├── curse_test.go ├── LICENSE.md ├── README.md └── curse.go /curse_test.go: -------------------------------------------------------------------------------- 1 | package curse 2 | 3 | import "testing" 4 | 5 | func TestMovementY(t *testing.T) { 6 | c := &Cursor{} 7 | c.MoveDown(2) 8 | c.MoveUp(5) 9 | newPosition := c.Position 10 | c.MoveDown(5).MoveUp(2) // restore 11 | 12 | if newPosition.Y != -3 { 13 | t.Errorf("Wrong Y position - got %d, want %d", newPosition, -3) 14 | } 15 | } 16 | 17 | func TestChainedMovementY(t *testing.T) { 18 | c := &Cursor{} 19 | c.MoveDown(1).MoveDown(1) 20 | newPosition := c.Position 21 | c.MoveUp(2) // restore 22 | 23 | if newPosition.Y != 2 { 24 | t.Errorf("Wrong Y position - got %d, want %d", newPosition.Y, 2) 25 | } 26 | } 27 | 28 | func TestEraseCurrentLine(t *testing.T) { 29 | c := &Cursor{} 30 | c.X = 12 31 | c.EraseCurrentLine() 32 | 33 | if c.Position.X != 1 { 34 | t.Errorf("Wrong X position - got %d, want %d", c.Position.X, 1) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Seth Ammons 2 | 3 | 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: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | 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. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Curse 2 | 3 | A utility for manipulating the terminal cursor. Current feature set: 4 | - Get terminal cursor position 5 | - Move cursor 6 | - Move up, down n-lines 7 | - Clear line 8 | - Clear Screen (up, down, all) 9 | - Set Color 10 | 11 | Basic Example usage (see below for an inline-progress bar): 12 | 13 | ```go 14 | package main 15 | 16 | import ( 17 | "fmt" 18 | "log" 19 | 20 | "github.com/sethgrid/curse" 21 | ) 22 | 23 | func main() { 24 | 25 | c, err := curse.New() 26 | if err != nil { 27 | log.Fatal(err) 28 | } 29 | 30 | c.SetColorBold(curse.RED).SetBackgroundColor(curse.BLACK) 31 | fmt.Println("Position: ", c.Position) 32 | c.SetDefaultStyle() 33 | fmt.Println("something to be erased") 34 | c.MoveUp(1).EraseCurrentLine().MoveDown(1) 35 | } 36 | ``` 37 | 38 | Progress Bar Example: 39 | 40 | ```go 41 | package main 42 | 43 | import ( 44 | "fmt" 45 | "strings" 46 | "time" 47 | 48 | "github.com/sethgrid/curse" 49 | ) 50 | 51 | func main() { 52 | fmt.Println("Progress Bar") 53 | total := 150 54 | progressBarWidth := 80 55 | c, _ := curse.New() 56 | 57 | // give some buffer space on the terminal 58 | fmt.Println() 59 | 60 | // display a progress bar 61 | for i := 0; i <= total; i++ { 62 | c.MoveUp(1) 63 | c.EraseCurrentLine() 64 | fmt.Printf("%d/%d ", i, total) 65 | 66 | c.MoveDown(1) 67 | c.EraseCurrentLine() 68 | fmt.Printf("%s", progressBar(i, total, progressBarWidth)) 69 | 70 | time.Sleep(time.Millisecond * 25) 71 | } 72 | // end the previous last line of output 73 | fmt.Println() 74 | fmt.Println("Complete") 75 | } 76 | 77 | func progressBar(progress, total, width int) string { 78 | bar := make([]string, width) 79 | for i, _ := range bar { 80 | if float32(progress)/float32(total) > float32(i)/float32(width) { 81 | bar[i] = "*" 82 | } else { 83 | bar[i] = " " 84 | } 85 | } 86 | return "[" + strings.Join(bar, "") + "]" 87 | } 88 | ``` 89 | -------------------------------------------------------------------------------- /curse.go: -------------------------------------------------------------------------------- 1 | package curse 2 | 3 | // http://en.wikipedia.org/wiki/ANSI_escape_code#Sequence_elements 4 | 5 | import ( 6 | "bufio" 7 | "errors" 8 | "fmt" 9 | "os" 10 | "os/exec" 11 | "regexp" 12 | "strconv" 13 | "strings" 14 | 15 | "github.com/kless/term" 16 | "golang.org/x/sys/unix" 17 | ) 18 | 19 | type Cursor struct { 20 | Position 21 | StartingPosition Position 22 | Style 23 | 24 | terminal *term.Terminal 25 | } 26 | 27 | type Position struct { 28 | X, Y int 29 | } 30 | 31 | type Style struct { 32 | Foreground, Background, Bold int 33 | } 34 | 35 | func New() (*Cursor, error) { 36 | col, line, err := GetCursorPosition() 37 | if err != nil { 38 | return &Cursor{}, err 39 | } 40 | 41 | c := &Cursor{} 42 | c.Position.X, c.StartingPosition.X = col, col 43 | c.Position.Y, c.StartingPosition.Y = line, line 44 | c.terminal, err = term.New() 45 | return c, err 46 | } 47 | 48 | func (c *Cursor) MoveUp(nLines int) *Cursor { 49 | fmt.Printf("%c[%dA", ESC, nLines) 50 | c.Position.Y -= nLines 51 | return c 52 | } 53 | 54 | func (c *Cursor) MoveDown(nLines int) *Cursor { 55 | fmt.Printf("%c[%dB", ESC, nLines) 56 | c.Position.Y += nLines 57 | return c 58 | } 59 | 60 | func (c *Cursor) MoveRight(nSpaces int) *Cursor { 61 | c.Position.X += nSpaces 62 | c.Move(c.Position.X, c.Position.Y) 63 | return c 64 | } 65 | 66 | func (c *Cursor) MoveLeft(nSpaces int) *Cursor { 67 | c.Position.X -= nSpaces 68 | c.Move(c.Position.X, c.Position.Y) 69 | return c 70 | } 71 | 72 | func (c *Cursor) EraseCurrentLine() *Cursor { 73 | fmt.Printf("%c[2K\r", ESC) 74 | c.Position.X = 1 75 | return c 76 | } 77 | 78 | func (c *Cursor) EraseUp() *Cursor { 79 | fmt.Printf("%c[1J", ESC) 80 | return c 81 | } 82 | 83 | func (c *Cursor) EraseDown() *Cursor { 84 | fmt.Printf("%c[0J", ESC) 85 | return c 86 | } 87 | 88 | func (c *Cursor) EraseAll() *Cursor { 89 | fmt.Printf("%c[0J", ESC) 90 | return c 91 | } 92 | 93 | func (c *Cursor) Reset() *Cursor { 94 | c.Move(c.StartingPosition.X, c.StartingPosition.Y) 95 | return c 96 | } 97 | 98 | func (c *Cursor) Move(col, line int) *Cursor { 99 | fmt.Printf("%c[%d;%df", ESC, line, col) 100 | c.Position.X = col 101 | c.Position.Y = line 102 | return c 103 | } 104 | 105 | func (c *Cursor) SetColor(color int) *Cursor { 106 | fmt.Printf("%c[%dm", ESC, FORGROUND+color) 107 | c.Style.Foreground = color 108 | c.Style.Bold = 0 109 | return c 110 | } 111 | 112 | func (c *Cursor) SetColorBold(color int) *Cursor { 113 | fmt.Printf("%c[%d;1m", ESC, FORGROUND+color) 114 | c.Style.Foreground = color 115 | c.Style.Bold = 1 116 | return c 117 | } 118 | 119 | func (c *Cursor) SetBackgroundColor(color int) *Cursor { 120 | fmt.Printf("%c[%dm", ESC, BACKGROUND+color) 121 | c.Style.Foreground = color 122 | c.Style.Bold = 0 123 | return c 124 | } 125 | 126 | func (c *Cursor) SetDefaultStyle() *Cursor { 127 | fmt.Printf("%c[39;49m", ESC) 128 | c.Style.Foreground = 0 129 | c.Style.Bold = 0 130 | return c 131 | } 132 | 133 | func (c *Cursor) ModeRaw() *Cursor { 134 | _ = c.terminal.RawMode() 135 | 136 | return c 137 | } 138 | 139 | func (c *Cursor) ModeRestore() *Cursor { 140 | _ = c.terminal.Restore() 141 | 142 | return c 143 | } 144 | 145 | // using named returns to help when using the method to know what is what 146 | func GetScreenDimensions() (cols int, lines int, err error) { 147 | ws, err := unix.IoctlGetWinsize(0, unix.TIOCGWINSZ) 148 | if err != nil { 149 | return -1, -1, err 150 | } 151 | return int(ws.Col), int(ws.Row), nil 152 | } 153 | 154 | func fallback_SetRawMode() { 155 | rawMode := exec.Command("/bin/stty", "raw") 156 | rawMode.Stdin = os.Stdin 157 | _ = rawMode.Run() 158 | rawMode.Wait() 159 | } 160 | 161 | func fallback_SetCookedMode() { 162 | // I've noticed that this does not always work when called from 163 | // inside the program. From command line, you can run the following 164 | // '$ go run calling_app.go; stty -raw' 165 | // if you lose the ability to visably enter new text 166 | cookedMode := exec.Command("/bin/stty", "-raw") 167 | cookedMode.Stdin = os.Stdin 168 | _ = cookedMode.Run() 169 | cookedMode.Wait() 170 | } 171 | 172 | func GetCursorPosition() (col int, line int, err error) { 173 | // set terminal to raw mode and back 174 | t, err := term.New() 175 | if err != nil { 176 | fallback_SetRawMode() 177 | defer fallback_SetCookedMode() 178 | } else { 179 | t.RawMode() 180 | defer t.Restore() 181 | } 182 | 183 | // same as $ echo -e "\033[6n" 184 | // by printing the output, we are triggering input 185 | fmt.Printf(fmt.Sprintf("\r%c[6n", ESC)) 186 | 187 | // capture keyboard output from print command 188 | reader := bufio.NewReader(os.Stdin) 189 | 190 | // capture the triggered stdin from the print 191 | text, _ := reader.ReadSlice('R') 192 | 193 | // check for the desired output 194 | re := regexp.MustCompile(`\d+;\d+`) 195 | res := re.FindString(string(text)) 196 | 197 | // make sure that cooked mode gets set 198 | if res != "" { 199 | parts := strings.Split(res, ";") 200 | line, _ = strconv.Atoi(parts[0]) 201 | col, _ = strconv.Atoi(parts[1]) 202 | return col, line, nil 203 | 204 | } else { 205 | return 0, 0, errors.New("unable to read cursor position") 206 | } 207 | } 208 | 209 | const ( 210 | // control 211 | ESC = 27 212 | 213 | // style 214 | BLACK = 0 215 | RED = 1 216 | GREEN = 2 217 | YELLOW = 3 218 | BLUE = 4 219 | MAGENTA = 5 220 | CYAN = 6 221 | WHITE = 7 222 | 223 | FORGROUND = 30 224 | BACKGROUND = 40 225 | ) 226 | --------------------------------------------------------------------------------