├── LICENSE ├── README.md ├── acs.go ├── box.go ├── box_sides.go ├── box_test.go ├── buffer.go ├── escape.go ├── examples ├── click │ └── main.go ├── grid │ └── main.go ├── hello │ └── main.go ├── popup │ └── main.go └── view │ └── main.go ├── go.mod ├── go.sum ├── goban.go ├── grid.go └── view.go /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 eihigh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # goban - minimal and concurrent CUI 2 | 3 | package goban (碁盤, meaning of Go game board in Japanese) provides CUI with simple API. 4 | 5 | ## Hello World 6 | 7 | ```go 8 | package main 9 | 10 | import ( 11 | "context" 12 | 13 | "github.com/eihigh/goban" 14 | ) 15 | 16 | func main() { 17 | goban.Main(app, view) 18 | } 19 | 20 | func app(_ context.Context, es goban.Events) error { 21 | goban.Show() 22 | es.ReadKey() 23 | return nil 24 | } 25 | 26 | func view() { 27 | goban.Screen().Enclose("hello").Prints("Hello World!\nPress any key to exit.") 28 | } 29 | ``` 30 | 31 | ## Features 32 | 33 | * Minimal API 34 | * Isolated Views and Controllers 35 | * Receive events from channel instead of event handlers 36 | * Color with escape sequences 37 | * Box drawings 38 | * Grid layouts 39 | 40 | ## Status 41 | 42 | goban is under active development. The API is subject to change. 43 | 44 | ## TODO 45 | 46 | * [ ] Flexbox layouts 47 | * [ ] More widgets 48 | * [ ] Mouse support 49 | 50 | ## Documentation 51 | 52 | See https://godoc.org/github.com/eihigh/goban . 53 | 54 | ## Dependencies 55 | 56 | This package is based on github.com/gdamore/tcell . 57 | -------------------------------------------------------------------------------- /acs.go: -------------------------------------------------------------------------------- 1 | package goban 2 | 3 | type acs int 4 | 5 | const ( 6 | _ acs = 1 << iota 7 | acsL 8 | acsT 9 | acsR 10 | acsB 11 | ) 12 | 13 | const ( 14 | acsH = '─' 15 | acsV = '│' 16 | acsLT = '┘' 17 | acsLB = '┐' 18 | acsTR = '└' 19 | acsRB = '┌' 20 | acsLTB = '┤' 21 | acsTLR = '┴' 22 | acsRTB = '├' 23 | acsBLR = '┬' 24 | acsAll = '┼' 25 | ) 26 | 27 | var ( 28 | acsList = []struct { 29 | flag acs 30 | r rune 31 | }{ 32 | {acsL | acsR, acsH}, 33 | {acsT | acsB, acsV}, 34 | {acsL | acsT, acsLT}, 35 | {acsL | acsB, acsLB}, 36 | {acsT | acsR, acsTR}, 37 | {acsR | acsB, acsRB}, 38 | {acsL | acsT | acsB, acsLTB}, 39 | {acsL | acsT | acsR, acsTLR}, 40 | {acsR | acsT | acsB, acsRTB}, 41 | {acsB | acsL | acsR, acsBLR}, 42 | {acsL | acsT | acsR | acsB, acsAll}, 43 | } 44 | ) 45 | 46 | func (a acs) Rune() rune { 47 | for _, pair := range acsList { 48 | if a&(acsL|acsT|acsR|acsB) == pair.flag { 49 | return pair.r 50 | } 51 | } 52 | return 0 53 | } 54 | 55 | func rune2acs(r rune) acs { 56 | for _, pair := range acsList { 57 | if pair.r == r { 58 | return pair.flag 59 | } 60 | } 61 | return 0 62 | } 63 | -------------------------------------------------------------------------------- /box.go: -------------------------------------------------------------------------------- 1 | package goban 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "strings" 8 | 9 | "github.com/gdamore/tcell" 10 | "github.com/mattn/go-runewidth" 11 | ) 12 | 13 | var ( 14 | errOutOfBox = fmt.Errorf("position outside of the box") 15 | ) 16 | 17 | // Point represents a point on the screen. 18 | type Point struct { 19 | X, Y int 20 | } 21 | 22 | // Box represents an area on the screen and provides 23 | // various drawing and layout functions. 24 | type Box struct { 25 | Pos Point 26 | Size Point 27 | Scroll Point 28 | Style tcell.Style 29 | 30 | cursor Point 31 | } 32 | 33 | type runeReader interface { 34 | io.RuneReader 35 | UnreadRune() error 36 | } 37 | 38 | // NewBox returns a box at the specified position and size. 39 | func NewBox(x, y, w, h int) *Box { 40 | return &Box{ 41 | Pos: Point{x, y}, 42 | Size: Point{w, h}, 43 | } 44 | } 45 | 46 | // Screen returns a box of screen size. 47 | func Screen() *Box { 48 | w, h := screen.Size() 49 | return &Box{ 50 | Pos: Point{0, 0}, 51 | Size: Point{w, h}, 52 | } 53 | } 54 | 55 | func (b *Box) Fit(dst *Box, dstx, dsty, srcx, srcy float64) *Box { 56 | spx, spy := b.rel(srcx, srcy) 57 | dpx, dpy := dst.rel(dstx, dsty) 58 | dx, dy := dpx-spx, dpy-spy 59 | return NewBox(b.Pos.X+dx, b.Pos.Y+dy, b.Size.X, b.Size.Y) 60 | } 61 | 62 | func (b *Box) CenterOf(dst *Box) *Box { 63 | return b.Fit(dst, 0, 0, 0, 0) 64 | } 65 | 66 | func (b *Box) rel(x, y float64) (int, int) { 67 | x = x/2 + 0.5 // [-1, 1] => [0, 1] 68 | y = y/2 + 0.5 69 | px := b.Pos.X + int(float64(b.Size.X)*x) 70 | py := b.Pos.Y + int(float64(b.Size.Y)*y) 71 | return px, py 72 | } 73 | 74 | func (b *Box) IsClicked(e *tcell.EventMouse) bool { 75 | if b == nil { 76 | return false 77 | } 78 | if e.Buttons()&tcell.Button1 == 0 { 79 | return false 80 | } 81 | x, y := e.Position() 82 | ax, ay := b.Pos.X, b.Pos.Y 83 | bx, by := ax+b.Size.X, ay+b.Size.Y 84 | if ax <= x && x < bx && ay <= y && y < by { 85 | return true 86 | } 87 | return false 88 | } 89 | 90 | func (b *Box) Clear() { 91 | for x := b.Pos.X; x < b.Pos.X+b.Size.X; x++ { 92 | for y := b.Pos.Y; y < b.Pos.Y+b.Size.Y; y++ { 93 | screen.SetContent(x, y, ' ', nil, b.Style) 94 | } 95 | } 96 | } 97 | 98 | func (b *Box) Write(p []byte) (int, error) { 99 | buf := bytes.NewBuffer(p) 100 | err := b.print(buf) 101 | return len(p), err 102 | } 103 | 104 | func (b *Box) Print(a ...interface{}) { 105 | b.Prints(fmt.Sprint(a...)) 106 | } 107 | 108 | func (b *Box) Printf(format string, a ...interface{}) { 109 | b.Prints(fmt.Sprintf(format, a...)) 110 | } 111 | 112 | func (b *Box) Println(a ...interface{}) { 113 | b.Prints(fmt.Sprintln(a...)) 114 | } 115 | 116 | func (b *Box) Puts(s string) { 117 | b.Prints(s + "\n") 118 | } 119 | 120 | func (b *Box) Prints(s string) { 121 | b.print(strings.NewReader(s)) 122 | } 123 | 124 | func (b *Box) print(reader runeReader) error { 125 | for { 126 | r, _, err := reader.ReadRune() 127 | if err != nil { 128 | break 129 | } 130 | 131 | switch r { 132 | case '\r': 133 | b.newLine() 134 | b.cursor.Y-- 135 | case '\n': 136 | b.newLine() 137 | case 0x1b: // escape sequence 138 | b.escape(reader) 139 | 140 | default: 141 | x, y, err := b.actualPoint() 142 | if err == nil { 143 | screen.SetContent(x, y, r, nil, b.Style) 144 | w := runewidth.RuneWidth(r) 145 | b.cursor.X += w 146 | } 147 | } 148 | } 149 | 150 | return nil 151 | } 152 | 153 | func (b *Box) actualPoint() (x, y int, err error) { 154 | // virtual point 155 | vx, vy := b.cursor.X-b.Scroll.X, b.cursor.Y-b.Scroll.Y 156 | if vx < 0 || vy < 0 || vx >= b.Size.X || vy >= b.Size.Y { 157 | return 0, 0, errOutOfBox 158 | } 159 | 160 | // actual point 161 | x, y = b.Pos.X+vx, b.Pos.Y+vy 162 | return 163 | } 164 | 165 | func (b *Box) newLine() { 166 | for { 167 | x, y, err := b.actualPoint() 168 | if err != nil { 169 | break 170 | } 171 | screen.SetContent(x, y, ' ', nil, b.Style) 172 | b.cursor.X++ 173 | } 174 | b.cursor.X = 0 175 | b.cursor.Y++ 176 | } 177 | 178 | func (b *Box) escape(rd runeReader) { 179 | s := scanner{ 180 | rd: rd, 181 | b: b, 182 | } 183 | s.scan() 184 | rd.UnreadRune() 185 | } 186 | -------------------------------------------------------------------------------- /box_sides.go: -------------------------------------------------------------------------------- 1 | package goban 2 | 3 | func (b *Box) Enclose(title string) *Box { 4 | newb := b.DrawSides(title, 1, 1, 1, 1) 5 | newb.Clear() 6 | return newb 7 | } 8 | 9 | func (b *Box) DrawSides(title string, left, top, right, bottom int) *Box { 10 | newb := b.InsideSides(left, top, right, bottom) 11 | 12 | if left != 0 { 13 | x := newb.Pos.X - 1 14 | for y := 0; y < newb.Size.Y; y++ { 15 | screen.SetContent(x, y+newb.Pos.Y, '│', nil, b.Style) 16 | } 17 | } 18 | 19 | if right != 0 { 20 | x := newb.Pos.X + newb.Size.X 21 | for y := 0; y < newb.Size.Y; y++ { 22 | screen.SetContent(x, y+newb.Pos.Y, '│', nil, b.Style) 23 | } 24 | } 25 | 26 | if top != 0 { 27 | y := newb.Pos.Y - 1 28 | for x := 0; x < newb.Size.X; x++ { 29 | screen.SetContent(x+newb.Pos.X, y, '─', nil, b.Style) 30 | } 31 | } 32 | 33 | if bottom != 0 { 34 | y := newb.Pos.Y + newb.Size.Y 35 | for x := 0; x < newb.Size.X; x++ { 36 | screen.SetContent(x+newb.Pos.X, y, '─', nil, b.Style) 37 | } 38 | } 39 | 40 | ax := b.Pos.X 41 | ay := b.Pos.Y 42 | bx := ax 43 | by := ay 44 | if b.Size.X > 0 { 45 | bx += b.Size.X - 1 46 | } 47 | if b.Size.Y > 0 { 48 | by += b.Size.Y - 1 49 | } 50 | 51 | if left != 0 { 52 | if top == 0 { 53 | b.joinACS(ax, ay-1) 54 | } else { 55 | b.setACS(ax, ay, '┌') 56 | } 57 | if bottom == 0 { 58 | b.joinACS(ax, by+1) 59 | } else { 60 | b.setACS(ax, by, '└') 61 | } 62 | } 63 | if right != 0 { 64 | if top == 0 { 65 | b.joinACS(bx, ay-1) 66 | } else { 67 | b.setACS(bx, ay, '┐') 68 | } 69 | if bottom == 0 { 70 | b.joinACS(bx, by+1) 71 | } else { 72 | b.setACS(bx, by, '┘') 73 | } 74 | } 75 | if top != 0 { 76 | if left == 0 { 77 | b.joinACS(ax-1, ay) 78 | } 79 | if right == 0 { 80 | b.joinACS(bx+1, ay) 81 | } 82 | } 83 | if bottom != 0 { 84 | if left == 0 { 85 | b.joinACS(ax-1, by) 86 | } 87 | if right == 0 { 88 | b.joinACS(bx+1, by) 89 | } 90 | } 91 | 92 | // draw title 93 | tb := NewBox(b.Pos.X+1, b.Pos.Y, b.Size.X-1, 1) 94 | if left != 0 { 95 | tb.Pos.X++ 96 | tb.Size.X-- 97 | } 98 | tb.Prints(title) 99 | 100 | return newb 101 | } 102 | 103 | func (b *Box) InsideSides(left, top, right, bottom int) *Box { 104 | newb := NewBox(b.Pos.X, b.Pos.Y, b.Size.X, b.Size.Y) 105 | 106 | if left != 0 { 107 | newb.Pos.X++ 108 | newb.Size.X-- 109 | } 110 | 111 | if right != 0 { 112 | newb.Size.X-- 113 | } 114 | 115 | if top != 0 { 116 | newb.Pos.Y++ 117 | newb.Size.Y-- 118 | } 119 | 120 | if bottom != 0 { 121 | newb.Size.Y-- 122 | } 123 | return newb 124 | } 125 | 126 | func (b *Box) setACS(x, y int, r rune) { 127 | a0 := acsAt(x, y) 128 | a1 := rune2acs(r) 129 | screen.SetContent(x, y, (a0 | a1).Rune(), nil, b.Style) 130 | } 131 | 132 | func (b *Box) joinACS(x, y int) { 133 | me := acsAt(x, y) 134 | 135 | if acsAt(x-1, y)&acsR != 0 { 136 | me = me | acsL 137 | } else { 138 | me = me &^ acsL 139 | } 140 | 141 | if acsAt(x, y-1)&acsB != 0 { 142 | me = me | acsT 143 | } else { 144 | me = me &^ acsT 145 | } 146 | 147 | if acsAt(x+1, y)&acsL != 0 { 148 | me = me | acsR 149 | } else { 150 | me = me &^ acsR 151 | } 152 | 153 | if acsAt(x, y+1)&acsT != 0 { 154 | me = me | acsB 155 | } else { 156 | me = me &^ acsB 157 | } 158 | 159 | screen.SetContent(x, y, me.Rune(), nil, b.Style) 160 | } 161 | 162 | func acsAt(x, y int) acs { 163 | r, _, _, _ := screen.GetContent(x, y) 164 | return rune2acs(r) 165 | } 166 | -------------------------------------------------------------------------------- /box_test.go: -------------------------------------------------------------------------------- 1 | package goban 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestFit(t *testing.T) { 9 | a := NewBox(0, 0, 10, 10) 10 | b := NewBox(10, 10, 10, 10) 11 | 12 | tests := []struct { 13 | got, want *Box 14 | }{ 15 | {a.Fit(b, -1, -1, -1, -1), NewBox(10, 10, 10, 10)}, 16 | {a.Fit(b, -1, -1, 0, 0), NewBox(5, 5, 10, 10)}, 17 | {a.Fit(b, 0, 0, 0, 0), NewBox(10, 10, 10, 10)}, 18 | {a.Fit(b, 1, 1, -1, -1), NewBox(20, 20, 10, 10)}, 19 | } 20 | 21 | for _, tt := range tests { 22 | if !reflect.DeepEqual(tt.want, tt.got) { 23 | t.Errorf("got %v; want %v", tt.got, tt.want) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /buffer.go: -------------------------------------------------------------------------------- 1 | package goban 2 | 3 | import ( 4 | "bufio" 5 | "strings" 6 | 7 | "github.com/mattn/go-runewidth" 8 | ) 9 | 10 | type Align int 11 | 12 | const ( 13 | AlignStart Align = iota 14 | AlignCenter 15 | AlignEnd 16 | ) 17 | 18 | type Buffer struct { 19 | VAlign, HAlign Align 20 | lines []string 21 | } 22 | 23 | func (b *Buffer) Flush(box *Box) { 24 | for y, line := range b.lines { 25 | w := runewidth.StringWidth(line) 26 | x := 0 27 | switch b.HAlign { 28 | case AlignCenter: 29 | x += box.Size.X/2 - w/2 30 | case AlignEnd: 31 | x += box.Size.X - w 32 | } 33 | box.cursor.X = x 34 | box.cursor.Y = y 35 | box.Prints(line) 36 | } 37 | } 38 | 39 | func (b *Buffer) Prints(s string) { 40 | r := bufio.NewScanner(strings.NewReader(s)) 41 | for r.Scan() { 42 | b.lines = append(b.lines, r.Text()) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /escape.go: -------------------------------------------------------------------------------- 1 | package goban 2 | 3 | import ( 4 | "io" 5 | "strconv" 6 | 7 | "github.com/gdamore/tcell" 8 | ) 9 | 10 | type scanner struct { 11 | rd io.RuneReader 12 | ch rune 13 | b *Box 14 | } 15 | 16 | func (s *scanner) next() { 17 | s.ch, _, _ = s.rd.ReadRune() 18 | } 19 | 20 | func (s *scanner) scan() { 21 | s.next() // skip '\e' 22 | if s.ch != '[' { 23 | return 24 | } 25 | s.next() // skip '[' 26 | 27 | params := []int{} 28 | for { 29 | switch { 30 | case '0' <= s.ch && s.ch <= '9': 31 | params = append(params, s.scanNumber()) 32 | case s.ch == 'm': 33 | s.apply(params) 34 | s.next() 35 | case s.ch == ';': 36 | s.next() 37 | default: 38 | return 39 | } 40 | } 41 | } 42 | 43 | func (s *scanner) scanNumber() int { 44 | str := string(s.ch) 45 | 46 | loop: 47 | for { 48 | s.next() 49 | switch { 50 | case '0' <= s.ch && s.ch <= '9': 51 | str += string(s.ch) 52 | default: 53 | break loop 54 | } 55 | } 56 | 57 | n, _ := strconv.Atoi(str) 58 | return n 59 | } 60 | 61 | func (s *scanner) apply(params []int) { 62 | for i := 0; i < len(params); i++ { 63 | param := params[i] 64 | 65 | switch { 66 | case param == 0: 67 | s.b.Style = tcell.StyleDefault 68 | case param == 1: 69 | s.b.Style = s.b.Style.Bold(true) 70 | case param == 4: 71 | s.b.Style = s.b.Style.Underline(true) 72 | 73 | case 30 <= param && param <= 37: 74 | s.b.Style = s.b.Style.Foreground(tcell.Color(param - 30)) 75 | case 40 <= param && param <= 47: 76 | s.b.Style = s.b.Style.Background(tcell.Color(param - 40)) 77 | case 90 <= param && param <= 97: 78 | s.b.Style = s.b.Style.Foreground(tcell.Color(param - 90)) 79 | case 100 <= param && param <= 107: 80 | s.b.Style = s.b.Style.Background(tcell.Color(param - 100)) 81 | 82 | case param == 38: 83 | n, c := extColor(params[i+1:]) 84 | s.b.Style = s.b.Style.Foreground(c) 85 | i += n 86 | case param == 48: 87 | n, c := extColor(params[i+1:]) 88 | s.b.Style = s.b.Style.Background(c) 89 | i += n 90 | } 91 | } 92 | } 93 | 94 | func extColor(p []int) (int, tcell.Color) { 95 | switch x, xs := shift(p); x { 96 | case 5: // 256 colors 97 | code, _ := shift(xs) 98 | if code == -1 { 99 | return 0, tcell.ColorDefault 100 | } 101 | return 2, tcell.Color(code) 102 | 103 | case 2: 104 | r, xs := shift(xs) 105 | g, xs := shift(xs) 106 | b, _ := shift(xs) 107 | if r != -1 && g != -1 && b != -1 { 108 | return 4, tcell.NewRGBColor(r, g, b) 109 | } 110 | } 111 | 112 | return 0, tcell.ColorDefault 113 | } 114 | 115 | func shift(s []int) (int32, []int) { 116 | if len(s) == 0 { 117 | return -1, s[:0] 118 | } 119 | return int32(s[0]), s[1:] 120 | } 121 | -------------------------------------------------------------------------------- /examples/click/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eihigh/goban" 7 | ) 8 | 9 | var ( 10 | text = "click here" 11 | 12 | grid = goban.NewGrid( 13 | " 1fr 1fr", 14 | "1fr msg msg", 15 | "3em cancel ok", 16 | ) 17 | ) 18 | 19 | func main() { 20 | goban.Main(app) 21 | } 22 | 23 | func app(_ context.Context, es goban.Events) error { 24 | v := &buttonView{} 25 | goban.PushView(v) 26 | 27 | for { 28 | goban.Show() 29 | switch e := es.ReadMouse(); { 30 | case v.cancel.IsClicked(e): 31 | text = "canceled" 32 | case v.ok.IsClicked(e): 33 | text = "ok" 34 | } 35 | } 36 | } 37 | 38 | type buttonView struct { 39 | cancel, ok *goban.Box 40 | } 41 | 42 | func (v *buttonView) View() { 43 | b := goban.NewBox(0, 0, 30, 7).CenterOf(goban.Screen()).Enclose("") 44 | msgBox := b.GridItem(grid, "msg") 45 | buf := goban.Buffer{ 46 | HAlign: goban.AlignCenter, 47 | } 48 | buf.Prints(text) 49 | buf.Flush(msgBox) 50 | 51 | okBox := goban.NewBox(0, 0, 4, 1).CenterOf(b.GridItem(grid, "ok")) 52 | okBox.Prints("[ok]") 53 | v.ok = okBox 54 | 55 | cancelBox := goban.NewBox(0, 0, 8, 1).CenterOf(b.GridItem(grid, "cancel")) 56 | cancelBox.Prints("[cancel]") 57 | v.cancel = cancelBox 58 | } 59 | -------------------------------------------------------------------------------- /examples/grid/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eihigh/goban" 7 | ) 8 | 9 | var ( 10 | grid = goban.NewGrid( 11 | " 1fr 1fr 1fr ", 12 | "1fr header header header", 13 | "3fr side content ", 14 | "1fr footer footer footer", 15 | ) 16 | ) 17 | 18 | func main() { 19 | goban.Main(app, view) 20 | } 21 | 22 | func app(_ context.Context, es goban.Events) error { 23 | goban.Show() 24 | es.ReadKey() 25 | return nil 26 | } 27 | 28 | func view() { 29 | b := goban.Screen().Enclose("") 30 | header := b.GridItem(grid, "header").DrawSides("", 0, 0, 0, 1) 31 | header.Prints("Header") 32 | footer := b.GridItem(grid, "footer").DrawSides("", 0, 1, 0, 0) 33 | footer.Prints("Footer") 34 | side := b.GridItem(grid, "side").DrawSides("", 0, 0, 1, 0) 35 | side.Prints("Side") 36 | content := b.GridItem(grid, "content").DrawSides("", 0, 0, 1, 0) 37 | content.Prints("Main Content") 38 | } 39 | -------------------------------------------------------------------------------- /examples/hello/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eihigh/goban" 7 | ) 8 | 9 | func main() { 10 | goban.Main(app, view) 11 | } 12 | 13 | func app(_ context.Context, es goban.Events) error { 14 | goban.Show() 15 | es.ReadKey() 16 | return nil 17 | } 18 | 19 | func view() { 20 | goban.Screen().Enclose("hello").Prints("Hello World!\nPress any key to exit.") 21 | } 22 | -------------------------------------------------------------------------------- /examples/popup/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eihigh/goban" 7 | ) 8 | 9 | func main() { 10 | goban.Main(app) 11 | } 12 | 13 | func app(_ context.Context, es goban.Events) error { 14 | v := func() { 15 | b := goban.Screen() 16 | b.Puts("Press any key to open popup") 17 | b.Puts("Ctrl+C to exit") 18 | } 19 | goban.PushViewFunc(v) 20 | 21 | for { 22 | goban.Show() 23 | es.ReadKey() 24 | popup(es) 25 | } 26 | } 27 | 28 | func popup(es goban.Events) { 29 | v := func() { 30 | b := goban.NewBox(0, 0, 40, 5).Enclose("popup window") 31 | b.Prints("Press any key to close popup") 32 | } 33 | 34 | // This is the recommended way to use `PushView` and `defer PopView` 35 | // when using modal views such as popup. 36 | goban.PushViewFunc(v) 37 | defer goban.PopView() 38 | 39 | goban.Show() 40 | es.ReadKey() 41 | } 42 | -------------------------------------------------------------------------------- /examples/view/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eihigh/goban" 7 | "github.com/gdamore/tcell" 8 | ) 9 | 10 | func main() { 11 | goban.Main(app) 12 | } 13 | 14 | func app(_ context.Context, es goban.Events) error { 15 | v := &menuView{ 16 | items: []string{ 17 | "foo", "bar", "baz", 18 | }, 19 | } 20 | goban.PushView(v) 21 | 22 | for { 23 | goban.Show() 24 | switch k := es.ReadKey(); k.Key() { 25 | case tcell.KeyUp: 26 | if v.cursor > 0 { 27 | v.cursor-- 28 | } 29 | case tcell.KeyDown: 30 | if v.cursor < len(v.items)-1 { 31 | v.cursor++ 32 | } 33 | } 34 | } 35 | } 36 | 37 | // view model implements goban.View. 38 | type menuView struct { 39 | cursor int 40 | items []string 41 | } 42 | 43 | func (v *menuView) View() { 44 | b := goban.NewBox(0, 0, 50, 20).Enclose("menu") 45 | b.Puts("↑, ↓: move cursor") 46 | for i, item := range v.items { 47 | if i == v.cursor { 48 | b.Print("> ") 49 | } 50 | b.Puts(item) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/eihigh/goban 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/gdamore/tcell v1.1.4 7 | github.com/mattn/go-runewidth v0.0.4 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/DATA-DOG/go-sqlmock v1.3.3 h1:CWUqKXe0s8A2z6qCgkP4Kru7wC11YoAnoupUKFDnH08= 2 | github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= 3 | github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= 4 | github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= 5 | github.com/gdamore/tcell v1.1.4 h1:6Bubmk3vZvnL9umQ9qTV2kwNQnjaZ4HLAbxR+xR3ATg= 6 | github.com/gdamore/tcell v1.1.4/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebKS4zMM= 7 | github.com/lucasb-eyer/go-colorful v1.0.2 h1:mCMFu6PgSozg9tDNMMK3g18oJBX7oYGrC09mS6CXfO4= 8 | github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s= 9 | github.com/mattn/go-runewidth v0.0.4 h1:2BvfKmzob6Bmd4YsL0zygOqfdFnK7GR4QL06Do4/p7Y= 10 | github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= 11 | golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756 h1:9nuHUbU8dRnRRfj9KjWUVrJeoexdbeMjttk6Oh1rD10= 12 | golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 13 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 14 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 15 | -------------------------------------------------------------------------------- /goban.go: -------------------------------------------------------------------------------- 1 | package goban 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "os/signal" 8 | "sync" 9 | "syscall" 10 | 11 | "github.com/gdamore/tcell" 12 | ) 13 | 14 | var ( 15 | screen tcell.Screen 16 | views []View 17 | m sync.Mutex 18 | ) 19 | 20 | var ( 21 | // ErrAborted represents that the program was aborted 22 | // for some reason, such as Ctrl+C or OS signals. 23 | ErrAborted = fmt.Errorf("program aborted") 24 | ) 25 | 26 | // Events is an alias of chan tcell.Event and 27 | // provides some reading functions. 28 | type Events chan tcell.Event 29 | 30 | // ReadKey waits for a key event to the channel and returns it. 31 | // Other events are ignored. 32 | func (es Events) ReadKey() *tcell.EventKey { 33 | for { 34 | e := <-es 35 | if e, ok := e.(*tcell.EventKey); ok { 36 | return e 37 | } 38 | } 39 | } 40 | 41 | // ReadMouse waits for a mouse event to the channel and returns it. 42 | // Other events are ignored. 43 | func (es Events) ReadMouse() *tcell.EventMouse { 44 | for { 45 | e := <-es 46 | if e, ok := e.(*tcell.EventMouse); ok { 47 | return e 48 | } 49 | } 50 | } 51 | 52 | // Show calls each View to refresh the screen. 53 | func Show() { 54 | screen.Clear() 55 | for _, v := range views { 56 | v.View() 57 | } 58 | screen.Show() 59 | } 60 | 61 | // Sync is similar to Show, but calls tcell.Screen's Sync instead. 62 | func Sync() { 63 | screen.Clear() 64 | for _, v := range views { 65 | v.View() 66 | } 67 | screen.Sync() 68 | } 69 | 70 | // Main runs the main process. 71 | // When the app exits, Main also exits. 72 | // If cancelled for any other reason, Main exits and the 73 | // cancellation is propagated to the context. 74 | func Main(app func(context.Context, Events) error, viewfns ...func()) error { 75 | m.Lock() 76 | defer m.Unlock() 77 | 78 | var err error 79 | 80 | tcell.SetEncodingFallback(tcell.EncodingFallbackASCII) 81 | screen, err = tcell.NewScreen() 82 | if err != nil { 83 | return err 84 | } 85 | defer screen.Fini() 86 | 87 | if err = screen.Init(); err != nil { 88 | return err 89 | } 90 | 91 | screen.SetStyle(tcell.StyleDefault) 92 | screen.EnableMouse() 93 | screen.Clear() 94 | 95 | for _, f := range viewfns { 96 | PushViewFunc(f) 97 | } 98 | 99 | events := make(Events) 100 | once := &sync.Once{} 101 | done := make(chan struct{}) 102 | ctx, cancel := context.WithCancel(context.Background()) 103 | sigc := make(chan os.Signal) 104 | signal.Notify(sigc, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT) 105 | 106 | go func() { 107 | e := app(ctx, events) 108 | once.Do(func() { 109 | err = e 110 | close(done) 111 | }) 112 | }() 113 | go func() { 114 | e := poll(ctx, events) 115 | once.Do(func() { 116 | err = e 117 | close(done) 118 | }) 119 | }() 120 | go func() { 121 | <-sigc 122 | once.Do(func() { 123 | err = ErrAborted 124 | close(done) 125 | }) 126 | }() 127 | 128 | <-done 129 | cancel() 130 | 131 | return err 132 | } 133 | 134 | func poll(ctx context.Context, es Events) error { 135 | for { 136 | select { 137 | case <-ctx.Done(): 138 | return nil 139 | default: 140 | } 141 | e := screen.PollEvent() 142 | switch e := e.(type) { 143 | case *tcell.EventKey: 144 | switch e.Key() { 145 | case tcell.KeyCtrlC: 146 | return ErrAborted 147 | } 148 | } 149 | go func() { 150 | es <- e 151 | }() 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /grid.go: -------------------------------------------------------------------------------- 1 | package goban 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | ) 7 | 8 | type Length struct { 9 | Unit LengthUnit 10 | Value int 11 | } 12 | 13 | type LengthUnit int 14 | 15 | const ( 16 | Em LengthUnit = iota 17 | Fr 18 | ) 19 | 20 | type item struct { 21 | left, top, right, bottom int 22 | } 23 | 24 | type Grid struct { 25 | Cols, Rows []Length 26 | items map[string]*item 27 | } 28 | 29 | func NewGrid(layout ...string) *Grid { 30 | g := &Grid{ 31 | items: map[string]*item{}, 32 | } 33 | 34 | if len(layout) == 0 { 35 | return g 36 | } 37 | 38 | head := layout[0] 39 | 40 | // col tracks 41 | for _, col := range strings.Fields(head) { 42 | l := parseLength(col) 43 | g.Cols = append(g.Cols, l) 44 | } 45 | 46 | for y, row := range layout[1:] { 47 | cells := strings.Fields(row) 48 | head := cells[0] 49 | l := parseLength(head) 50 | g.Rows = append(g.Rows, l) 51 | 52 | for x, name := range cells[1:] { 53 | if a, ok := g.items[name]; ok { 54 | if a.right <= x { 55 | a.right = x + 1 56 | } 57 | if a.bottom <= y { 58 | a.bottom = y + 1 59 | } 60 | } else { 61 | g.items[name] = &item{ 62 | left: x, 63 | top: y, 64 | right: x + 1, 65 | bottom: y + 1, 66 | } 67 | } 68 | } 69 | } 70 | 71 | return g 72 | } 73 | 74 | func parseLength(s string) Length { 75 | for u := Em; u <= Fr; u++ { 76 | suffix := u.String() 77 | if strings.HasSuffix(s, suffix) { 78 | x, err := strconv.Atoi(strings.TrimSuffix(s, suffix)) 79 | if err != nil { 80 | panic(err) 81 | } 82 | return Length{u, x} 83 | } 84 | } 85 | panic("undefined length name") 86 | } 87 | 88 | func (u LengthUnit) String() string { 89 | switch u { 90 | case Em: 91 | return "em" 92 | case Fr: 93 | return "fr" 94 | default: 95 | return "" 96 | } 97 | } 98 | 99 | func (b *Box) GridItem(g *Grid, name string) *Box { 100 | a := g.items[name] 101 | return b.GridCell(g, a.left, a.top, a.right, a.bottom) 102 | } 103 | 104 | func (b *Box) GridCell(g *Grid, left, top, right, bottom int) *Box { 105 | cols := absLengths(b.Size.X, g.Cols) 106 | rows := absLengths(b.Size.Y, g.Rows) 107 | 108 | left = cols[left] 109 | top = rows[top] 110 | right = cols[right] 111 | bottom = rows[bottom] 112 | 113 | return NewBox(b.Pos.X+left, b.Pos.Y+top, right-left, bottom-top) 114 | } 115 | 116 | func NFr(n int) Length { 117 | return Length{Fr, n} 118 | } 119 | 120 | func NEm(n int) Length { 121 | return Length{Em, n} 122 | } 123 | 124 | func absLengths(total int, lens []Length) []int { 125 | x := total 126 | frs := 0 127 | abs := []int{0} 128 | for _, l := range lens { 129 | if l.Unit == Fr { 130 | frs += l.Value 131 | } else { 132 | x -= l.Value // Em 133 | } 134 | } 135 | 136 | last := 0 137 | for _, l := range lens { 138 | if l.Unit == Fr { 139 | last += x / frs * l.Value 140 | } else { 141 | last += l.Value 142 | } 143 | abs = append(abs, last) 144 | } 145 | 146 | if lens[len(lens)-1].Unit == Fr { 147 | abs[len(abs)-1] = total 148 | } 149 | return abs 150 | } 151 | -------------------------------------------------------------------------------- /view.go: -------------------------------------------------------------------------------- 1 | package goban 2 | 3 | // View represents a drawing function. 4 | type View interface { 5 | View() 6 | } 7 | 8 | // PushView pushes the view on top. 9 | func PushView(v View) { 10 | views = append(views, v) 11 | } 12 | 13 | // PopView pops the view on top. 14 | func PopView() { 15 | views = views[:len(views)-1] 16 | } 17 | 18 | // RemoveView removes the specified view. 19 | func RemoveView(v View) { 20 | is := []int{} 21 | for i, view := range views { 22 | if v == view { 23 | is = append(is, i) 24 | break 25 | } 26 | } 27 | if len(is) == 0 { 28 | return 29 | } 30 | for _, i := range is { 31 | views = append(views[:i], views[i+1:]...) 32 | } 33 | } 34 | 35 | type viewFunc func() 36 | 37 | func (f viewFunc) View() { 38 | f() 39 | } 40 | 41 | // PushViewFunc pushes the function as view on top. 42 | func PushViewFunc(f func()) { 43 | PushView(viewFunc(f)) 44 | } 45 | --------------------------------------------------------------------------------