├── .gitignore ├── example ├── chat │ ├── README.md │ ├── screenshot.png │ └── main.go ├── http │ ├── README.md │ ├── screenshot.png │ └── main.go ├── mail │ ├── README.md │ ├── screenshot.png │ └── main.go ├── color │ ├── README.md │ ├── screenshot.png │ └── main.go ├── editor │ ├── README.md │ ├── screenshot.png │ └── main.go ├── login │ ├── README.md │ ├── screenshot.png │ └── main.go ├── scroll │ ├── README.md │ ├── screenshot.png │ └── main.go ├── multiview │ ├── README.md │ ├── screenshot.png │ └── main.go ├── audioplayer │ ├── README.md │ ├── screenshot.png │ └── main.go ├── tabs │ ├── screenshot.gif │ ├── README.md │ └── main.go └── README.md ├── .github └── FUNDING.yml ├── math.go ├── keybinding.go ├── .travis.yml ├── logger.go ├── progress_test.go ├── CONTRIBUTING.md ├── text.go ├── spacer.go ├── go.mod ├── keybinding_test.go ├── ui.go ├── button_test.go ├── doc.go ├── LICENSE ├── text_edit_test.go ├── statusbar.go ├── theme_test.go ├── progress.go ├── list_test.go ├── text_test.go ├── wordwrap ├── wordwrap.go └── wordwrap_test.go ├── button.go ├── scroll_area.go ├── padder.go ├── cjk_test.go ├── focus.go ├── go.sum ├── widget.go ├── theme.go ├── label.go ├── label_test.go ├── CODE_OF_CONDUCT.md ├── table_test.go ├── README.md ├── text_edit.go ├── label_sizehint_test.go ├── list.go ├── table.go ├── runebuf.go ├── testing.go ├── painter.go ├── entry.go ├── grid_test.go ├── ui_tcell.go ├── scroll_area_test.go ├── runebuf_test.go ├── event.go ├── box.go ├── painter_test.go ├── grid.go ├── entry_test.go └── box_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | tags 2 | -------------------------------------------------------------------------------- /example/chat/README.md: -------------------------------------------------------------------------------- 1 | # chat 2 | 3 | ![Screenshot](screenshot.png) -------------------------------------------------------------------------------- /example/http/README.md: -------------------------------------------------------------------------------- 1 | # http 2 | 3 | ![Screenshot](screenshot.png) -------------------------------------------------------------------------------- /example/mail/README.md: -------------------------------------------------------------------------------- 1 | # mail 2 | 3 | ![Screenshot](screenshot.png) -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: https://buymeacoffee.com/marcusolsson 2 | -------------------------------------------------------------------------------- /example/color/README.md: -------------------------------------------------------------------------------- 1 | # color 2 | 3 | ![Screenshot](screenshot.png) -------------------------------------------------------------------------------- /example/editor/README.md: -------------------------------------------------------------------------------- 1 | # editor 2 | 3 | ![Screenshot](screenshot.png) -------------------------------------------------------------------------------- /example/login/README.md: -------------------------------------------------------------------------------- 1 | # login 2 | 3 | ![Screenshot](screenshot.png) -------------------------------------------------------------------------------- /example/scroll/README.md: -------------------------------------------------------------------------------- 1 | # scroll 2 | 3 | ![Screenshot](screenshot.png) -------------------------------------------------------------------------------- /example/multiview/README.md: -------------------------------------------------------------------------------- 1 | # multiview 2 | 3 | ![Screenshot](screenshot.png) -------------------------------------------------------------------------------- /example/audioplayer/README.md: -------------------------------------------------------------------------------- 1 | # audioplayer 2 | 3 | ![Screenshot](screenshot.png) -------------------------------------------------------------------------------- /example/chat/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcusolsson/tui-go/HEAD/example/chat/screenshot.png -------------------------------------------------------------------------------- /example/color/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcusolsson/tui-go/HEAD/example/color/screenshot.png -------------------------------------------------------------------------------- /example/editor/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcusolsson/tui-go/HEAD/example/editor/screenshot.png -------------------------------------------------------------------------------- /example/http/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcusolsson/tui-go/HEAD/example/http/screenshot.png -------------------------------------------------------------------------------- /example/login/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcusolsson/tui-go/HEAD/example/login/screenshot.png -------------------------------------------------------------------------------- /example/mail/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcusolsson/tui-go/HEAD/example/mail/screenshot.png -------------------------------------------------------------------------------- /example/scroll/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcusolsson/tui-go/HEAD/example/scroll/screenshot.png -------------------------------------------------------------------------------- /example/tabs/screenshot.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcusolsson/tui-go/HEAD/example/tabs/screenshot.gif -------------------------------------------------------------------------------- /example/audioplayer/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcusolsson/tui-go/HEAD/example/audioplayer/screenshot.png -------------------------------------------------------------------------------- /example/multiview/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcusolsson/tui-go/HEAD/example/multiview/screenshot.png -------------------------------------------------------------------------------- /math.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | // Maximum integer value for the current architecture. 4 | const maxUint = ^uint(0) 5 | const maxInt = int(maxUint >> 1) 6 | -------------------------------------------------------------------------------- /example/tabs/README.md: -------------------------------------------------------------------------------- 1 | # tabs 2 | 3 | Implementation of a more complex multi-view layout, however with visual tabs. 4 | 5 | ![Screenshot](screenshot.gif) 6 | -------------------------------------------------------------------------------- /keybinding.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | type keybinding struct { 8 | sequence string 9 | handler func() 10 | } 11 | 12 | func (b *keybinding) match(ev KeyEvent) bool { 13 | return strings.EqualFold(b.sequence, ev.Name()) 14 | } 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - 1.11.x 5 | - tip 6 | 7 | env: 8 | - GO111MODULE=on 9 | 10 | install: 11 | - go get golang.org/x/lint/golint 12 | - go get honnef.co/go/tools/cmd/staticcheck 13 | 14 | script: 15 | - go test -v -race ./... 16 | - golint -set_exit_status 17 | - staticcheck ./... 18 | -------------------------------------------------------------------------------- /logger.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "io/ioutil" 5 | "log" 6 | ) 7 | 8 | var logger Logger = log.New(ioutil.Discard, "", 0) 9 | 10 | // Logger provides a interface for the standard logger. 11 | type Logger interface { 12 | Printf(format string, args ...interface{}) 13 | } 14 | 15 | // SetLogger sets the logger that is used in tui. 16 | func SetLogger(l Logger) { 17 | logger = l 18 | } 19 | -------------------------------------------------------------------------------- /progress_test.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestProgress_Draw(t *testing.T) { 8 | p := NewProgress(100) 9 | p.SetSizePolicy(Expanding, Minimum) 10 | p.SetCurrent(50) 11 | 12 | surface := NewTestSurface(11, 2) 13 | painter := NewPainter(surface, NewTheme()) 14 | painter.Repaint(p) 15 | 16 | want := ` 17 | [===>-----] 18 | ........... 19 | ` 20 | 21 | if diff := surfaceEquals(surface, want); diff != "" { 22 | t.Error(diff) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | If you're using tui-go for your application, please let me know what works well for you, and especially what doesn't (bug reports are greatly appreciated!). 4 | 5 | Pull requests are very much welcome! Check out the current issues to find out how you can help. If you do find anything interesting, please assign yourself to that issue so that others know you're working on it. If you want to contribute a feature not currently not listed, please create a new issue with a description of what you want to do. 6 | 7 | Please post any feature requests you might have. Smaller requests might end up being implemented rather quickly and larger ones will be considered for the road map. 8 | -------------------------------------------------------------------------------- /text.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "unicode/utf8" 5 | 6 | runewidth "github.com/mattn/go-runewidth" 7 | ) 8 | 9 | // runeWidth returns the cell width of given rune 10 | func runeWidth(r rune) int { 11 | return runewidth.RuneWidth(r) 12 | } 13 | 14 | // stringWidth returns the cell width of given string 15 | func stringWidth(s string) int { 16 | return runewidth.StringWidth(s) 17 | } 18 | 19 | // trimRightLen returns s with n runes trimmed off 20 | func trimRightLen(s string, n int) string { 21 | if n <= 0 { 22 | return s 23 | } 24 | c := utf8.RuneCountInString(s) 25 | runeCount := 0 26 | var i int 27 | for i = range s { 28 | if runeCount >= c-n { 29 | break 30 | } 31 | runeCount++ 32 | } 33 | return s[:i] 34 | } 35 | -------------------------------------------------------------------------------- /spacer.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import "image" 4 | 5 | var _ Widget = &Spacer{} 6 | 7 | // Spacer is a widget to fill out space. 8 | type Spacer struct { 9 | WidgetBase 10 | } 11 | 12 | // NewSpacer returns a new Spacer. 13 | func NewSpacer() *Spacer { 14 | return &Spacer{} 15 | } 16 | 17 | // MinSizeHint returns the minimum size the widget is allowed to be. 18 | func (s *Spacer) MinSizeHint() image.Point { 19 | return image.Point{} 20 | } 21 | 22 | // SizeHint returns the recommended size for the spacer. 23 | func (s *Spacer) SizeHint() image.Point { 24 | return image.Point{} 25 | } 26 | 27 | // SizePolicy returns the default layout behavior. 28 | func (s *Spacer) SizePolicy() (SizePolicy, SizePolicy) { 29 | return Expanding, Expanding 30 | } 31 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/marcusolsson/tui-go 2 | 3 | require ( 4 | github.com/gdamore/encoding v0.0.0-20151215212835-b23993cbb635 // indirect 5 | github.com/gdamore/tcell v1.1.0 6 | github.com/google/go-cmp v0.2.0 7 | github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect 8 | github.com/jtolds/gls v4.2.1+incompatible // indirect 9 | github.com/lucasb-eyer/go-colorful v0.0.0-20180709185858-c7842319cf3a // indirect 10 | github.com/mattn/go-runewidth v0.0.3 11 | github.com/mitchellh/go-wordwrap v1.0.0 12 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d // indirect 13 | github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c // indirect 14 | golang.org/x/text v0.3.0 // indirect 15 | gopkg.in/DATA-DOG/go-sqlmock.v1 v1.3.0 // indirect 16 | ) 17 | -------------------------------------------------------------------------------- /keybinding_test.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import "testing" 4 | 5 | func TestKeybinding_Match(t *testing.T) { 6 | for _, tt := range []struct { 7 | binding keybinding 8 | event KeyEvent 9 | match bool 10 | }{ 11 | {keybinding{sequence: "a"}, KeyEvent{Key: KeyRune, Rune: 'a'}, true}, 12 | {keybinding{sequence: "a"}, KeyEvent{Key: KeyRune, Rune: 'l'}, false}, 13 | {keybinding{sequence: "Enter"}, KeyEvent{Key: KeyRune, Rune: 'l'}, false}, 14 | {keybinding{sequence: "Enter"}, KeyEvent{Key: KeyEnter}, true}, 15 | {keybinding{sequence: "Ctrl+Space"}, KeyEvent{Key: KeyCtrlSpace, Modifiers: ModCtrl}, true}, 16 | } { 17 | tt := tt 18 | t.Run(tt.binding.sequence, func(t *testing.T) { 19 | if got := tt.binding.match(tt.event); got != tt.match { 20 | t.Errorf("got = %v; want = %v", got, tt.match) 21 | } 22 | }) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /ui.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | // UI defines the operations needed by the underlying engine. 4 | type UI interface { 5 | // SetWidget sets the root widget of the UI. 6 | SetWidget(w Widget) 7 | // SetTheme sets the current theme of the UI. 8 | SetTheme(p *Theme) 9 | // SetKeybinding sets the callback for when a key sequence is pressed. 10 | SetKeybinding(seq string, fn func()) 11 | // ClearKeybindings removes all previous set keybindings. 12 | ClearKeybindings() 13 | // SetFocusChain sets a chain of widgets that determines focus order. 14 | SetFocusChain(ch FocusChain) 15 | // Run starts the UI goroutine and blocks either Quit was called or an error occurred. 16 | Run() error 17 | // Update schedules work in the UI thread and await its completion. 18 | // Note that calling Update from the UI thread will result in deadlock. 19 | Update(fn func()) 20 | // Quit shuts down the UI goroutine. 21 | Quit() 22 | // Repaint the UI 23 | Repaint() 24 | } 25 | 26 | // New returns a new UI with a root widget. 27 | func New(root Widget) (UI, error) { 28 | return newTcellUI(root) 29 | } 30 | -------------------------------------------------------------------------------- /button_test.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestButton_OnActivated(t *testing.T) { 8 | btn := NewButton("test") 9 | 10 | var invoked bool 11 | btn.OnActivated(func(b *Button) { 12 | invoked = true 13 | }) 14 | 15 | ev := KeyEvent{ 16 | Key: KeyEnter, 17 | } 18 | 19 | t.Run("When button is not focused", func(t *testing.T) { 20 | btn.OnKeyEvent(ev) 21 | if invoked { 22 | t.Errorf("button should not be activated") 23 | } 24 | }) 25 | 26 | invoked = false 27 | btn.SetFocused(true) 28 | 29 | t.Run("When button is focused", func(t *testing.T) { 30 | btn.OnKeyEvent(ev) 31 | if !invoked { 32 | t.Errorf("button should be activated") 33 | } 34 | }) 35 | } 36 | 37 | func TestButton_Draw(t *testing.T) { 38 | surface := NewTestSurface(10, 5) 39 | painter := NewPainter(surface, NewTheme()) 40 | 41 | btn := NewButton("test") 42 | painter.Repaint(btn) 43 | 44 | want := ` 45 | test 46 | .......... 47 | .......... 48 | .......... 49 | .......... 50 | ` 51 | 52 | if diff := surfaceEquals(surface, want); diff != "" { 53 | t.Error(diff) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package tui is a library for building user interfaces for the terminal. 3 | 4 | 5 | Widgets 6 | 7 | Widgets are the main building blocks of any user interface. They allow us to 8 | present information and interact with our application. It receives keyboard and 9 | mouse events from the terminal and draws a representation of itself. 10 | 11 | lbl := tui.NewLabel("Hello, World!") 12 | 13 | 14 | Layouts 15 | 16 | Widgets are structured using layouts. Layouts are powerful tools that let you 17 | position your widgets without having to specify their exact coordinates. 18 | 19 | box := tui.NewVBox( 20 | tui.NewLabel("Press the button to continue ..."), 21 | tui.NewButton("Continue"), 22 | ) 23 | 24 | Here, the VBox will ensure that the Button will be placed underneath the Label. 25 | There are currently three layouts to choose from; VBox, HBox and Grid. 26 | 27 | Size policies 28 | 29 | Sizing of widgets is controlled by its SizePolicy. For now, you can read more 30 | about how size policies work in the Qt docs: 31 | 32 | http://doc.qt.io/qt-5/qsizepolicy.html#Policy-enum 33 | */ 34 | package tui 35 | -------------------------------------------------------------------------------- /example/scroll/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | tui "github.com/marcusolsson/tui-go" 7 | ) 8 | 9 | var lorem = `Lorem ipsum dolor sit amet.` 10 | 11 | func main() { 12 | root := tui.NewHBox() 13 | 14 | l := tui.NewLabel(lorem) 15 | 16 | s := tui.NewScrollArea(l) 17 | 18 | scrollBox := tui.NewVBox(s) 19 | scrollBox.SetBorder(true) 20 | 21 | root.Append(tui.NewVBox(tui.NewSpacer())) 22 | root.Append(scrollBox) 23 | root.Append(tui.NewVBox(tui.NewSpacer())) 24 | 25 | ui, err := tui.New(root) 26 | if err != nil { 27 | log.Fatal(err) 28 | } 29 | 30 | ui.SetKeybinding("Esc", func() { ui.Quit() }) 31 | ui.SetKeybinding("Up", func() { s.Scroll(0, -1) }) 32 | ui.SetKeybinding("Down", func() { s.Scroll(0, 1) }) 33 | ui.SetKeybinding("Left", func() { s.Scroll(-1, 0) }) 34 | ui.SetKeybinding("Right", func() { s.Scroll(1, 0) }) 35 | ui.SetKeybinding("a", func() { s.SetAutoscrollToBottom(true) }) 36 | ui.SetKeybinding("t", func() { s.ScrollToTop() }) 37 | ui.SetKeybinding("b", func() { s.ScrollToBottom() }) 38 | 39 | if err := ui.Run(); err != nil { 40 | log.Fatal(err) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /example/multiview/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/marcusolsson/tui-go" 7 | ) 8 | 9 | func main() { 10 | var currentView int 11 | 12 | views := []tui.Widget{ 13 | tui.NewVBox(tui.NewLabel("Press right arrow to continue ...")), 14 | tui.NewVBox(tui.NewLabel("Almost there, one more time!")), 15 | tui.NewVBox(tui.NewLabel("Congratulations, you've finished the example!")), 16 | } 17 | 18 | root := tui.NewVBox(views[0]) 19 | 20 | ui, err := tui.New(root) 21 | if err != nil { 22 | log.Fatal(err) 23 | } 24 | 25 | ui.SetKeybinding("Esc", func() { ui.Quit() }) 26 | ui.SetKeybinding("Left", func() { 27 | currentView = clamp(currentView-1, 0, len(views)-1) 28 | ui.SetWidget(views[currentView]) 29 | }) 30 | ui.SetKeybinding("Right", func() { 31 | currentView = clamp(currentView+1, 0, len(views)-1) 32 | ui.SetWidget(views[currentView]) 33 | }) 34 | 35 | if err := ui.Run(); err != nil { 36 | log.Fatal(err) 37 | } 38 | } 39 | 40 | func clamp(n, min, max int) int { 41 | if n < min { 42 | return min 43 | } 44 | if n > max { 45 | return max 46 | } 47 | return n 48 | } 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Marcus Olsson 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 13 | all 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 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /text_edit_test.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "image" 5 | "testing" 6 | ) 7 | 8 | var drawTextEditTests = []struct { 9 | test string 10 | size image.Point 11 | setup func() *TextEdit 12 | want string 13 | }{ 14 | { 15 | test: "Simple", 16 | size: image.Point{15, 5}, 17 | setup: func() *TextEdit { 18 | e := NewTextEdit() 19 | e.SetText("Lorem ipsum dolor sit amet") 20 | e.SetWordWrap(true) 21 | return e 22 | }, 23 | want: ` 24 | Lorem ipsum 25 | dolor sit amet 26 | ............... 27 | ............... 28 | ............... 29 | `, 30 | }, 31 | } 32 | 33 | func TestTextEdit_Draw(t *testing.T) { 34 | for _, tt := range drawTextEditTests { 35 | tt := tt 36 | t.Run(tt.test, func(t *testing.T) { 37 | var surface *TestSurface 38 | if tt.size.X == 0 && tt.size.Y == 0 { 39 | surface = NewTestSurface(10, 5) 40 | } else { 41 | surface = NewTestSurface(tt.size.X, tt.size.Y) 42 | } 43 | 44 | painter := NewPainter(surface, NewTheme()) 45 | painter.Repaint(tt.setup()) 46 | 47 | if diff := surfaceEquals(surface, tt.want); diff != "" { 48 | t.Error(diff) 49 | } 50 | }) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /statusbar.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "image" 5 | ) 6 | 7 | var _ Widget = &StatusBar{} 8 | 9 | // StatusBar is a widget to display status information. 10 | type StatusBar struct { 11 | WidgetBase 12 | 13 | text string 14 | permText string 15 | } 16 | 17 | // NewStatusBar returns a new StatusBar. 18 | func NewStatusBar(text string) *StatusBar { 19 | return &StatusBar{ 20 | text: text, 21 | permText: "", 22 | } 23 | } 24 | 25 | // Draw draws the status bar. 26 | func (b *StatusBar) Draw(p *Painter) { 27 | p.WithStyle("statusbar", func(p *Painter) { 28 | p.FillRect(0, 0, b.Size().X, 1) 29 | p.DrawText(0, 0, b.text) 30 | p.DrawText(b.Size().X-stringWidth(b.permText), 0, b.permText) 31 | }) 32 | } 33 | 34 | // SizeHint returns the recommended size for the status bar. 35 | func (b *StatusBar) SizeHint() image.Point { 36 | return image.Point{10, 1} 37 | } 38 | 39 | // SizePolicy returns the default layout behavior. 40 | func (b *StatusBar) SizePolicy() (SizePolicy, SizePolicy) { 41 | return Preferred, Maximum 42 | } 43 | 44 | // SetText sets the text content of the status bar. 45 | func (b *StatusBar) SetText(text string) { 46 | b.text = text 47 | } 48 | 49 | // SetPermanentText sets the permanent text of the status bar. 50 | func (b *StatusBar) SetPermanentText(text string) { 51 | b.permText = text 52 | } 53 | -------------------------------------------------------------------------------- /theme_test.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | var base = Style{Fg: ColorWhite, Bg: ColorBlack, Bold: DecorationOff, Underline: DecorationOff} 8 | 9 | var mergeTests = []struct { 10 | test string 11 | chain []Style 12 | want Style 13 | }{ 14 | { 15 | test: "zero inherits", 16 | chain: []Style{ 17 | base, 18 | Style{}, 19 | }, 20 | want: base, 21 | }, 22 | { 23 | test: "orthogonal inherits", 24 | chain: []Style{ 25 | base, 26 | Style{Reverse: DecorationOn}, 27 | Style{Bold: DecorationOn}, 28 | }, 29 | want: Style{Fg: ColorWhite, Bg: ColorBlack, Reverse: DecorationOn, Bold: DecorationOn, Underline: DecorationOff}, 30 | }, 31 | { 32 | test: "serial inherits", 33 | chain: []Style{ 34 | base, 35 | Style{Reverse: DecorationOn, Bold: DecorationOn}, 36 | Style{Bold: DecorationOff}, 37 | }, 38 | want: Style{Fg: ColorWhite, Bg: ColorBlack, Reverse: DecorationOn, Bold: DecorationOff, Underline: DecorationOff}, 39 | }, 40 | } 41 | 42 | func TestStyle_Merge(t *testing.T) { 43 | for _, tt := range mergeTests { 44 | tt := tt 45 | t.Run(tt.test, func(t *testing.T) { 46 | var got Style 47 | for _, s := range tt.chain { 48 | got = got.mergeIn(s) 49 | } 50 | if got != tt.want { 51 | t.Errorf("got = \n%v\nwant = \n%v", got, tt.want) 52 | } 53 | }) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /progress.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import "image" 4 | 5 | var _ Widget = &Progress{} 6 | 7 | // Progress is a widget to display a progress bar. 8 | type Progress struct { 9 | WidgetBase 10 | 11 | current, max int 12 | } 13 | 14 | // NewProgress returns a new Progress. 15 | func NewProgress(max int) *Progress { 16 | return &Progress{ 17 | max: max, 18 | } 19 | } 20 | 21 | // Draw draws the progress bar. 22 | func (p *Progress) Draw(painter *Painter) { 23 | painter.DrawRune(0, 0, '[') 24 | painter.DrawRune(p.Size().X-1, 0, ']') 25 | 26 | start := 1 27 | end := p.Size().X - 1 28 | curr := int((float64(p.current) / float64(p.max)) * float64(end-start)) 29 | 30 | for i := start; i < curr; i++ { 31 | painter.DrawRune(i, 0, '=') 32 | } 33 | for i := curr + start; i < end; i++ { 34 | painter.DrawRune(i, 0, '-') 35 | } 36 | painter.DrawRune(curr, 0, '>') 37 | } 38 | 39 | // MinSizeHint returns the minimum size the widget is allowed to be. 40 | func (p *Progress) MinSizeHint() image.Point { 41 | return image.Point{5, 1} 42 | } 43 | 44 | // SizeHint returns the recommended size for the progress bar. 45 | func (p *Progress) SizeHint() image.Point { 46 | return image.Point{p.max, 1} 47 | } 48 | 49 | // SetCurrent sets the current progress. 50 | func (p *Progress) SetCurrent(c int) { 51 | p.current = c 52 | } 53 | 54 | // SetMax sets the maximum progress. 55 | func (p *Progress) SetMax(m int) { 56 | p.max = m 57 | } 58 | -------------------------------------------------------------------------------- /list_test.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestList_Draw(t *testing.T) { 8 | surface := NewTestSurface(10, 5) 9 | painter := NewPainter(surface, NewTheme()) 10 | 11 | l := NewList() 12 | l.AddItems("foo", "bar") 13 | painter.Repaint(l) 14 | 15 | want := ` 16 | foo 17 | bar 18 | .......... 19 | .......... 20 | .......... 21 | ` 22 | 23 | if diff := surfaceEquals(surface, want); diff != "" { 24 | t.Error(diff) 25 | } 26 | } 27 | 28 | func TestList_RemoveItem(t *testing.T) { 29 | surface := NewTestSurface(5, 3) 30 | painter := NewPainter(surface, NewTheme()) 31 | 32 | l := NewList() 33 | l.AddItems("one", "two", "three", "four", "five") 34 | l.SetSelected(1) 35 | 36 | painter.Repaint(l) 37 | 38 | want := ` 39 | one 40 | two 41 | three 42 | ` 43 | 44 | // Make sure okay before removing any items. 45 | if surface.String() != want { 46 | t.Errorf("got = \n%s\n\nwant = \n%s", surface.String(), want) 47 | } 48 | 49 | // Remove a visible item. 50 | l.RemoveItem(2) 51 | 52 | painter.Repaint(l) 53 | 54 | want = ` 55 | one 56 | two 57 | four 58 | ` 59 | 60 | if diff := surfaceEquals(surface, want); diff != "" { 61 | t.Fatal(diff) 62 | } 63 | 64 | // Remove an item not visible. 65 | l.RemoveItem(3) 66 | 67 | painter.Repaint(l) 68 | 69 | if diff := surfaceEquals(surface, want); diff != "" { 70 | t.Error(diff) 71 | } 72 | 73 | // Selected item should not have changed. 74 | if l.Selected() != 1 { 75 | t.Errorf("got = %d; want = %d", l.Selected(), 1) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /text_test.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import "testing" 4 | 5 | func TestRuneWidth(t *testing.T) { 6 | for n, tt := range []struct { 7 | r rune 8 | result int 9 | }{ 10 | {' ', 1}, 11 | {'a', 1}, 12 | {'あ', 2}, 13 | } { 14 | if got, want := runeWidth(tt.r), tt.result; got != want { 15 | t.Errorf("[%d] runeWidth(%q) = %d, want %d", n, tt.r, got, want) 16 | } 17 | } 18 | } 19 | 20 | func TestStringWidth(t *testing.T) { 21 | for n, tt := range []struct { 22 | s string 23 | result int 24 | }{ 25 | {"", 0}, 26 | {" ", 1}, 27 | {"a", 1}, 28 | {"a ", 2}, 29 | {"abc", 3}, 30 | {"あ", 2}, 31 | {"あいう", 6}, 32 | {"abcあいう123", 12}, 33 | } { 34 | if got, want := stringWidth(tt.s), tt.result; got != want { 35 | t.Errorf("[%d] stringWidth(%q) = %d, want %d", n, tt.s, got, want) 36 | } 37 | } 38 | } 39 | 40 | func TestTrimRightLen(t *testing.T) { 41 | for n, tt := range []struct { 42 | s string 43 | n int 44 | result string 45 | }{ 46 | {"", 0, ""}, 47 | {"", 1, ""}, 48 | {"", -1, ""}, 49 | {" ", 1, ""}, 50 | {"abc", -1, "abc"}, 51 | {"abc", 0, "abc"}, 52 | {"abc", 1, "ab"}, 53 | {"abc ", 1, "abc"}, 54 | {"abc", 2, "a"}, 55 | {"abc", 3, ""}, 56 | {"abc", 4, ""}, 57 | {"あいう", -1, "あいう"}, 58 | {"あいう", 0, "あいう"}, 59 | {"あいう", 1, "あい"}, 60 | {"あいう", 2, "あ"}, 61 | {"あいう", 3, ""}, 62 | {"あいう", 4, ""}, 63 | } { 64 | if got, want := trimRightLen(tt.s, tt.n), tt.result; got != want { 65 | t.Errorf("[%d] trimRightLen(%q, %d) = %q, want %q", n, tt.s, tt.n, got, want) 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /wordwrap/wordwrap.go: -------------------------------------------------------------------------------- 1 | package wordwrap 2 | 3 | import ( 4 | "bytes" 5 | "unicode" 6 | 7 | "github.com/mattn/go-runewidth" 8 | ) 9 | 10 | // WrapString wraps the input string by inserting newline characters. It does 11 | // not remove whitespace, but preserves the original text. 12 | func WrapString(s string, width int) string { 13 | if len(s) <= 1 { 14 | return s 15 | } 16 | 17 | // Output buffer. Does not include the most recent word. 18 | var buf bytes.Buffer 19 | // Trailing word. 20 | var word bytes.Buffer 21 | var wordLen int 22 | 23 | spaceLeft := width 24 | var prev rune 25 | 26 | for _, curr := range s { 27 | if curr == rune('\n') { 28 | // Received a newline. 29 | if word.Len() > spaceLeft { 30 | spaceLeft = width 31 | buf.WriteRune(curr) 32 | } else { 33 | spaceLeft = width 34 | } 35 | word.WriteTo(&buf) 36 | wordLen = 0 37 | } else if unicode.IsSpace(prev) && !unicode.IsSpace(curr) { 38 | // At the start of a new word. 39 | // Does the last word fit on this line, or the next? 40 | if wordLen > spaceLeft { 41 | spaceLeft = width - wordLen 42 | buf.WriteRune('\n') 43 | } else { 44 | spaceLeft -= wordLen 45 | } 46 | // fmt.Printf("42: writing %q with %d spaces remaining out of %d\n", word.String(), spaceLeft, width) 47 | word.WriteTo(&buf) 48 | wordLen = 0 49 | } 50 | word.WriteRune(curr) 51 | wordLen += runewidth.RuneWidth(curr) 52 | 53 | prev = curr 54 | } 55 | 56 | // Close out the final word. 57 | if wordLen > spaceLeft { 58 | buf.WriteRune('\n') 59 | } 60 | word.WriteTo(&buf) 61 | 62 | return buf.String() 63 | } 64 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | The most effective way to learn how to build terminal applications using tui-go is to study and learn from the examples applications. 4 | 5 | ### audioplayer 6 | 7 | An audio player with a library of tracks that you can choose which song to play next. A progress bar and status bar shows where you are in the song, as well as basic volume information. 8 | 9 | ### chat 10 | 11 | A Slack-inspired chat client with a list of channels, a history of text messages and a text entry to write new messages. 12 | 13 | ### color 14 | 15 | Demonstrates basic support for theming your application. 16 | 17 | ### editor 18 | 19 | Demonstrates a simple editor for multi-line text entry. 20 | 21 | ### http 22 | 23 | Demonstrates a HTTP client inspired by applications like [Postman](https://chrome.google.com/webstore/detail/postman/fhbjgbiflinjbdggehcddcbncdddomop) and [wuzz](https://github.com/asciimoo/wuzz). 24 | 25 | ### login 26 | 27 | Demonstrates a both vertically, and horizontally centered login box with text entries for username and password as well as buttons for logging in and registering. 28 | 29 | ### mail 30 | 31 | A basic interface for a mail client. An inbox shows the mails you've received and a view for displaying mail content. 32 | 33 | ### multiview 34 | 35 | Demonstrates switching between views to support multiple tabs/pages (see [tabs](#tabs) for a more complex version). 36 | 37 | ### scroll 38 | 39 | Demonstrates the use of a scroll area to provide a scrolling view of an underlying widget. 40 | 41 | ### tabs 42 | 43 | Demonstrates the use of switching between views with a visual tab bar (more complex version of [multiview](#multiview)). 44 | -------------------------------------------------------------------------------- /button.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "image" 5 | "strings" 6 | ) 7 | 8 | var _ Widget = &Button{} 9 | 10 | // Button is a widget that can be activated to perform some action, or to 11 | // answer a question. 12 | type Button struct { 13 | WidgetBase 14 | 15 | text string 16 | 17 | onActivated func(*Button) 18 | } 19 | 20 | // NewButton returns a new Button with the given text as the label. 21 | func NewButton(text string) *Button { 22 | return &Button{ 23 | text: text, 24 | } 25 | } 26 | 27 | // Draw draws the button. 28 | func (b *Button) Draw(p *Painter) { 29 | style := "button" 30 | if b.IsFocused() { 31 | style += ".focused" 32 | } 33 | p.WithStyle(style, func(p *Painter) { 34 | lines := strings.Split(b.text, "\n") 35 | for i, line := range lines { 36 | p.FillRect(0, i, b.Size().X, 1) 37 | p.DrawText(0, i, line) 38 | } 39 | }) 40 | } 41 | 42 | // SizeHint returns the recommended size hint for the button. 43 | func (b *Button) SizeHint() image.Point { 44 | if len(b.text) == 0 { 45 | return b.MinSizeHint() 46 | } 47 | 48 | var size image.Point 49 | lines := strings.Split(b.text, "\n") 50 | for _, line := range lines { 51 | if w := stringWidth(line); w > size.X { 52 | size.X = w 53 | } 54 | } 55 | size.Y = len(lines) 56 | 57 | return size 58 | } 59 | 60 | // OnKeyEvent handles keys events. 61 | func (b *Button) OnKeyEvent(ev KeyEvent) { 62 | if !b.IsFocused() { 63 | return 64 | } 65 | if ev.Key == KeyEnter && b.onActivated != nil { 66 | b.onActivated(b) 67 | } 68 | } 69 | 70 | // OnActivated allows a custom function to be run whenever the button is activated. 71 | func (b *Button) OnActivated(fn func(b *Button)) { 72 | b.onActivated = fn 73 | } 74 | -------------------------------------------------------------------------------- /example/login/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/marcusolsson/tui-go" 7 | ) 8 | 9 | var logo = ` _____ __ ____ ___ ______________ 10 | / ___// //_/\ \/ / | / / ____/_ __/ 11 | \__ \/ ,< \ / |/ / __/ / / 12 | ___/ / /| | / / /| / /___ / / 13 | /____/_/ |_| /_/_/ |_/_____/ /_/ ` 14 | 15 | func main() { 16 | user := tui.NewEntry() 17 | user.SetFocused(true) 18 | 19 | password := tui.NewEntry() 20 | password.SetEchoMode(tui.EchoModePassword) 21 | 22 | form := tui.NewGrid(0, 0) 23 | form.AppendRow(tui.NewLabel("User"), tui.NewLabel("Password")) 24 | form.AppendRow(user, password) 25 | 26 | status := tui.NewStatusBar("Ready.") 27 | 28 | login := tui.NewButton("[Login]") 29 | login.OnActivated(func(b *tui.Button) { 30 | status.SetText("Logged in.") 31 | }) 32 | 33 | register := tui.NewButton("[Register]") 34 | 35 | buttons := tui.NewHBox( 36 | tui.NewSpacer(), 37 | tui.NewPadder(1, 0, login), 38 | tui.NewPadder(1, 0, register), 39 | ) 40 | 41 | window := tui.NewVBox( 42 | tui.NewPadder(10, 1, tui.NewLabel(logo)), 43 | tui.NewPadder(12, 0, tui.NewLabel("Welcome to Skynet! Login or register.")), 44 | tui.NewPadder(1, 1, form), 45 | buttons, 46 | ) 47 | window.SetBorder(true) 48 | 49 | wrapper := tui.NewVBox( 50 | tui.NewSpacer(), 51 | window, 52 | tui.NewSpacer(), 53 | ) 54 | content := tui.NewHBox(tui.NewSpacer(), wrapper, tui.NewSpacer()) 55 | 56 | root := tui.NewVBox( 57 | content, 58 | status, 59 | ) 60 | 61 | tui.DefaultFocusChain.Set(user, password, login, register) 62 | 63 | ui, err := tui.New(root) 64 | if err != nil { 65 | log.Fatal(err) 66 | } 67 | 68 | ui.SetKeybinding("Esc", func() { ui.Quit() }) 69 | 70 | if err := ui.Run(); err != nil { 71 | log.Fatal(err) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /scroll_area.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "image" 5 | ) 6 | 7 | var _ Widget = &ScrollArea{} 8 | 9 | // ScrollArea is a widget to fill out space. 10 | type ScrollArea struct { 11 | WidgetBase 12 | 13 | Widget Widget 14 | 15 | topLeft image.Point 16 | autoscroll bool 17 | } 18 | 19 | // NewScrollArea returns a new ScrollArea. 20 | func NewScrollArea(w Widget) *ScrollArea { 21 | return &ScrollArea{ 22 | Widget: w, 23 | } 24 | } 25 | 26 | // MinSizeHint returns the minimum size the widget is allowed to be. 27 | func (s *ScrollArea) MinSizeHint() image.Point { 28 | return image.Point{} 29 | } 30 | 31 | // SizeHint returns the size hint of the underlying widget. 32 | func (s *ScrollArea) SizeHint() image.Point { 33 | return image.Pt(15, 8) 34 | } 35 | 36 | // Scroll shifts the views over the content. 37 | func (s *ScrollArea) Scroll(dx, dy int) { 38 | s.topLeft.X += dx 39 | s.topLeft.Y += dy 40 | } 41 | 42 | // ScrollToBottom ensures the bottom-most part of the scroll area is visible. 43 | func (s *ScrollArea) ScrollToBottom() { 44 | s.topLeft.Y = s.Widget.SizeHint().Y - s.Size().Y 45 | } 46 | 47 | // ScrollToTop resets the vertical scroll position. 48 | func (s *ScrollArea) ScrollToTop() { 49 | s.topLeft.Y = 0 50 | } 51 | 52 | // SetAutoscrollToBottom makes sure the content is scrolled to bottom on resize. 53 | func (s *ScrollArea) SetAutoscrollToBottom(autoscroll bool) { 54 | s.autoscroll = autoscroll 55 | } 56 | 57 | // Draw draws the scroll area. 58 | func (s *ScrollArea) Draw(p *Painter) { 59 | p.Translate(-s.topLeft.X, -s.topLeft.Y) 60 | defer p.Restore() 61 | 62 | off := image.Point{s.topLeft.X, s.topLeft.Y} 63 | p.WithMask(image.Rectangle{Min: off, Max: s.Size().Add(off)}, func(p *Painter) { 64 | s.Widget.Draw(p) 65 | }) 66 | } 67 | 68 | // Resize resizes the scroll area and the underlying widget. 69 | func (s *ScrollArea) Resize(size image.Point) { 70 | s.Widget.Resize(s.Widget.SizeHint()) 71 | s.WidgetBase.Resize(size) 72 | 73 | if s.autoscroll { 74 | s.ScrollToBottom() 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /example/chat/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "time" 7 | 8 | "github.com/marcusolsson/tui-go" 9 | ) 10 | 11 | type post struct { 12 | username string 13 | message string 14 | time string 15 | } 16 | 17 | var posts = []post{ 18 | {username: "john", message: "hi, what's up?", time: "14:41"}, 19 | {username: "jane", message: "not much", time: "14:43"}, 20 | } 21 | 22 | func main() { 23 | sidebar := tui.NewVBox( 24 | tui.NewLabel("CHANNELS"), 25 | tui.NewLabel("general"), 26 | tui.NewLabel("random"), 27 | tui.NewLabel(""), 28 | tui.NewLabel("DIRECT MESSAGES"), 29 | tui.NewLabel("slackbot"), 30 | tui.NewSpacer(), 31 | ) 32 | sidebar.SetBorder(true) 33 | 34 | history := tui.NewVBox() 35 | 36 | for _, m := range posts { 37 | history.Append(tui.NewHBox( 38 | tui.NewLabel(m.time), 39 | tui.NewPadder(1, 0, tui.NewLabel(fmt.Sprintf("<%s>", m.username))), 40 | tui.NewLabel(m.message), 41 | tui.NewSpacer(), 42 | )) 43 | } 44 | 45 | historyScroll := tui.NewScrollArea(history) 46 | historyScroll.SetAutoscrollToBottom(true) 47 | 48 | historyBox := tui.NewVBox(historyScroll) 49 | historyBox.SetBorder(true) 50 | 51 | input := tui.NewEntry() 52 | input.SetFocused(true) 53 | input.SetSizePolicy(tui.Expanding, tui.Maximum) 54 | 55 | inputBox := tui.NewHBox(input) 56 | inputBox.SetBorder(true) 57 | inputBox.SetSizePolicy(tui.Expanding, tui.Maximum) 58 | 59 | chat := tui.NewVBox(historyBox, inputBox) 60 | chat.SetSizePolicy(tui.Expanding, tui.Expanding) 61 | 62 | input.OnSubmit(func(e *tui.Entry) { 63 | history.Append(tui.NewHBox( 64 | tui.NewLabel(time.Now().Format("15:04")), 65 | tui.NewPadder(1, 0, tui.NewLabel(fmt.Sprintf("<%s>", "john"))), 66 | tui.NewLabel(e.Text()), 67 | tui.NewSpacer(), 68 | )) 69 | input.SetText("") 70 | }) 71 | 72 | root := tui.NewHBox(sidebar, chat) 73 | 74 | ui, err := tui.New(root) 75 | if err != nil { 76 | log.Fatal(err) 77 | } 78 | 79 | ui.SetKeybinding("Esc", func() { ui.Quit() }) 80 | 81 | if err := ui.Run(); err != nil { 82 | log.Fatal(err) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /padder.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import "image" 4 | 5 | var _ Widget = &Padder{} 6 | 7 | // Padder is a widget to fill out space. 8 | // It adds empty space of a specified size to the outside of its contained Widget. 9 | type Padder struct { 10 | widget Widget 11 | 12 | padding image.Point 13 | } 14 | 15 | // NewPadder returns a new Padder. 16 | // The enclosed Widget is given horizontal margin of x on the right and x on the left, 17 | // and a vertical margin of y on the top and y on the bottom. 18 | func NewPadder(x, y int, w Widget) *Padder { 19 | return &Padder{ 20 | widget: w, 21 | padding: image.Point{x, y}, 22 | } 23 | } 24 | 25 | // Draw draws the padded widget. 26 | func (p *Padder) Draw(painter *Painter) { 27 | painter.Translate(p.padding.X, p.padding.Y) 28 | defer painter.Restore() 29 | 30 | p.widget.Draw(painter) 31 | } 32 | 33 | // Size returns the size of the padded widget. 34 | func (p *Padder) Size() image.Point { 35 | return p.widget.Size().Add(p.padding.Mul(2)) 36 | } 37 | 38 | // MinSizeHint returns the minimum size the widget is allowed to be. 39 | func (p *Padder) MinSizeHint() image.Point { 40 | return p.widget.MinSizeHint().Add(p.padding.Mul(2)) 41 | } 42 | 43 | // SizeHint returns the recommended size for the padded widget. 44 | func (p *Padder) SizeHint() image.Point { 45 | return p.widget.SizeHint().Add(p.padding.Mul(2)) 46 | } 47 | 48 | // SizePolicy returns the default layout behavior. 49 | func (p *Padder) SizePolicy() (SizePolicy, SizePolicy) { 50 | return p.widget.SizePolicy() 51 | } 52 | 53 | // Resize updates the size of the padded widget. 54 | func (p *Padder) Resize(size image.Point) { 55 | p.widget.Resize(size.Sub(p.padding.Mul(2))) 56 | } 57 | 58 | // OnKeyEvent handles key events. 59 | func (p *Padder) OnKeyEvent(ev KeyEvent) { 60 | p.widget.OnKeyEvent(ev) 61 | } 62 | 63 | // SetFocused set the focus on the widget. 64 | func (p *Padder) SetFocused(f bool) { 65 | p.widget.SetFocused(f) 66 | } 67 | 68 | // IsFocused returns true if the widget is focused. 69 | func (p *Padder) IsFocused() bool { 70 | return p.widget.IsFocused() 71 | } 72 | -------------------------------------------------------------------------------- /wordwrap/wordwrap_test.go: -------------------------------------------------------------------------------- 1 | package wordwrap 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func TestSimple(t *testing.T) { 9 | for _, tt := range []struct { 10 | In string 11 | Width int 12 | Out string 13 | }{ 14 | {"", 3, ""}, 15 | {"a", 3, "a"}, 16 | {"aa", 3, "aa"}, 17 | {"aa bb", 3, "aa \nbb"}, 18 | {"aa bb ddd", 7, "aa bb \nddd"}, 19 | {"aaa bb cc ddddd", 7, "aaa bb \ncc \nddddd"}, 20 | {"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis egestas nisl urna, vel accumsan libero bibendum id. In consectetur facilisis iaculis. Vivamus cursus hendrerit neque, et bibendum leo accumsan ac.", 20, "Lorem ipsum dolor \nsit amet, \nconsectetur \nadipiscing elit. \nDuis egestas nisl \nurna, vel accumsan \nlibero bibendum id. \nIn consectetur \nfacilisis iaculis. \nVivamus cursus \nhendrerit neque, et \nbibendum leo \naccumsan ac."}, 21 | {"aaa bb\n\ncc ddddd", 7, "aaa bb\n\ncc \nddddd"}, 22 | {"Nulla lorem magna, efficitur interdum ante at, convallis sodales nulla. Sed maximus tempor condimentum.\n\nNam et risus est. Cras ornare iaculis orci, sit amet fringilla nisl pharetra quis.", 30, "Nulla lorem magna, efficitur \ninterdum ante at, convallis \nsodales nulla. Sed maximus \ntempor condimentum.\n\nNam et risus est. Cras ornare \niaculis orci, sit amet \nfringilla nisl pharetra quis."}, 23 | {"Nulla lorem magna, efficitur interdum ante at, convallis sodales nulla. Sed maximus tempor condimentum.\n\nNam et risus est. Cras ornare iaculis orci, sit amet fringilla nisl pharetra quis.", 35, "Nulla lorem magna, efficitur \ninterdum ante at, convallis \nsodales nulla. Sed maximus tempor \ncondimentum.\n\nNam et risus est. Cras ornare \niaculis orci, sit amet fringilla \nnisl pharetra quis."}, 24 | {"\n\nNam et risus est.", 30, "\n\nNam et risus est."}, 25 | {"a\n\na\n\n", 6, "a\n\na\n\n"}, 26 | {"null set ∅", 11, "null set ∅"}, 27 | } { 28 | t.Run("", func(t *testing.T) { 29 | if got := WrapString(tt.In, tt.Width); got != tt.Out { 30 | padding := strings.Repeat(".", tt.Width) 31 | t.Fatalf("\n\ngot = \n\n%s\n%s\n%s\n\nwant = \n\n%s\n%s\n%s", padding, got, padding, padding, tt.Out, padding) 32 | } 33 | }) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /example/mail/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/marcusolsson/tui-go" 7 | ) 8 | 9 | type mail struct { 10 | from string 11 | subject string 12 | date string 13 | body string 14 | } 15 | 16 | var mails = []mail{ 17 | { 18 | from: "John Doe ", 19 | subject: "Vacation pictures", 20 | date: "Yesterday", 21 | body: ` 22 | Hey, 23 | 24 | Where can I find the pictures from the diving trip? 25 | 26 | Cheers, 27 | John`, 28 | }, 29 | { 30 | from: "Jane Doe ", 31 | subject: "Meeting notes", 32 | date: "Yesterday", 33 | body: ` 34 | Here are the notes from today's meeting. 35 | 36 | /Jane`, 37 | }, 38 | } 39 | 40 | func main() { 41 | inbox := tui.NewTable(0, 0) 42 | inbox.SetColumnStretch(0, 3) 43 | inbox.SetColumnStretch(1, 2) 44 | inbox.SetColumnStretch(2, 1) 45 | inbox.SetFocused(true) 46 | 47 | for _, m := range mails { 48 | inbox.AppendRow( 49 | tui.NewLabel(m.subject), 50 | tui.NewLabel(m.from), 51 | tui.NewLabel(m.date), 52 | ) 53 | } 54 | 55 | var ( 56 | from = tui.NewLabel("") 57 | subject = tui.NewLabel("") 58 | date = tui.NewLabel("") 59 | ) 60 | 61 | info := tui.NewGrid(0, 0) 62 | info.AppendRow(tui.NewLabel("From:"), from) 63 | info.AppendRow(tui.NewLabel("Subject:"), subject) 64 | info.AppendRow(tui.NewLabel("Date:"), date) 65 | 66 | body := tui.NewLabel("") 67 | body.SetSizePolicy(tui.Preferred, tui.Expanding) 68 | 69 | mail := tui.NewVBox(info, body) 70 | mail.SetSizePolicy(tui.Preferred, tui.Expanding) 71 | 72 | inbox.OnSelectionChanged(func(t *tui.Table) { 73 | m := mails[t.Selected()] 74 | from.SetText(m.from) 75 | subject.SetText(m.subject) 76 | date.SetText(m.date) 77 | body.SetText(m.body) 78 | }) 79 | 80 | // Select first mail on startup. 81 | inbox.Select(0) 82 | 83 | root := tui.NewVBox(inbox, tui.NewLabel(""), mail) 84 | 85 | ui, err := tui.New(root) 86 | if err != nil { 87 | log.Fatal(err) 88 | } 89 | 90 | ui.SetKeybinding("Esc", func() { ui.Quit() }) 91 | ui.SetKeybinding("Shift+Alt+Up", func() { ui.Quit() }) 92 | 93 | if err := ui.Run(); err != nil { 94 | log.Fatal(err) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /cjk_test.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import "testing" 4 | 5 | var drawCJKTests = []struct { 6 | test string 7 | setup func() Widget 8 | want string 9 | }{ 10 | { 11 | test: "Label", 12 | setup: func() Widget { 13 | return NewLabel("テスト") 14 | }, 15 | want: ` 16 | テスト.... 17 | .......... 18 | .......... 19 | .......... 20 | `, 21 | }, 22 | { 23 | test: "Box", 24 | setup: func() Widget { 25 | b := NewVBox( 26 | NewLabel("テスト1"), 27 | NewLabel("テスト2"), 28 | ) 29 | b.SetBorder(true) 30 | return b 31 | }, 32 | want: ` 33 | ┌────────┐ 34 | │テスト1│ 35 | │テスト2│ 36 | └────────┘ 37 | `, 38 | }, 39 | { 40 | test: "Box with title", 41 | setup: func() Widget { 42 | b := NewVBox( 43 | NewLabel("测试"), 44 | ) 45 | b.SetTitle("标题") 46 | b.SetBorder(true) 47 | return b 48 | }, 49 | want: ` 50 | ┌标题────┐ 51 | │测试 │ 52 | │ │ 53 | └────────┘ 54 | `, 55 | }, 56 | { 57 | test: "Entry", 58 | setup: func() Widget { 59 | e := NewEntry() 60 | e.SetText("テスト") 61 | b := NewVBox(e) 62 | b.SetBorder(true) 63 | return b 64 | }, 65 | want: ` 66 | ┌────────┐ 67 | │テスト │ 68 | │ │ 69 | └────────┘ 70 | `, 71 | }, 72 | { 73 | test: "Entry with long text", 74 | setup: func() Widget { 75 | e := NewEntry() 76 | e.SetText("これはテストです") 77 | b := NewVBox(e) 78 | b.SetBorder(true) 79 | return b 80 | }, 81 | want: ` 82 | ┌────────┐ 83 | │これはテ│ 84 | │ │ 85 | └────────┘ 86 | `, 87 | }, 88 | { 89 | test: "List", 90 | setup: func() Widget { 91 | l := NewList() 92 | l.AddItems("テスト1", "テスト2") 93 | b := NewVBox(l) 94 | b.SetBorder(true) 95 | return b 96 | }, 97 | want: ` 98 | ┌────────┐ 99 | │テスト1│ 100 | │テスト2│ 101 | └────────┘ 102 | `, 103 | }, 104 | } 105 | 106 | func TestCJK_Label(t *testing.T) { 107 | for _, tt := range drawCJKTests { 108 | t.Run(tt.test, func(t *testing.T) { 109 | surface := NewTestSurface(10, 4) 110 | 111 | painter := NewPainter(surface, NewTheme()) 112 | painter.Repaint(tt.setup()) 113 | 114 | if diff := surfaceEquals(surface, tt.want); diff != "" { 115 | t.Error(diff) 116 | } 117 | }) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /focus.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | // FocusChain enables custom focus traversal when Tab or Backtab is pressed. 4 | type FocusChain interface { 5 | FocusNext(w Widget) Widget 6 | FocusPrev(w Widget) Widget 7 | FocusDefault() Widget 8 | } 9 | 10 | type kbFocusController struct { 11 | focusedWidget Widget 12 | 13 | chain FocusChain 14 | } 15 | 16 | func (c *kbFocusController) OnKeyEvent(e KeyEvent) { 17 | if c.chain == nil { 18 | return 19 | } 20 | switch e.Key { 21 | case KeyTab: 22 | if c.focusedWidget != nil { 23 | c.focusedWidget.SetFocused(false) 24 | c.focusedWidget = c.chain.FocusNext(c.focusedWidget) 25 | c.focusedWidget.SetFocused(true) 26 | } 27 | case KeyBacktab: 28 | if c.focusedWidget != nil { 29 | c.focusedWidget.SetFocused(false) 30 | c.focusedWidget = c.chain.FocusPrev(c.focusedWidget) 31 | c.focusedWidget.SetFocused(true) 32 | } 33 | } 34 | } 35 | 36 | // DefaultFocusChain is the default focus chain. 37 | var DefaultFocusChain = &SimpleFocusChain{ 38 | widgets: make([]Widget, 0), 39 | } 40 | 41 | // SimpleFocusChain represents a ring of widgets where focus is loops to the 42 | // first widget when it reaches the end. 43 | type SimpleFocusChain struct { 44 | widgets []Widget 45 | } 46 | 47 | // Set sets the widgets in the focus chain. Widgets will received focus in the 48 | // order widgets were passed. 49 | func (c *SimpleFocusChain) Set(ws ...Widget) { 50 | c.widgets = ws 51 | } 52 | 53 | // FocusNext returns the widget in the ring that is after the given widget. 54 | func (c *SimpleFocusChain) FocusNext(current Widget) Widget { 55 | for i, w := range c.widgets { 56 | if w != current { 57 | continue 58 | } 59 | if i < len(c.widgets)-1 { 60 | return c.widgets[i+1] 61 | } 62 | return c.widgets[0] 63 | } 64 | return nil 65 | } 66 | 67 | // FocusPrev returns the widget in the ring that is before the given widget. 68 | func (c *SimpleFocusChain) FocusPrev(current Widget) Widget { 69 | for i, w := range c.widgets { 70 | if w != current { 71 | continue 72 | } 73 | if i <= 0 { 74 | return c.widgets[len(c.widgets)-1] 75 | } 76 | return c.widgets[i-1] 77 | } 78 | return nil 79 | } 80 | 81 | // FocusDefault returns the default widget for when there is no widget 82 | // currently focused. 83 | func (c *SimpleFocusChain) FocusDefault() Widget { 84 | if len(c.widgets) == 0 { 85 | return nil 86 | } 87 | return c.widgets[0] 88 | } 89 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/gdamore/encoding v0.0.0-20151215212835-b23993cbb635 h1:hheUEMzaOie/wKeIc1WPa7CDVuIO5hqQxjS+dwTQEnI= 2 | github.com/gdamore/encoding v0.0.0-20151215212835-b23993cbb635/go.mod h1:yrQYJKKDTrHmbYxI7CYi+/hbdiDT2m4Hj+t0ikCjsrQ= 3 | github.com/gdamore/tcell v1.1.0 h1:RbQgl7jukmdqROeNcKps7R2YfDCQbWkOd1BwdXrxfr4= 4 | github.com/gdamore/tcell v1.1.0/go.mod h1:tqyG50u7+Ctv1w5VX67kLzKcj9YXR/JSBZQq/+mLl1A= 5 | github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ= 6 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 7 | github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e h1:JKmoR8x90Iww1ks85zJ1lfDGgIiMDuIptTOhJq+zKyg= 8 | github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 9 | github.com/jtolds/gls v4.2.1+incompatible h1:fSuqC+Gmlu6l/ZYAoZzx2pyucC8Xza35fpRVWLVmUEE= 10 | github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 11 | github.com/lucasb-eyer/go-colorful v0.0.0-20180709185858-c7842319cf3a h1:B2QfFRl5yGVGGcyEVFzfdXlC1BBvszsIAsCeef2oD0k= 12 | github.com/lucasb-eyer/go-colorful v0.0.0-20180709185858-c7842319cf3a/go.mod h1:NXg0ArsFk0Y01623LgUqoqcouGDB+PwCCQlrwrG6xJ4= 13 | github.com/mattn/go-runewidth v0.0.3 h1:a+kO+98RDGEfo6asOGMmpodZq4FNtnGP54yps8BzLR4= 14 | github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= 15 | github.com/mitchellh/go-wordwrap v1.0.0 h1:6GlHJ/LTGMrIJbwgdqdl2eEH8o+Exx/0m8ir9Gns0u4= 16 | github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= 17 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= 18 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= 19 | github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c h1:Ho+uVpkel/udgjbwB5Lktg9BtvJSh2DT0Hi6LPSyI2w= 20 | github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= 21 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 22 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 23 | gopkg.in/DATA-DOG/go-sqlmock.v1 v1.3.0 h1:FVCohIoYO7IJoDDVpV2pdq7SgrMH6wHnuTyrdrxJNoY= 24 | gopkg.in/DATA-DOG/go-sqlmock.v1 v1.3.0/go.mod h1:OdE7CF6DbADk7lN8LIKRzRJTTZXIjtWgA5THM5lhBAw= 25 | -------------------------------------------------------------------------------- /widget.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import "image" 4 | 5 | // SizePolicy determines the space occupied by a widget. 6 | type SizePolicy int 7 | 8 | const ( 9 | // Preferred interprets the size hint as the preferred size. 10 | Preferred SizePolicy = iota 11 | // Minimum allows the widget to shrink down to the size hint. 12 | Minimum 13 | // Maximum allows the widget to grow up to the size hint. 14 | Maximum 15 | // Expanding makes the widget expand to the available space. 16 | Expanding 17 | ) 18 | 19 | // Widget defines common operations on widgets. 20 | type Widget interface { 21 | Draw(p *Painter) 22 | MinSizeHint() image.Point 23 | Size() image.Point 24 | SizeHint() image.Point 25 | SizePolicy() (SizePolicy, SizePolicy) 26 | Resize(size image.Point) 27 | OnKeyEvent(ev KeyEvent) 28 | SetFocused(bool) 29 | IsFocused() bool 30 | } 31 | 32 | // WidgetBase defines base attributes and operations for all widgets. 33 | type WidgetBase struct { 34 | size image.Point 35 | 36 | sizePolicyX SizePolicy 37 | sizePolicyY SizePolicy 38 | 39 | focused bool 40 | } 41 | 42 | // Draw is an empty operation to fulfill the Widget interface. 43 | func (w *WidgetBase) Draw(p *Painter) { 44 | } 45 | 46 | // SetFocused focuses the widget. 47 | func (w *WidgetBase) SetFocused(f bool) { 48 | w.focused = f 49 | } 50 | 51 | // IsFocused returns whether the widget is focused. 52 | func (w *WidgetBase) IsFocused() bool { 53 | return w.focused 54 | } 55 | 56 | // MinSizeHint returns the size below which the widget cannot shrink. 57 | func (w *WidgetBase) MinSizeHint() image.Point { 58 | return image.Point{1, 1} 59 | } 60 | 61 | // Size returns the current size of the widget. 62 | func (w *WidgetBase) Size() image.Point { 63 | return w.size 64 | } 65 | 66 | // SizeHint returns the size hint of the widget. 67 | func (w *WidgetBase) SizeHint() image.Point { 68 | return image.Point{} 69 | } 70 | 71 | // SetSizePolicy sets the size policy for horizontal and vertical directions. 72 | func (w *WidgetBase) SetSizePolicy(h, v SizePolicy) { 73 | w.sizePolicyX = h 74 | w.sizePolicyY = v 75 | } 76 | 77 | // SizePolicy returns the current size policy. 78 | func (w *WidgetBase) SizePolicy() (SizePolicy, SizePolicy) { 79 | return w.sizePolicyX, w.sizePolicyY 80 | } 81 | 82 | // Resize sets the size of the widget. 83 | func (w *WidgetBase) Resize(size image.Point) { 84 | w.size = size 85 | } 86 | 87 | // OnKeyEvent is an empty operation to fulfill the Widget interface. 88 | func (w *WidgetBase) OnKeyEvent(ev KeyEvent) { 89 | } 90 | -------------------------------------------------------------------------------- /theme.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | // Color represents a color. 4 | type Color int32 5 | 6 | // Common colors. 7 | const ( 8 | ColorDefault Color = iota 9 | ColorBlack 10 | ColorWhite 11 | ColorRed 12 | ColorGreen 13 | ColorBlue 14 | ColorCyan 15 | ColorMagenta 16 | ColorYellow 17 | ) 18 | 19 | // Decoration represents a bold/underline/etc. state 20 | type Decoration int 21 | 22 | // Decoration modes: Inherit from parent widget, explicitly on, or explicitly off. 23 | const ( 24 | DecorationInherit Decoration = iota 25 | DecorationOn 26 | DecorationOff 27 | ) 28 | 29 | // Style determines how a cell should be painted. 30 | // The zero value uses default from 31 | type Style struct { 32 | Fg Color 33 | Bg Color 34 | 35 | Reverse Decoration 36 | Bold Decoration 37 | Underline Decoration 38 | } 39 | 40 | // mergeIn returns the receiver Style, with any changes in delta applied. 41 | func (s Style) mergeIn(delta Style) Style { 42 | result := s 43 | if delta.Fg != ColorDefault { 44 | result.Fg = delta.Fg 45 | } 46 | if delta.Bg != ColorDefault { 47 | result.Bg = delta.Bg 48 | } 49 | if delta.Reverse != DecorationInherit { 50 | result.Reverse = delta.Reverse 51 | } 52 | if delta.Bold != DecorationInherit { 53 | result.Bold = delta.Bold 54 | } 55 | if delta.Underline != DecorationInherit { 56 | result.Underline = delta.Underline 57 | } 58 | return result 59 | } 60 | 61 | // Theme defines the styles for a set of identifiers. 62 | type Theme struct { 63 | styles map[string]Style 64 | } 65 | 66 | // DefaultTheme is a theme with reasonable defaults. 67 | var DefaultTheme = &Theme{ 68 | styles: map[string]Style{ 69 | "list.item.selected": {Reverse: DecorationOn}, 70 | "table.cell.selected": {Reverse: DecorationOn}, 71 | "button.focused": {Reverse: DecorationOn}, 72 | }, 73 | } 74 | 75 | // NewTheme return an empty theme. 76 | func NewTheme() *Theme { 77 | return &Theme{ 78 | styles: make(map[string]Style), 79 | } 80 | } 81 | 82 | // SetStyle sets a style for a given identifier. 83 | func (p *Theme) SetStyle(n string, i Style) { 84 | p.styles[n] = i 85 | } 86 | 87 | // Style returns the style associated with an identifier. 88 | // If there is no Style associated with the name, it returns a default Style. 89 | func (p *Theme) Style(name string) Style { 90 | return p.styles[name] 91 | } 92 | 93 | // HasStyle returns whether an identifier is associated with an identifier. 94 | func (p *Theme) HasStyle(name string) bool { 95 | _, ok := p.styles[name] 96 | return ok 97 | } 98 | -------------------------------------------------------------------------------- /label.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "image" 5 | "strings" 6 | 7 | wordwrap "github.com/mitchellh/go-wordwrap" 8 | ) 9 | 10 | var _ Widget = &Label{} 11 | 12 | // Label is a widget to display read-only text. 13 | type Label struct { 14 | WidgetBase 15 | 16 | text string 17 | wordWrap bool 18 | 19 | // cache the result of SizeHint() (see #14) 20 | cacheSizeHint *image.Point 21 | 22 | styleName string 23 | } 24 | 25 | // NewLabel returns a new Label. 26 | func NewLabel(text string) *Label { 27 | return &Label{ 28 | text: text, 29 | } 30 | } 31 | 32 | // Resize changes the size of the Widget. 33 | func (l *Label) Resize(size image.Point) { 34 | if l.Size() != size { 35 | l.cacheSizeHint = nil 36 | } 37 | l.WidgetBase.Resize(size) 38 | } 39 | 40 | // Draw draws the label. 41 | func (l *Label) Draw(p *Painter) { 42 | lines := l.lines() 43 | 44 | style := "label" 45 | if l.styleName != "" { 46 | style += "." + l.styleName 47 | } 48 | 49 | p.WithStyle(style, func(p *Painter) { 50 | for i, line := range lines { 51 | p.DrawText(0, i, line) 52 | } 53 | }) 54 | } 55 | 56 | // MinSizeHint returns the minimum size the widget is allowed to be. 57 | func (l *Label) MinSizeHint() image.Point { 58 | return image.Point{1, 1} 59 | } 60 | 61 | // SizeHint returns the recommended size for the label. 62 | func (l *Label) SizeHint() image.Point { 63 | if l.cacheSizeHint != nil { 64 | return *l.cacheSizeHint 65 | } 66 | var max int 67 | lines := l.lines() 68 | for _, line := range lines { 69 | if w := stringWidth(line); w > max { 70 | max = w 71 | } 72 | } 73 | sizeHint := image.Point{max, len(lines)} 74 | l.cacheSizeHint = &sizeHint 75 | return sizeHint 76 | } 77 | 78 | func (l *Label) lines() []string { 79 | txt := l.text 80 | if l.wordWrap { 81 | txt = wordwrap.WrapString(l.text, uint(l.Size().X)) 82 | } 83 | return strings.Split(txt, "\n") 84 | } 85 | 86 | // Text returns the text content of the label. 87 | func (l *Label) Text() string { 88 | return l.text 89 | } 90 | 91 | // SetText sets the text content of the label. 92 | func (l *Label) SetText(text string) { 93 | l.cacheSizeHint = nil 94 | l.text = text 95 | } 96 | 97 | // SetWordWrap sets whether text content should be wrapped. 98 | func (l *Label) SetWordWrap(enabled bool) { 99 | l.wordWrap = enabled 100 | } 101 | 102 | // SetStyleName sets the identifier used for custom styling. 103 | func (l *Label) SetStyleName(style string) { 104 | l.styleName = style 105 | } 106 | -------------------------------------------------------------------------------- /example/color/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/marcusolsson/tui-go" 7 | ) 8 | 9 | // StyledBox is a Box with an overriden Draw method. 10 | // Embedding a Widget within another allows overriding of some behaviors. 11 | type StyledBox struct { 12 | Style string 13 | *tui.Box 14 | } 15 | 16 | // Draw decorates the Draw call to the widget with a style. 17 | func (s *StyledBox) Draw(p *tui.Painter) { 18 | p.WithStyle(s.Style, func(p *tui.Painter) { 19 | s.Box.Draw(p) 20 | }) 21 | } 22 | 23 | func main() { 24 | t := tui.NewTheme() 25 | normal := tui.Style{Bg: tui.ColorWhite, Fg: tui.ColorBlack} 26 | t.SetStyle("normal", normal) 27 | 28 | // A simple label. 29 | okay := tui.NewLabel("Everything is fine.") 30 | 31 | // A list with some items selected. 32 | l := tui.NewList() 33 | l.SetFocused(true) 34 | l.AddItems("First row", "Second row", "Third row", "Fourth row", "Fifth row", "Sixth row") 35 | l.SetSelected(0) 36 | 37 | t.SetStyle("list.item", tui.Style{Bg: tui.ColorCyan, Fg: tui.ColorMagenta}) 38 | t.SetStyle("list.item.selected", tui.Style{Bg: tui.ColorRed, Fg: tui.ColorWhite}) 39 | 40 | // The style name is appended to the widget name to support coloring of 41 | // individual labels. 42 | warning := tui.NewLabel("WARNING: This is a warning") 43 | warning.SetStyleName("warning") 44 | t.SetStyle("label.warning", tui.Style{Bg: tui.ColorDefault, Fg: tui.ColorYellow}) 45 | 46 | fatal := tui.NewLabel("FATAL: Cats and dogs are now living together.") 47 | fatal.SetStyleName("fatal") 48 | t.SetStyle("label.fatal", tui.Style{Bg: tui.ColorDefault, Fg: tui.ColorRed}) 49 | 50 | // Styles inherit properties of the parent widget by default; 51 | // setting a property overrides only that property. 52 | message1 := tui.NewLabel("This is an ") 53 | emphasis := tui.NewLabel("important") 54 | message2 := tui.NewLabel(" message from our sponsors.") 55 | message := &StyledBox{ 56 | Style: "bsod", 57 | Box: tui.NewHBox(message1, emphasis, message2, tui.NewSpacer()), 58 | } 59 | 60 | emphasis.SetStyleName("emphasis") 61 | t.SetStyle("label.emphasis", tui.Style{Bold: tui.DecorationOn, Underline: tui.DecorationOn, Bg: tui.ColorRed}) 62 | t.SetStyle("bsod", tui.Style{Bg: tui.ColorCyan, Fg: tui.ColorWhite}) 63 | 64 | // Another unstyled label. 65 | okay2 := tui.NewLabel("Everything is still fine.") 66 | 67 | root := tui.NewVBox(okay, l, warning, fatal, message, okay2) 68 | 69 | ui, err := tui.New(root) 70 | if err != nil { 71 | log.Fatal(err) 72 | } 73 | 74 | ui.SetTheme(t) 75 | ui.SetKeybinding("Esc", func() { ui.Quit() }) 76 | 77 | if err := ui.Run(); err != nil { 78 | log.Fatal(err) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /label_test.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "image" 5 | "testing" 6 | ) 7 | 8 | var labelTests = []struct { 9 | test string 10 | setup func() *Label 11 | size image.Point 12 | sizeHint image.Point 13 | }{ 14 | { 15 | test: "Empty", 16 | setup: func() *Label { 17 | return NewLabel("") 18 | }, 19 | size: image.Point{100, 100}, 20 | sizeHint: image.Point{0, 1}, 21 | }, 22 | { 23 | test: "Single word", 24 | setup: func() *Label { 25 | return NewLabel("test") 26 | }, 27 | size: image.Point{100, 100}, 28 | sizeHint: image.Point{4, 1}, 29 | }, 30 | { 31 | test: "Wide word", 32 | setup: func() *Label { 33 | return NewLabel("あäa") 34 | }, 35 | size: image.Point{100, 100}, 36 | sizeHint: image.Point{4, 1}, 37 | }, 38 | { 39 | test: "Unicode only", 40 | setup: func() *Label { 41 | return NewLabel("∅") 42 | }, 43 | size: image.Point{100, 100}, 44 | sizeHint: image.Point{1, 1}, 45 | }, 46 | { 47 | test: "Tall string", 48 | setup: func() *Label { 49 | return NewLabel("Null set: ∅") 50 | }, 51 | size: image.Point{100, 100}, 52 | sizeHint: image.Point{11, 1}, 53 | }, 54 | } 55 | 56 | func TestLabel_Size(t *testing.T) { 57 | for _, tt := range labelTests { 58 | tt := tt 59 | t.Run(tt.test, func(t *testing.T) { 60 | t.Parallel() 61 | 62 | l := tt.setup() 63 | l.Resize(image.Point{100, 100}) 64 | 65 | if got := l.Size(); got != tt.size { 66 | t.Errorf("l.Size() = %s; want = %s", got, tt.size) 67 | } 68 | if got := l.SizeHint(); got != tt.sizeHint { 69 | t.Errorf("l.SizeHint() = %s; want = %s", got, tt.sizeHint) 70 | } 71 | }) 72 | } 73 | } 74 | 75 | var drawLabelTests = []struct { 76 | test string 77 | setup func() *Label 78 | want string 79 | }{ 80 | { 81 | test: "Simple label", 82 | setup: func() *Label { 83 | return NewLabel("test") 84 | }, 85 | want: ` 86 | test...... 87 | .......... 88 | .......... 89 | .......... 90 | .......... 91 | `, 92 | }, 93 | { 94 | test: "Word wrap", 95 | setup: func() *Label { 96 | l := NewLabel("this will wrap") 97 | l.SetWordWrap(true) 98 | l.SetSizePolicy(Expanding, Expanding) 99 | return l 100 | }, 101 | want: ` 102 | this will. 103 | wrap...... 104 | .......... 105 | .......... 106 | .......... 107 | `, 108 | }, 109 | } 110 | 111 | func TestLabel_Draw(t *testing.T) { 112 | for _, tt := range drawLabelTests { 113 | surface := NewTestSurface(10, 5) 114 | 115 | painter := NewPainter(surface, NewTheme()) 116 | painter.Repaint(tt.setup()) 117 | 118 | if diff := surfaceEquals(surface, tt.want); diff != "" { 119 | t.Error(diff) 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /example/audioplayer/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "time" 7 | 8 | "github.com/marcusolsson/tui-go" 9 | ) 10 | 11 | type song struct { 12 | artist string 13 | album string 14 | track string 15 | duration time.Duration 16 | } 17 | 18 | var songs = []song{ 19 | {artist: "DJ Example", album: "Mixtape 3", track: "Testing stuff", duration: 110 * time.Second}, 20 | {artist: "DJ Example", album: "Mixtape 3", track: "Breaking stuff", duration: 140 * time.Second}, 21 | {artist: "DJ Example", album: "Initial commit", track: "****, not again ...", duration: 150 * time.Second}, 22 | } 23 | 24 | func main() { 25 | var p player 26 | 27 | progress := tui.NewProgress(100) 28 | 29 | library := tui.NewTable(0, 0) 30 | library.SetColumnStretch(0, 1) 31 | library.SetColumnStretch(1, 1) 32 | library.SetColumnStretch(2, 4) 33 | library.SetFocused(true) 34 | 35 | library.AppendRow( 36 | tui.NewLabel("ARTIST"), 37 | tui.NewLabel("ALBUM"), 38 | tui.NewLabel("TRACK"), 39 | ) 40 | 41 | for _, s := range songs { 42 | library.AppendRow( 43 | tui.NewLabel(s.artist), 44 | tui.NewLabel(s.album), 45 | tui.NewLabel(s.track), 46 | ) 47 | } 48 | 49 | status := tui.NewStatusBar("") 50 | status.SetPermanentText(`VOLUME 68%`) 51 | 52 | root := tui.NewVBox( 53 | library, 54 | tui.NewSpacer(), 55 | progress, 56 | status, 57 | ) 58 | 59 | ui, err := tui.New(root) 60 | if err != nil { 61 | log.Fatal(err) 62 | } 63 | 64 | library.OnItemActivated(func(t *tui.Table) { 65 | p.play(songs[t.Selected()-1], func(curr, max int) { 66 | ui.Update(func() { 67 | progress.SetCurrent(curr) 68 | progress.SetMax(max) 69 | 70 | status.SetText(fmt.Sprintf("%s / %s", time.Duration(curr)*time.Second, time.Duration(max)*time.Second)) 71 | }) 72 | }) 73 | }) 74 | 75 | ui.SetKeybinding("Esc", func() { ui.Quit() }) 76 | ui.SetKeybinding("q", func() { ui.Quit() }) 77 | 78 | if err := ui.Run(); err != nil { 79 | log.Fatal(err) 80 | } 81 | } 82 | 83 | type player struct { 84 | elapsed int 85 | total int 86 | quit chan struct{} 87 | } 88 | 89 | func (p *player) play(s song, callback func(current, max int)) { 90 | if p.quit != nil { 91 | p.quit <- struct{}{} 92 | <-p.quit 93 | } 94 | 95 | p.quit = make(chan struct{}) 96 | p.total = int(s.duration.Seconds()) 97 | p.elapsed = 0 98 | 99 | go func() { 100 | for { 101 | select { 102 | case <-time.After(1 * time.Second): 103 | if p.elapsed >= p.total { 104 | p.quit <- struct{}{} 105 | } 106 | 107 | callback(p.elapsed, p.total) 108 | p.elapsed++ 109 | case <-p.quit: 110 | p.quit <- struct{}{} 111 | return 112 | } 113 | } 114 | }() 115 | } 116 | -------------------------------------------------------------------------------- /example/tabs/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/marcusolsson/tui-go" 8 | ) 9 | 10 | type uiTab struct { 11 | label *tui.Label 12 | view tui.Widget 13 | } 14 | 15 | func newTabWidget(views ...*uiTab) *tabWidget { 16 | topbar := tui.NewHBox(tui.NewLabel("> ")) 17 | topbar.SetSizePolicy(tui.Minimum, tui.Maximum) 18 | view := &tabWidget{views: views} 19 | 20 | for i := 0; i < len(views); i++ { 21 | topbar.Append(views[i].label) 22 | } 23 | 24 | topbar.Append(tui.NewSpacer()) 25 | view.style() 26 | 27 | vbox := tui.NewVBox(topbar, views[0].view) 28 | vbox.SetSizePolicy(tui.Maximum, tui.Preferred) 29 | view.Box = vbox 30 | return view 31 | } 32 | 33 | type tabWidget struct { 34 | *tui.Box 35 | 36 | views []*uiTab 37 | active int 38 | } 39 | 40 | func (t *tabWidget) OnKeyEvent(ev tui.KeyEvent) { 41 | switch ev.Key { 42 | case tui.KeyTab, tui.KeyRight: 43 | t.Next() 44 | case tui.KeyBacktab, tui.KeyLeft: 45 | t.Previous() 46 | } 47 | 48 | t.Box.OnKeyEvent(ev) 49 | } 50 | 51 | func (t *tabWidget) setView(view tui.Widget) { 52 | t.Box.Remove(1) 53 | t.Box.Append(view) 54 | } 55 | 56 | func (t *tabWidget) style() { 57 | for i := 0; i < len(t.views); i++ { 58 | if i == t.active { 59 | t.views[i].label.SetStyleName("tab-selected") 60 | continue 61 | } 62 | t.views[i].label.SetStyleName("tab") 63 | } 64 | } 65 | 66 | func (t *tabWidget) Next() { 67 | t.active = clamp(t.active+1, 0, len(t.views)-1) 68 | t.style() 69 | t.setView(t.views[t.active].view) 70 | } 71 | 72 | func (t *tabWidget) Previous() { 73 | t.active = clamp(t.active-1, 0, len(t.views)-1) 74 | t.style() 75 | t.setView(t.views[t.active].view) 76 | } 77 | 78 | func clamp(n, min, max int) int { 79 | if n < min { 80 | return max 81 | } 82 | if n > max { 83 | return min 84 | } 85 | return n 86 | } 87 | 88 | func main() { 89 | extendedView := tui.NewList() 90 | for i := 0; i < 20; i++ { 91 | extendedView.AddItems(fmt.Sprintf("content here x%d", i)) 92 | } 93 | 94 | tabLayout := newTabWidget( 95 | &uiTab{label: tui.NewLabel(" tab 1 "), view: extendedView}, 96 | &uiTab{label: tui.NewLabel(" tab 2 "), view: tui.NewLabel("some other view here x2")}, 97 | &uiTab{label: tui.NewLabel(" tab 3 "), view: tui.NewLabel("some other view here x3")}, 98 | &uiTab{label: tui.NewLabel(" tab 4 "), view: tui.NewLabel("some other view here x4")}, 99 | ) 100 | 101 | ui, err := tui.New(tabLayout) 102 | if err != nil { 103 | log.Fatal(err) 104 | } 105 | 106 | ui.SetKeybinding("Esc", func() { ui.Quit() }) 107 | 108 | theme := tui.NewTheme() 109 | theme.SetStyle("label.tab", tui.Style{Reverse: tui.DecorationOff}) 110 | theme.SetStyle("label.tab-selected", tui.Style{Reverse: tui.DecorationOn, Fg: tui.ColorMagenta, Bg: tui.ColorWhite}) 111 | ui.SetTheme(theme) 112 | 113 | if err := ui.Run(); err != nil { 114 | log.Fatal(err) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /example/editor/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/marcusolsson/tui-go" 7 | ) 8 | 9 | func main() { 10 | buffer := tui.NewTextEdit() 11 | buffer.SetSizePolicy(tui.Expanding, tui.Expanding) 12 | buffer.SetText(body) 13 | buffer.SetFocused(true) 14 | buffer.SetWordWrap(true) 15 | 16 | status := tui.NewStatusBar("lorem.txt") 17 | 18 | root := tui.NewVBox(buffer, status) 19 | 20 | ui, err := tui.New(root) 21 | if err != nil { 22 | log.Fatal(err) 23 | } 24 | 25 | ui.SetKeybinding("Esc", func() { ui.Quit() }) 26 | 27 | if err := ui.Run(); err != nil { 28 | log.Fatal(err) 29 | } 30 | } 31 | 32 | const body = `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dui vitae nisl scelerisque porta. Proin orci mauris, imperdiet ac venenatis id, interdum fermentum ligula. Praesent risus odio, pharetra ac maximus in, tincidunt eget nibh. Vestibulum pretium molestie fermentum. Aenean sed neque purus. Vivamus vitae nulla nec ligula ultrices lacinia. Ut in vulputate ante. Proin lacinia eleifend varius. Cras quis urna eget nisi efficitur tristique sed vitae nibh. Nam ac nisi libero. In interdum volutpat elementum. Nulla lorem magna, efficitur interdum ante at, convallis sodales nulla. Sed maximus tempor condimentum. 33 | 34 | Nam et risus est. Cras ornare iaculis orci, sit amet fringilla nisl pharetra quis. Integer quis sem porttitor, gravida nisi eget, feugiat lacus. Aliquam aliquet quam eget ipsum ultrices, in viverra ex dapibus. Sed ullamcorper, justo sit amet feugiat faucibus, nisl sem hendrerit dui, non tincidunt lacus turpis non tortor. Aenean nisl justo, dictum non eros quis, luctus pulvinar urna. Ut finibus odio id nunc rutrum iaculis. 35 | 36 | Nam commodo tempor augue, nec facilisis nulla pretium scelerisque. Donec eu interdum nisl. Aliquam dui nisl, venenatis id velit ac, ultrices faucibus massa. Suspendisse id condimentum augue. Sed a libero ornare, sollicitudin neque sed, blandit nunc. Quisque sed sem non erat pharetra semper. Mauris molestie leo ante, in varius elit ullamcorper at. Suspendisse sed scelerisque velit, eget rutrum nunc. Cras in ultrices risus. Aliquam maximus, purus in consequat rutrum, erat mauris pharetra lacus, nec interdum turpis metus non velit. Cras in lobortis tortor, vitae dignissim mauris. Phasellus nec massa nisi. Etiam auctor, odio egestas egestas ullamcorper, mauris risus maximus nisl, eget faucibus risus dui sed nisi. Sed ligula mi, egestas in augue vitae, tincidunt molestie sapien. 37 | 38 | Maecenas eget tristique dolor. Quisque vel velit ante. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Pellentesque lorem diam, feugiat ut odio et, tempus consequat mauris. Phasellus lobortis sodales tellus, sed aliquam lectus lobortis id. Nulla mollis tempor elit. Etiam luctus convallis justo, sed viverra nibh sodales eget. Aliquam blandit, felis eget accumsan tempus, orci magna molestie metus, vel bibendum nibh risus at augue. Maecenas vulputate feugiat dui sit amet facilisis. In et eros vel elit vestibulum laoreet at id mauris. Nullam tincidunt suscipit diam, vel sollicitudin massa venenatis id. Fusce porttitor urna et aliquam dignissim. Maecenas mollis ligula ut ex maximus, vel feugiat metus scelerisque. Sed pharetra ac nunc in pharetra.` 39 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at olsson.e.marcus@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /table_test.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "image" 5 | "testing" 6 | ) 7 | 8 | var drawTableTests = []struct { 9 | test string 10 | size image.Point 11 | setup func() *Box 12 | want string 13 | }{ 14 | { 15 | test: "Long labels are masked (#31)", 16 | size: image.Point{32, 10}, 17 | setup: func() *Box { 18 | first := NewTable(0, 0) 19 | first.AppendRow(NewLabel("ABC123"), NewLabel("test")) 20 | first.AppendRow(NewLabel("DEF456"), NewLabel("testing a longer text")) 21 | first.AppendRow(NewLabel("GHI789"), NewLabel("foo")) 22 | first.SetBorder(true) 23 | 24 | second := NewVBox(NewLabel("test")) 25 | second.SetBorder(true) 26 | 27 | third := NewHBox(first, second) 28 | third.SetBorder(true) 29 | 30 | return third 31 | }, 32 | want: ` 33 | ┌──────────────────────────────┐ 34 | │┌───────────┬──────────┐┌────┐│ 35 | ││ABC123 │test ││test││ 36 | ││ │ ││ ││ 37 | │├───────────┼──────────┤│ ││ 38 | ││DEF456 │testing a ││ ││ 39 | │├───────────┼──────────┤│ ││ 40 | ││GHI789 │foo ││ ││ 41 | │└───────────┴──────────┘└────┘│ 42 | └──────────────────────────────┘ 43 | `, 44 | }, 45 | { 46 | test: "Remove a row from table", 47 | size: image.Point{20, 10}, 48 | setup: func() *Box { 49 | table := NewTable(0, 0) 50 | table.AppendRow(NewLabel("A"), NewLabel("apple")) 51 | table.AppendRow(NewLabel("B"), NewLabel("box")) 52 | table.AppendRow(NewLabel("C"), NewLabel("cat")) 53 | table.AppendRow(NewLabel("D"), NewLabel("dog")) 54 | table.SetBorder(true) 55 | 56 | table.RemoveRow(1) 57 | 58 | box := NewHBox(table) 59 | box.SetBorder(true) 60 | 61 | return box 62 | }, 63 | want: ` 64 | ┌──────────────────┐ 65 | │┌────────┬───────┐│ 66 | ││A │apple ││ 67 | ││ │ ││ 68 | │├────────┼───────┤│ 69 | ││C │cat ││ 70 | │├────────┼───────┤│ 71 | ││D │dog ││ 72 | │└────────┴───────┘│ 73 | └──────────────────┘ 74 | `, 75 | }, 76 | { 77 | test: "Remove all rows from table", 78 | size: image.Point{20, 10}, 79 | setup: func() *Box { 80 | table := NewTable(0, 0) 81 | table.AppendRow(NewLabel("A"), NewLabel("apple")) 82 | table.AppendRow(NewLabel("B"), NewLabel("box")) 83 | table.AppendRow(NewLabel("C"), NewLabel("cat")) 84 | table.AppendRow(NewLabel("D"), NewLabel("dog")) 85 | table.SetBorder(true) 86 | 87 | table.RemoveRows() 88 | 89 | box := NewHBox(table) 90 | box.SetBorder(true) 91 | 92 | return box 93 | }, 94 | want: ` 95 | ┌──────────────────┐ 96 | │┌────────┬───────┐│ 97 | ││ │ ││ 98 | ││ │ ││ 99 | ││ │ ││ 100 | ││ │ ││ 101 | ││ │ ││ 102 | ││ │ ││ 103 | │└────────┴───────┘│ 104 | └──────────────────┘ 105 | `, 106 | }, 107 | } 108 | 109 | func TestTable_Draw(t *testing.T) { 110 | for _, tt := range drawTableTests { 111 | tt := tt 112 | t.Run(tt.test, func(t *testing.T) { 113 | var surface *TestSurface 114 | if tt.size.X == 0 && tt.size.Y == 0 { 115 | surface = NewTestSurface(10, 5) 116 | } else { 117 | surface = NewTestSurface(tt.size.X, tt.size.Y) 118 | } 119 | 120 | painter := NewPainter(surface, NewTheme()) 121 | painter.Repaint(tt.setup()) 122 | 123 | if diff := surfaceEquals(surface, tt.want); diff != "" { 124 | t.Error(diff) 125 | } 126 | }) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tui: Terminal UI for Go 2 | 3 | [![Build Status](https://travis-ci.org/marcusolsson/tui-go.svg?branch=master)](https://travis-ci.org/marcusolsson/tui-go) 4 | [![GoDoc](https://img.shields.io/badge/godoc-reference-blue.svg?style=flat)](https://godoc.org/github.com/marcusolsson/tui-go) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/marcusolsson/tui-go)](https://goreportcard.com/report/github.com/marcusolsson/tui-go) 6 | [![License MIT](https://img.shields.io/badge/license-MIT-lightgrey.svg?style=flat)](LICENSE) 7 | 8 | A UI library for terminal applications. 9 | 10 | tui (pronounced _tooey_) provides a higher-level programming model for building rich terminal applications. It lets you build layout-based user interfaces that (should) gracefully handle resizing for you. 11 | 12 | _IMPORTANT:_ tui-go is still in an experimental phase so please don't use it for anything other than experiments, yet. 13 | 14 | **Update**: I created tui-go as an experiment because I wanted a simpler way of creating terminal-based user interfaces. It has since then become a project, with all the work that comes with it. While it's been really fun, unfortunately I'm no longer able to maintain this project. 15 | 16 | Since I started working on tui-go, a number of similar projects have popped up. One that I think shows great promise is [rivo/tview](https://github.com/rivo/tview), which embodies much of what I envisioned for tui-go. I highly recommend trying it out! 17 | 18 | Thanks all of you who have contributed and supported tui-go! 19 | 20 | ![Screenshot](example/chat/screenshot.png) 21 | 22 | ## Installation 23 | 24 | ``` 25 | go get github.com/marcusolsson/tui-go 26 | ``` 27 | 28 | ## Usage 29 | 30 | ```go 31 | package main 32 | 33 | import "github.com/marcusolsson/tui-go" 34 | 35 | func main() { 36 | box := tui.NewVBox( 37 | tui.NewLabel("tui-go"), 38 | ) 39 | 40 | ui, err := tui.New(box) 41 | if err != nil { 42 | panic(err) 43 | } 44 | ui.SetKeybinding("Esc", func() { ui.Quit() }) 45 | 46 | if err := ui.Run(); err != nil { 47 | panic(err) 48 | } 49 | } 50 | ``` 51 | 52 | ## Getting started 53 | 54 | If you want to know what it is like to build terminal applications with tui-go, check out some of the [examples](example). 55 | 56 | Documentation is available at [godoc.org](https://godoc.org/github.com/marcusolsson/tui-go). 57 | 58 | Make sure you check out some of the [projects using tui-go](https://godoc.org/github.com/marcusolsson/tui-go?importers). 59 | 60 | Once you've gotten started developing your first application with tui-go, you might be interested in learning about common [patterns](https://github.com/marcusolsson/tui-go/wiki/Patterns) or how you can [debug](https://github.com/marcusolsson/tui-go/wiki/Debugging) your applications. 61 | 62 | ## Related projects 63 | 64 | tui-go is mainly influenced by [Qt](https://www.qt.io/) and offers a similar programming model that has been adapted to Go and the terminal. 65 | 66 | For an overview of the alternatives for writing terminal user interfaces, check out [this article](https://appliedgo.net/tui/) by [AppliedGo](https://appliedgo.net/). 67 | 68 | ## License 69 | 70 | tui-go is released under the [MIT License](LICENSE). 71 | 72 | ## Contact 73 | 74 | If you're interested in chatting with users and contributors, join 75 | [#tui-go](https://gophers.slack.com/messages/tui-go) on 76 | the [Gophers Slack](https://gophers.slack.com). 77 | If you're not already a part of the Slack workspace, you can join 78 | [here](https://invite.slack.golangbridge.org/). If you prefer a lower-bandwidth 79 | interface, see [this 80 | article](https://get.slack.help/hc/en-us/articles/201727913-Connect-to-Slack-over-IRC-and-XMPP) 81 | on connecting to Slack via IRC or XMPP. 82 | -------------------------------------------------------------------------------- /text_edit.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "image" 5 | "strings" 6 | ) 7 | 8 | var _ Widget = &TextEdit{} 9 | 10 | // TextEdit is a multi-line text editor. 11 | type TextEdit struct { 12 | WidgetBase 13 | 14 | text RuneBuffer 15 | offset int 16 | 17 | onTextChange func(*TextEdit) 18 | } 19 | 20 | // NewTextEdit returns a new TextEdit. 21 | func NewTextEdit() *TextEdit { 22 | return &TextEdit{} 23 | } 24 | 25 | // Draw draws the entry. 26 | func (e *TextEdit) Draw(p *Painter) { 27 | style := "entry" 28 | if e.IsFocused() { 29 | style += ".focused" 30 | } 31 | p.WithStyle(style, func(p *Painter) { 32 | s := e.Size() 33 | e.text.SetMaxWidth(s.X) 34 | 35 | lines := e.text.SplitByLine() 36 | for i, line := range lines { 37 | p.FillRect(0, i, s.X, 1) 38 | p.DrawText(0, i, line) 39 | } 40 | if e.IsFocused() { 41 | pos := e.text.CursorPos() 42 | p.DrawCursor(pos.X, pos.Y) 43 | } 44 | }) 45 | } 46 | 47 | // SizeHint returns the recommended size for the entry. 48 | func (e *TextEdit) SizeHint() image.Point { 49 | var max int 50 | lines := strings.Split(e.text.String(), "\n") 51 | for _, line := range lines { 52 | if w := stringWidth(line); w > max { 53 | max = w 54 | } 55 | } 56 | return image.Point{max, e.text.heightForWidth(max)} 57 | } 58 | 59 | // OnKeyEvent handles key events. 60 | func (e *TextEdit) OnKeyEvent(ev KeyEvent) { 61 | if !e.IsFocused() { 62 | return 63 | } 64 | 65 | screenWidth := e.Size().X 66 | e.text.SetMaxWidth(screenWidth) 67 | 68 | if ev.Key != KeyRune { 69 | switch ev.Key { 70 | case KeyEnter: 71 | e.text.WriteRune('\n') 72 | case KeyBackspace: 73 | fallthrough 74 | case KeyBackspace2: 75 | e.text.Backspace() 76 | if e.offset > 0 && !e.isTextRemaining() { 77 | e.offset-- 78 | } 79 | if e.onTextChange != nil { 80 | e.onTextChange(e) 81 | } 82 | case KeyDelete, KeyCtrlD: 83 | e.text.Delete() 84 | if e.onTextChange != nil { 85 | e.onTextChange(e) 86 | } 87 | case KeyLeft, KeyCtrlB: 88 | e.text.MoveBackward() 89 | if e.offset > 0 { 90 | e.offset-- 91 | } 92 | case KeyRight, KeyCtrlF: 93 | e.text.MoveForward() 94 | 95 | isCursorTooFar := e.text.CursorPos().X >= screenWidth 96 | isTextLeft := (e.text.Width() - e.offset) > (screenWidth - 1) 97 | 98 | if isCursorTooFar && isTextLeft { 99 | e.offset++ 100 | } 101 | case KeyHome, KeyCtrlA: 102 | e.text.MoveToLineStart() 103 | e.offset = 0 104 | case KeyEnd, KeyCtrlE: 105 | e.text.MoveToLineEnd() 106 | left := e.text.Width() - (screenWidth - 1) 107 | if left >= 0 { 108 | e.offset = left 109 | } 110 | case KeyCtrlK: 111 | e.text.Kill() 112 | } 113 | return 114 | } 115 | 116 | e.text.WriteRune(ev.Rune) 117 | if e.text.CursorPos().X >= screenWidth { 118 | e.offset++ 119 | } 120 | if e.onTextChange != nil { 121 | e.onTextChange(e) 122 | } 123 | } 124 | 125 | // OnTextChanged sets a function to be run whenever the text content of the 126 | // widget has been changed. 127 | func (e *TextEdit) OnTextChanged(fn func(entry *TextEdit)) { 128 | e.onTextChange = fn 129 | } 130 | 131 | // SetText sets the text content of the entry. 132 | func (e *TextEdit) SetText(text string) { 133 | e.text.Set([]rune(text)) 134 | } 135 | 136 | // Text returns the text content of the entry. 137 | func (e *TextEdit) Text() string { 138 | return e.text.String() 139 | } 140 | 141 | // SetWordWrap sets whether the text should wrap or not. 142 | func (e *TextEdit) SetWordWrap(enabled bool) { 143 | e.text.wordwrap = enabled 144 | } 145 | 146 | func (e *TextEdit) isTextRemaining() bool { 147 | return e.text.Width()-e.offset > e.Size().X 148 | } 149 | -------------------------------------------------------------------------------- /label_sizehint_test.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "image" 5 | "testing" 6 | ) 7 | 8 | var TailBoxTests = []struct { 9 | Test string 10 | Setup func() Widget 11 | Want string 12 | } { 13 | { 14 | Test: "draw small labels", 15 | Setup: func() Widget { 16 | return NewTailBox( 17 | NewLabel("hello mom"), 18 | NewLabel("hello dad"), 19 | ) 20 | }, 21 | Want: ` 22 | 23 | 24 | 25 | hello mom 26 | hello dad 27 | `, 28 | }, 29 | { 30 | Test: "draw unwrapped labels", 31 | Setup: func() Widget { 32 | l1, l2 := NewLabel("hello muddah"), NewLabel("hello faddah") 33 | return NewTailBox(l1, l2) 34 | }, 35 | Want: ` 36 | 37 | 38 | 39 | hello mudd 40 | hello fadd 41 | `, 42 | }, 43 | { 44 | Test: "draw wrapped labels", 45 | Setup: func() Widget { 46 | l1, l2 := NewLabel("hello muddah"), NewLabel("hello faddah") 47 | l1.SetWordWrap(true) 48 | l2.SetWordWrap(true) 49 | return NewTailBox(l1, l2) 50 | }, 51 | Want: ` 52 | 53 | hello 54 | muddah 55 | hello 56 | faddah 57 | `, 58 | }, 59 | 60 | 61 | } 62 | 63 | func TestTailBox(t *testing.T) { 64 | for _, tt := range TailBoxTests { 65 | tt := tt 66 | t.Run(tt.Test, func(t *testing.T) { 67 | surface := NewTestSurface(10, 5) 68 | p := NewPainter(surface, NewTheme()) 69 | p.Repaint(tt.Setup()) 70 | 71 | if surface.String() != tt.Want { 72 | t.Errorf("unexpected contents: got = \n%s\nwant = \n%s", surface.String(), tt.Want) 73 | } 74 | }) 75 | } 76 | } 77 | 78 | 79 | // TailBox is a container Widget that may not show all its 80 | // While Box attempts to show every contained Widget - sometimes shrinking 81 | // those Widgets to do so- TailBox prioritizes completely displaying its last 82 | // Widget, then the next-to-last widget, etc. 83 | // It is vertically-aligned, i.e. all the contained Widgets have the same width. 84 | type TailBox struct { 85 | WidgetBase 86 | sz image.Point 87 | contents []Widget 88 | } 89 | 90 | var _ Widget = &TailBox{} 91 | 92 | func NewTailBox(w ...Widget) *TailBox { 93 | return &TailBox{ 94 | contents: w, 95 | } 96 | } 97 | 98 | func (t *TailBox) Append(w Widget) { 99 | t.contents = append(t.contents, w) 100 | t.doLayout(t.Size()) 101 | } 102 | 103 | func (t *TailBox) SetContents(w ...Widget) { 104 | t.contents = w 105 | t.doLayout(t.Size()) 106 | } 107 | 108 | func (t *TailBox) Draw(p *Painter) { 109 | p.WithMask(image.Rect(0, 0, t.sz.X, t.sz.Y), func(p *Painter) { 110 | // Draw background 111 | p.FillRect(0, 0, t.sz.X, t.sz.Y) 112 | 113 | // Draw from the bottom up. 114 | space := t.sz.Y 115 | p.Translate(0, space) 116 | defer p.Restore() 117 | for i := len(t.contents) - 1; i >= 0 && space > 0; i-- { 118 | w := t.contents[i] 119 | space -= w.Size().Y 120 | p.Translate(0, -w.Size().Y) 121 | defer p.Restore() 122 | w.Draw(p) 123 | } 124 | }) 125 | } 126 | 127 | // Resize recalculates the layout of the box's contents. 128 | func (t *TailBox) Resize(size image.Point) { 129 | t.WidgetBase.Resize(size) 130 | defer func() { 131 | t.sz = size 132 | }() 133 | 134 | // If it's just a height change, Draw should do the right thing already. 135 | if size.X != t.sz.X { 136 | t.doLayout(size) 137 | } 138 | } 139 | 140 | func (t *TailBox) doLayout(size image.Point) { 141 | for _, w := range t.contents { 142 | hint := w.SizeHint() 143 | // Set the width to the container width, and height to the requested height 144 | w.Resize(image.Pt(size.X, hint.Y)) 145 | // ...and then resize again, now that the Y-hint has been refreshed by the X-value. 146 | hint = w.SizeHint() 147 | w.Resize(image.Pt(size.X, hint.Y)) 148 | } 149 | } 150 | 151 | 152 | -------------------------------------------------------------------------------- /example/http/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io/ioutil" 5 | "log" 6 | "net/http" 7 | "sort" 8 | "strings" 9 | 10 | "github.com/marcusolsson/tui-go" 11 | ) 12 | 13 | var ( 14 | method = "GET" 15 | params = "x=2&y=3" 16 | payload = `{"id": 12}` 17 | headers = "User-Agent: myBrowser" 18 | ) 19 | 20 | func main() { 21 | reqParamsEdit := tui.NewTextEdit() 22 | reqParamsEdit.SetText(params) 23 | reqParamsEdit.OnTextChanged(func(e *tui.TextEdit) { 24 | params = e.Text() 25 | }) 26 | 27 | reqParams := tui.NewVBox(reqParamsEdit) 28 | reqParams.SetTitle("URL Params") 29 | reqParams.SetBorder(true) 30 | 31 | reqMethodEntry := tui.NewEntry() 32 | reqMethodEntry.SetText(method) 33 | reqMethodEntry.OnChanged(func(e *tui.Entry) { 34 | method = e.Text() 35 | }) 36 | 37 | reqMethod := tui.NewVBox(reqMethodEntry) 38 | reqMethod.SetTitle("Request method") 39 | reqMethod.SetBorder(true) 40 | reqMethod.SetSizePolicy(tui.Preferred, tui.Maximum) 41 | 42 | reqDataEdit := tui.NewTextEdit() 43 | reqDataEdit.SetText(payload) 44 | reqDataEdit.OnTextChanged(func(e *tui.TextEdit) { 45 | payload = e.Text() 46 | }) 47 | 48 | reqData := tui.NewVBox(reqDataEdit) 49 | reqData.SetTitle("Request body") 50 | reqData.SetBorder(true) 51 | 52 | reqHeadEdit := tui.NewTextEdit() 53 | reqHeadEdit.SetText(headers) 54 | reqHeadEdit.OnTextChanged(func(e *tui.TextEdit) { 55 | headers = e.Text() 56 | }) 57 | 58 | reqHead := tui.NewVBox(reqHeadEdit) 59 | reqHead.SetTitle("Request headers") 60 | reqHead.SetBorder(true) 61 | 62 | respHeadLbl := tui.NewLabel("") 63 | respHeadLbl.SetSizePolicy(tui.Expanding, tui.Expanding) 64 | 65 | respHead := tui.NewVBox(respHeadLbl) 66 | respHead.SetTitle("Response headers") 67 | respHead.SetBorder(true) 68 | 69 | respBodyLbl := tui.NewLabel("") 70 | respBodyLbl.SetSizePolicy(tui.Expanding, tui.Expanding) 71 | 72 | respBody := tui.NewVBox(respBodyLbl) 73 | respBody.SetTitle("Response body") 74 | respBody.SetBorder(true) 75 | 76 | req := tui.NewVBox(reqParams, reqMethod, reqData, reqHead) 77 | resp := tui.NewVBox(respHead, respBody) 78 | resp.SetSizePolicy(tui.Expanding, tui.Preferred) 79 | 80 | browser := tui.NewHBox(req, resp) 81 | browser.SetSizePolicy(tui.Preferred, tui.Expanding) 82 | 83 | urlEntry := tui.NewEntry() 84 | urlEntry.SetText("https://httpbin.org/get") 85 | urlEntry.OnSubmit(func(e *tui.Entry) { 86 | req, err := http.NewRequest(method, e.Text(), strings.NewReader(payload)) 87 | if err != nil { 88 | return 89 | } 90 | req.URL.RawQuery = params 91 | 92 | for _, h := range strings.Split(headers, "\n") { 93 | kv := strings.Split(h, ":") 94 | if len(kv) == 2 { 95 | req.Header.Set(kv[0], kv[1]) 96 | } 97 | } 98 | 99 | resp, err := http.DefaultClient.Do(req) 100 | if err != nil { 101 | return 102 | } 103 | defer resp.Body.Close() 104 | 105 | var headers []string 106 | for k, v := range resp.Header { 107 | headers = append(headers, k+": "+strings.Join(v, ";")) 108 | 109 | } 110 | sort.Strings(headers) 111 | 112 | respHeadLbl.SetText(strings.Join(headers, "\n")) 113 | 114 | b, _ := ioutil.ReadAll(resp.Body) 115 | respBodyLbl.SetText(string(b)) 116 | }) 117 | 118 | urlBox := tui.NewHBox(urlEntry) 119 | urlBox.SetTitle("URL") 120 | urlBox.SetBorder(true) 121 | 122 | root := tui.NewVBox(urlBox, browser) 123 | 124 | tui.DefaultFocusChain.Set(urlEntry, reqParamsEdit, reqMethodEntry, reqDataEdit, reqHeadEdit) 125 | 126 | theme := tui.NewTheme() 127 | theme.SetStyle("box.focused.border", tui.Style{Fg: tui.ColorYellow, Bg: tui.ColorDefault}) 128 | 129 | ui, err := tui.New(root) 130 | if err != nil { 131 | log.Fatal(err) 132 | } 133 | 134 | ui.SetTheme(theme) 135 | ui.SetKeybinding("Esc", func() { ui.Quit() }) 136 | 137 | if err := ui.Run(); err != nil { 138 | log.Fatal(err) 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /list.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import "image" 4 | 5 | var _ Widget = &List{} 6 | 7 | // List is a widget for displaying and selecting items. 8 | type List struct { 9 | WidgetBase 10 | 11 | items []string 12 | selected int 13 | pos int 14 | 15 | onItemActivated func(*List) 16 | onSelectionChanged func(*List) 17 | } 18 | 19 | // NewList returns a new List with no selection. 20 | func NewList() *List { 21 | return &List{ 22 | selected: -1, 23 | } 24 | } 25 | 26 | // Draw draws the list. 27 | func (l *List) Draw(p *Painter) { 28 | for i, item := range l.items { 29 | style := "list.item" 30 | if i == l.selected-l.pos { 31 | style += ".selected" 32 | } 33 | p.WithStyle(style, func(p *Painter) { 34 | p.FillRect(0, i, l.Size().X, 1) 35 | p.DrawText(0, i, item) 36 | }) 37 | } 38 | } 39 | 40 | // SizeHint returns the recommended size for the list. 41 | func (l *List) SizeHint() image.Point { 42 | var width int 43 | for _, item := range l.items { 44 | if w := stringWidth(item); w > width { 45 | width = w 46 | } 47 | } 48 | return image.Point{width, len(l.items)} 49 | } 50 | 51 | // OnKeyEvent handles terminal events. 52 | func (l *List) OnKeyEvent(ev KeyEvent) { 53 | if !l.IsFocused() { 54 | return 55 | } 56 | 57 | switch ev.Key { 58 | case KeyUp: 59 | l.moveUp() 60 | case KeyDown: 61 | l.moveDown() 62 | case KeyEnter: 63 | if l.onItemActivated != nil { 64 | l.onItemActivated(l) 65 | } 66 | } 67 | 68 | switch ev.Rune { 69 | case 'k': 70 | l.moveUp() 71 | case 'j': 72 | l.moveDown() 73 | } 74 | } 75 | 76 | func (l *List) moveUp() { 77 | if l.selected > 0 { 78 | l.selected-- 79 | 80 | if l.selected < l.pos { 81 | l.pos-- 82 | } 83 | } 84 | if l.onSelectionChanged != nil { 85 | l.onSelectionChanged(l) 86 | } 87 | } 88 | 89 | func (l *List) moveDown() { 90 | if l.selected < len(l.items)-1 { 91 | l.selected++ 92 | if l.selected >= l.pos+len(l.items) { 93 | l.pos++ 94 | } 95 | } 96 | if l.onSelectionChanged != nil { 97 | l.onSelectionChanged(l) 98 | } 99 | } 100 | 101 | // AddItems appends items to the end of the list. 102 | func (l *List) AddItems(items ...string) { 103 | l.items = append(l.items, items...) 104 | } 105 | 106 | // RemoveItems clears all the items from the list. 107 | func (l *List) RemoveItems() { 108 | l.items = []string{} 109 | l.pos = 0 110 | l.selected = -1 111 | if l.onSelectionChanged != nil { 112 | l.onSelectionChanged(l) 113 | } 114 | } 115 | 116 | // RemoveItem removes the item at the given position. 117 | func (l *List) RemoveItem(i int) { 118 | // Adjust pos and selected before removing. 119 | if l.pos >= len(l.items) { 120 | l.pos-- 121 | } 122 | if l.selected == i { 123 | l.selected = -1 124 | } else if l.selected > i { 125 | l.selected-- 126 | } 127 | 128 | // Copy items following i to position i. 129 | copy(l.items[i:], l.items[i+1:]) 130 | 131 | // Shrink items by one. 132 | l.items[len(l.items)-1] = "" 133 | l.items = l.items[:len(l.items)-1] 134 | 135 | if l.onSelectionChanged != nil { 136 | l.onSelectionChanged(l) 137 | } 138 | } 139 | 140 | // Length returns the number of items in the list. 141 | func (l *List) Length() int { 142 | return len(l.items) 143 | } 144 | 145 | // SetSelected sets the currently selected item. 146 | func (l *List) SetSelected(i int) { 147 | l.selected = i 148 | } 149 | 150 | // Selected returns the index of the currently selected item. 151 | func (l *List) Selected() int { 152 | return l.selected 153 | } 154 | 155 | // Select calls SetSelected and the OnSelectionChanged function. 156 | func (l *List) Select(i int) { 157 | l.SetSelected(i) 158 | if l.onSelectionChanged != nil { 159 | l.onSelectionChanged(l) 160 | } 161 | } 162 | 163 | // SelectedItem returns the currently selected item. 164 | func (l *List) SelectedItem() string { 165 | return l.items[l.selected] 166 | } 167 | 168 | // OnItemActivated gets called when activated (through pressing KeyEnter). 169 | func (l *List) OnItemActivated(fn func(*List)) { 170 | l.onItemActivated = fn 171 | } 172 | 173 | // OnSelectionChanged gets called whenever a new item is selected. 174 | func (l *List) OnSelectionChanged(fn func(*List)) { 175 | l.onSelectionChanged = fn 176 | } 177 | -------------------------------------------------------------------------------- /table.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import "image" 4 | 5 | var _ Widget = &Table{} 6 | 7 | // Table is a widget that lays out widgets in a table. 8 | type Table struct { 9 | selected int 10 | onItemActivated func(*Table) 11 | onSelectionChanged func(*Table) 12 | 13 | *Grid 14 | } 15 | 16 | // NewTable returns a new Table. 17 | func NewTable(cols, rows int) *Table { 18 | return &Table{ 19 | Grid: NewGrid(cols, rows), 20 | } 21 | } 22 | 23 | // Draw draws the table. 24 | func (t *Table) Draw(p *Painter) { 25 | s := t.Size() 26 | 27 | if t.hasBorder { 28 | border := 1 29 | 30 | // Draw outmost border. 31 | p.DrawRect(0, 0, s.X, s.Y) 32 | 33 | // Draw column dividers. 34 | var coloff int 35 | for i := 0; i < t.cols-1; i++ { 36 | x := t.colWidths[i] + coloff + border 37 | p.DrawVerticalLine(x, 0, s.Y-1) 38 | p.DrawRune(x, 0, '┬') 39 | p.DrawRune(x, s.Y-1, '┴') 40 | coloff = x 41 | } 42 | 43 | // Draw row dividers. 44 | var rowoff int 45 | for j := 0; j < t.rows-1; j++ { 46 | y := t.rowHeights[j] + rowoff + border 47 | p.DrawHorizontalLine(0, s.X-1, y) 48 | p.DrawRune(0, y, '├') 49 | p.DrawRune(s.X-1, y, '┤') 50 | rowoff = y 51 | } 52 | 53 | // Polish the intersections. 54 | rowoff = 0 55 | for j := 0; j < t.rows-1; j++ { 56 | y := t.rowHeights[j] + rowoff + border 57 | coloff = 0 58 | for i := 0; i < t.cols-1; i++ { 59 | x := t.colWidths[i] + coloff + border 60 | p.DrawRune(x, y, '┼') 61 | coloff = x 62 | } 63 | rowoff = y 64 | } 65 | } 66 | 67 | // Draw cell content. 68 | for i := 0; i < t.cols; i++ { 69 | for j := 0; j < t.rows; j++ { 70 | style := "table.cell" 71 | if j == t.selected { 72 | style += ".selected" 73 | } 74 | 75 | p.WithStyle(style, func(p *Painter) { 76 | pos := image.Point{i, j} 77 | wp := t.mapCellToLocal(pos) 78 | 79 | p.Translate(wp.X, wp.Y) 80 | defer p.Restore() 81 | 82 | if w, ok := t.cells[pos]; ok { 83 | size := w.Size() 84 | size.X = t.colWidths[i] 85 | 86 | p.FillRect(0, 0, size.X, size.Y) 87 | 88 | p.WithMask(image.Rectangle{ 89 | Min: image.Point{}, 90 | Max: size, 91 | }, func(p *Painter) { 92 | w.Draw(p) 93 | }) 94 | } 95 | }) 96 | } 97 | } 98 | } 99 | 100 | // OnKeyEvent handles an event and propagates it to all children. 101 | func (t *Table) OnKeyEvent(ev KeyEvent) { 102 | if !t.IsFocused() { 103 | return 104 | } 105 | 106 | switch ev.Key { 107 | case KeyUp: 108 | t.moveUp() 109 | case KeyDown: 110 | t.moveDown() 111 | case KeyEnter: 112 | if t.onItemActivated != nil { 113 | t.onItemActivated(t) 114 | } 115 | } 116 | 117 | switch ev.Rune { 118 | case 'k': 119 | t.moveUp() 120 | case 'j': 121 | t.moveDown() 122 | } 123 | } 124 | 125 | func (t *Table) moveUp() { 126 | if t.selected > 0 { 127 | t.selected-- 128 | } 129 | if t.onSelectionChanged != nil { 130 | t.onSelectionChanged(t) 131 | } 132 | } 133 | 134 | func (t *Table) moveDown() { 135 | if t.selected < t.rows-1 { 136 | t.selected++ 137 | } 138 | if t.onSelectionChanged != nil { 139 | t.onSelectionChanged(t) 140 | } 141 | } 142 | 143 | // SetSelected changes the currently selected item. 144 | func (t *Table) SetSelected(i int) { 145 | t.selected = i 146 | } 147 | 148 | // Selected returns the index of the currently selected item. 149 | func (t *Table) Selected() int { 150 | return t.selected 151 | } 152 | 153 | // Select calls SetSelected and the OnSelectionChanged function. 154 | func (t *Table) Select(i int) { 155 | t.SetSelected(i) 156 | if t.onSelectionChanged != nil { 157 | t.onSelectionChanged(t) 158 | } 159 | } 160 | 161 | // RemoveRow removes specific row from the table 162 | func (t *Table) RemoveRow(index int) { 163 | t.Grid.RemoveRow(index) 164 | if t.selected == index { 165 | t.selected = -1 166 | } else if t.selected > index { 167 | t.selected-- 168 | } 169 | } 170 | 171 | // RemoveRows removes all the rows added to the table. 172 | func (t *Table) RemoveRows() { 173 | t.Grid.RemoveRows() 174 | t.selected = -1 175 | } 176 | 177 | // OnItemActivated sets the function that is called when an item was activated. 178 | func (t *Table) OnItemActivated(fn func(*Table)) { 179 | t.onItemActivated = fn 180 | } 181 | 182 | // OnSelectionChanged sets the function that is called when an item was selected. 183 | func (t *Table) OnSelectionChanged(fn func(*Table)) { 184 | t.onSelectionChanged = fn 185 | } 186 | -------------------------------------------------------------------------------- /runebuf.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "image" 5 | "strings" 6 | "unicode/utf8" 7 | 8 | "github.com/marcusolsson/tui-go/wordwrap" 9 | runewidth "github.com/mattn/go-runewidth" 10 | ) 11 | 12 | // RuneBuffer provides readline functionality for text widgets. 13 | type RuneBuffer struct { 14 | buf []rune 15 | idx int 16 | 17 | wordwrap bool 18 | 19 | width int 20 | } 21 | 22 | // SetMaxWidth sets the maximum text width. 23 | func (r *RuneBuffer) SetMaxWidth(w int) { 24 | r.width = w 25 | } 26 | 27 | // Width returns the width of the rune buffer, taking into account for CJK. 28 | func (r *RuneBuffer) Width() int { 29 | return runewidth.StringWidth(string(r.buf)) 30 | } 31 | 32 | // Set the buffer and the index at the end of the buffer. 33 | func (r *RuneBuffer) Set(buf []rune) { 34 | r.SetWithIdx(len(buf), buf) 35 | } 36 | 37 | // SetWithIdx set the the buffer with a given index. 38 | func (r *RuneBuffer) SetWithIdx(idx int, buf []rune) { 39 | r.buf = buf 40 | r.idx = idx 41 | } 42 | 43 | // WriteRune appends a rune to the buffer. 44 | func (r *RuneBuffer) WriteRune(s rune) { 45 | r.WriteRunes([]rune{s}) 46 | } 47 | 48 | // WriteRunes appends runes to the buffer. 49 | func (r *RuneBuffer) WriteRunes(s []rune) { 50 | tail := append(s, r.buf[r.idx:]...) 51 | r.buf = append(r.buf[:r.idx], tail...) 52 | r.idx += len(s) 53 | } 54 | 55 | // Pos returns the current index in the buffer. 56 | func (r *RuneBuffer) Pos() int { 57 | return r.idx 58 | } 59 | 60 | // Len returns the number of runes in the buffer. 61 | func (r *RuneBuffer) Len() int { 62 | return len(r.buf) 63 | } 64 | 65 | // SplitByLine returns the lines for a given width. 66 | func (r *RuneBuffer) SplitByLine() []string { 67 | return r.getSplitByLine(r.width) 68 | } 69 | 70 | func (r *RuneBuffer) getSplitByLine(w int) []string { 71 | var text string 72 | if r.wordwrap { 73 | text = wordwrap.WrapString(r.String(), w) 74 | } else { 75 | text = r.String() 76 | } 77 | return strings.Split(text, "\n") 78 | } 79 | 80 | // CursorPos returns the coordinate for the cursor for a given width. 81 | func (r *RuneBuffer) CursorPos() image.Point { 82 | if r.width == 0 { 83 | return image.Point{} 84 | } 85 | 86 | sp := r.SplitByLine() 87 | var x, y int 88 | remaining := r.idx 89 | for _, l := range sp { 90 | if utf8.RuneCountInString(l) < remaining { 91 | y++ 92 | remaining -= utf8.RuneCountInString(l) + 1 93 | } else { 94 | x = remaining 95 | break 96 | } 97 | } 98 | return image.Pt(stringWidth(string(r.buf[:x])), y) 99 | } 100 | 101 | func (r *RuneBuffer) String() string { 102 | return string(r.buf) 103 | } 104 | 105 | // Runes return the buffer 106 | func (r *RuneBuffer) Runes() []rune { 107 | return r.buf 108 | } 109 | 110 | // MoveBackward moves the cursor back by one rune. 111 | func (r *RuneBuffer) MoveBackward() { 112 | if r.idx == 0 { 113 | return 114 | } 115 | r.idx-- 116 | } 117 | 118 | // MoveForward moves the cursor forward by one rune. 119 | func (r *RuneBuffer) MoveForward() { 120 | if r.idx == len(r.buf) { 121 | return 122 | } 123 | r.idx++ 124 | } 125 | 126 | // MoveToLineStart moves the cursor to the start of the current line. 127 | func (r *RuneBuffer) MoveToLineStart() { 128 | for i := r.idx; i > 0; i-- { 129 | if r.buf[i-1] == '\n' { 130 | r.idx = i 131 | return 132 | } 133 | } 134 | r.idx = 0 135 | } 136 | 137 | // MoveToLineEnd moves the cursor to the end of the current line. 138 | func (r *RuneBuffer) MoveToLineEnd() { 139 | for i := r.idx; i < len(r.buf)-1; i++ { 140 | if r.buf[i] == '\n' { 141 | r.idx = i 142 | return 143 | } 144 | } 145 | r.idx = len(r.buf) 146 | } 147 | 148 | // Backspace deletes the rune left of the cursor. 149 | func (r *RuneBuffer) Backspace() { 150 | if r.idx == 0 { 151 | return 152 | } 153 | r.idx-- 154 | r.buf = append(r.buf[:r.idx], r.buf[r.idx+1:]...) 155 | } 156 | 157 | // Delete deletes the rune at the current cursor position. 158 | func (r *RuneBuffer) Delete() { 159 | if r.idx == len(r.buf) { 160 | return 161 | } 162 | r.buf = append(r.buf[:r.idx], r.buf[r.idx+1:]...) 163 | } 164 | 165 | // Kill deletes all runes from the cursor until the end of the line. 166 | func (r *RuneBuffer) Kill() { 167 | newlineIdx := strings.IndexRune(string(r.buf[r.idx:]), '\n') 168 | if newlineIdx < 0 { 169 | r.buf = r.buf[:r.idx] 170 | } else { 171 | r.buf = append(r.buf[:r.idx], r.buf[r.idx+newlineIdx+1:]...) 172 | } 173 | } 174 | 175 | func (r *RuneBuffer) heightForWidth(w int) int { 176 | return len(r.getSplitByLine(w)) 177 | } 178 | -------------------------------------------------------------------------------- /testing.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "image" 7 | "strconv" 8 | ) 9 | 10 | type testCell struct { 11 | Rune rune 12 | Style Style 13 | } 14 | 15 | // A TestSurface implements the Surface interface with local buffers, 16 | // and provides accessors to check the output of a draw operation on the Surface. 17 | type TestSurface struct { 18 | cells map[image.Point]testCell 19 | cursor image.Point 20 | size image.Point 21 | emptyCh rune 22 | } 23 | 24 | // NewTestSurface returns a new TestSurface. 25 | func NewTestSurface(w, h int) *TestSurface { 26 | return &TestSurface{ 27 | cells: make(map[image.Point]testCell), 28 | size: image.Point{w, h}, 29 | emptyCh: '.', 30 | } 31 | } 32 | 33 | // SetCell sets the contents of the addressed cell. 34 | func (s *TestSurface) SetCell(x, y int, ch rune, style Style) { 35 | s.cells[image.Point{x, y}] = testCell{ 36 | Rune: ch, 37 | Style: style, 38 | } 39 | } 40 | 41 | // SetCursor moves the Surface's cursor to the specified position. 42 | func (s *TestSurface) SetCursor(x, y int) { 43 | s.cursor = image.Point{x, y} 44 | } 45 | 46 | // HideCursor removes the cursor from the display. 47 | func (s *TestSurface) HideCursor() { 48 | s.cursor = image.Point{} 49 | } 50 | 51 | // Begin resets the state of the TestSurface, clearing all cells. 52 | // It must be called before drawing the Surface. 53 | func (s *TestSurface) Begin() { 54 | s.cells = make(map[image.Point]testCell) 55 | } 56 | 57 | // End indicates the surface has been painted on, and can be rendered. 58 | // It's a no-op for TestSurface. 59 | func (s *TestSurface) End() { 60 | // NOP 61 | } 62 | 63 | // Size returns the dimensions of the surface. 64 | func (s *TestSurface) Size() image.Point { 65 | return s.size 66 | } 67 | 68 | // String returns the characters written to the TestSurface. 69 | func (s *TestSurface) String() string { 70 | var buf bytes.Buffer 71 | buf.WriteRune('\n') 72 | for j := 0; j < s.size.Y; j++ { 73 | for i := 0; i < s.size.X; i++ { 74 | if cell, ok := s.cells[image.Point{i, j}]; ok { 75 | buf.WriteRune(cell.Rune) 76 | if w := runeWidth(cell.Rune); w > 1 { 77 | i += w - 1 78 | } 79 | } else { 80 | buf.WriteRune(s.emptyCh) 81 | } 82 | } 83 | buf.WriteRune('\n') 84 | } 85 | return buf.String() 86 | } 87 | 88 | // FgColors renders the TestSurface's foreground colors, using the digits 0-7 for painted cells, and the empty character for unpainted cells. 89 | func (s *TestSurface) FgColors() string { 90 | var buf bytes.Buffer 91 | buf.WriteRune('\n') 92 | for j := 0; j < s.size.Y; j++ { 93 | for i := 0; i < s.size.X; i++ { 94 | if cell, ok := s.cells[image.Point{i, j}]; ok { 95 | color := cell.Style.Fg 96 | buf.WriteRune('0' + rune(color)) 97 | } else { 98 | buf.WriteRune(s.emptyCh) 99 | } 100 | } 101 | buf.WriteRune('\n') 102 | } 103 | return buf.String() 104 | } 105 | 106 | // BgColors renders the TestSurface's background colors, using the digits 0-7 for painted cells, and the empty character for unpainted cells. 107 | func (s *TestSurface) BgColors() string { 108 | var buf bytes.Buffer 109 | buf.WriteRune('\n') 110 | for j := 0; j < s.size.Y; j++ { 111 | for i := 0; i < s.size.X; i++ { 112 | if cell, ok := s.cells[image.Point{i, j}]; ok { 113 | color := cell.Style.Bg 114 | buf.WriteRune('0' + rune(color)) 115 | } else { 116 | buf.WriteRune(s.emptyCh) 117 | } 118 | } 119 | buf.WriteRune('\n') 120 | } 121 | return buf.String() 122 | } 123 | 124 | // Decorations renders the TestSurface's decorations (Reverse, Bold, Underline) using a bitmask: 125 | // Reverse: 1 126 | // Bold: 2 127 | // Underline: 4 128 | func (s *TestSurface) Decorations() string { 129 | var buf bytes.Buffer 130 | buf.WriteRune('\n') 131 | for j := 0; j < s.size.Y; j++ { 132 | for i := 0; i < s.size.X; i++ { 133 | if cell, ok := s.cells[image.Point{i, j}]; ok { 134 | mask := int64(0) 135 | if cell.Style.Reverse == DecorationOn { 136 | mask |= 1 137 | } 138 | if cell.Style.Bold == DecorationOn { 139 | mask |= 2 140 | } 141 | if cell.Style.Underline == DecorationOn { 142 | mask |= 4 143 | } 144 | buf.WriteString(strconv.FormatInt(mask, 16)) 145 | } else { 146 | buf.WriteRune(s.emptyCh) 147 | } 148 | } 149 | buf.WriteRune('\n') 150 | } 151 | return buf.String() 152 | } 153 | 154 | func surfaceEquals(surface *TestSurface, want string) string { 155 | if surface.String() != want { 156 | return fmt.Sprintf("got = \n%s\n\nwant = \n%s", surface.String(), want) 157 | } 158 | return "" 159 | } 160 | -------------------------------------------------------------------------------- /painter.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "image" 5 | ) 6 | 7 | // Surface defines a surface that can be painted on. 8 | type Surface interface { 9 | SetCell(x, y int, ch rune, s Style) 10 | SetCursor(x, y int) 11 | HideCursor() 12 | Begin() 13 | End() 14 | Size() image.Point 15 | } 16 | 17 | // Painter provides operations to paint on a surface. 18 | type Painter struct { 19 | theme *Theme 20 | 21 | // Surface to paint on. 22 | surface Surface 23 | 24 | // Current brush. 25 | style Style 26 | 27 | // Transform stack 28 | transforms []image.Point 29 | 30 | mask image.Rectangle 31 | } 32 | 33 | // NewPainter returns a new instance of Painter. 34 | func NewPainter(s Surface, p *Theme) *Painter { 35 | return &Painter{ 36 | theme: p, 37 | surface: s, 38 | style: p.Style("normal"), 39 | mask: image.Rectangle{ 40 | Min: image.Point{}, 41 | Max: s.Size(), 42 | }, 43 | } 44 | } 45 | 46 | // Translate pushes a new translation transform to the stack. 47 | func (p *Painter) Translate(x, y int) { 48 | p.transforms = append(p.transforms, image.Point{x, y}) 49 | } 50 | 51 | // Restore pops the latest transform from the stack. 52 | func (p *Painter) Restore() { 53 | if len(p.transforms) > 0 { 54 | p.transforms = p.transforms[:len(p.transforms)-1] 55 | } 56 | } 57 | 58 | // Begin prepares the surface for painting. 59 | func (p *Painter) Begin() { 60 | p.surface.Begin() 61 | } 62 | 63 | // End finalizes any painting that has been made. 64 | func (p *Painter) End() { 65 | p.surface.End() 66 | } 67 | 68 | // Repaint clears the surface, draws the scene and flushes it. 69 | func (p *Painter) Repaint(w Widget) { 70 | p.mask = image.Rectangle{ 71 | Min: image.Point{}, 72 | Max: p.surface.Size(), 73 | } 74 | 75 | p.surface.HideCursor() 76 | 77 | w.Resize(p.surface.Size()) 78 | 79 | p.Begin() 80 | w.Draw(p) 81 | p.End() 82 | } 83 | 84 | // DrawCursor draws the cursor at the given position. 85 | func (p *Painter) DrawCursor(x, y int) { 86 | wp := p.mapLocalToWorld(image.Point{x, y}) 87 | p.surface.SetCursor(wp.X, wp.Y) 88 | } 89 | 90 | // DrawRune paints a rune at the given coordinate. 91 | func (p *Painter) DrawRune(x, y int, r rune) { 92 | wp := p.mapLocalToWorld(image.Point{x, y}) 93 | if (p.mask.Min.X <= wp.X) && (wp.X < p.mask.Max.X) && (p.mask.Min.Y <= wp.Y) && (wp.Y < p.mask.Max.Y) { 94 | p.surface.SetCell(wp.X, wp.Y, r, p.style) 95 | } 96 | } 97 | 98 | // DrawText paints a string starting at the given coordinate. 99 | func (p *Painter) DrawText(x, y int, text string) { 100 | for _, r := range text { 101 | p.DrawRune(x, y, r) 102 | x += runeWidth(r) 103 | } 104 | } 105 | 106 | // DrawHorizontalLine paints a horizontal line using box characters. 107 | func (p *Painter) DrawHorizontalLine(x1, x2, y int) { 108 | for x := x1; x < x2; x++ { 109 | p.DrawRune(x, y, '─') 110 | } 111 | } 112 | 113 | // DrawVerticalLine paints a vertical line using box characters. 114 | func (p *Painter) DrawVerticalLine(x, y1, y2 int) { 115 | for y := y1; y < y2; y++ { 116 | p.DrawRune(x, y, '│') 117 | } 118 | } 119 | 120 | // DrawRect paints a rectangle using box characters. 121 | func (p *Painter) DrawRect(x, y, w, h int) { 122 | for j := 0; j < h; j++ { 123 | for i := 0; i < w; i++ { 124 | m := i + x 125 | n := j + y 126 | 127 | switch { 128 | case i == 0 && j == 0: 129 | p.DrawRune(m, n, '┌') 130 | case i == w-1 && j == 0: 131 | p.DrawRune(m, n, '┐') 132 | case i == 0 && j == h-1: 133 | p.DrawRune(m, n, '└') 134 | case i == w-1 && j == h-1: 135 | p.DrawRune(m, n, '┘') 136 | case i == 0 || i == w-1: 137 | p.DrawRune(m, n, '│') 138 | case j == 0 || j == h-1: 139 | p.DrawRune(m, n, '─') 140 | } 141 | } 142 | } 143 | } 144 | 145 | // FillRect clears a rectangular area with whitespace. 146 | func (p *Painter) FillRect(x, y, w, h int) { 147 | for j := 0; j < h; j++ { 148 | for i := 0; i < w; i++ { 149 | p.DrawRune(i+x, j+y, ' ') 150 | } 151 | } 152 | } 153 | 154 | // SetStyle sets the style used when painting. 155 | func (p *Painter) SetStyle(s Style) { 156 | p.style = s 157 | } 158 | 159 | // WithStyle executes the provided function with the named Style applied on top of the current one. 160 | func (p *Painter) WithStyle(n string, fn func(*Painter)) { 161 | prev := p.style 162 | new := prev.mergeIn(p.theme.Style(n)) 163 | p.SetStyle(new) 164 | fn(p) 165 | p.SetStyle(prev) 166 | } 167 | 168 | // WithMask masks a painter to restrict painting within the given rectangle. 169 | func (p *Painter) WithMask(r image.Rectangle, fn func(*Painter)) { 170 | tmp := p.mask 171 | defer func() { p.mask = tmp }() 172 | 173 | p.mask = p.mask.Intersect(image.Rectangle{ 174 | Min: p.mapLocalToWorld(r.Min), 175 | Max: p.mapLocalToWorld(r.Max), 176 | }) 177 | 178 | fn(p) 179 | } 180 | 181 | func (p *Painter) mapLocalToWorld(point image.Point) image.Point { 182 | var offset image.Point 183 | for _, s := range p.transforms { 184 | offset = offset.Add(s) 185 | } 186 | return point.Add(offset) 187 | } 188 | -------------------------------------------------------------------------------- /entry.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "image" 5 | "strings" 6 | ) 7 | 8 | var _ Widget = &Entry{} 9 | 10 | // EchoMode is used to determine the visibility of Entry text. 11 | type EchoMode int 12 | 13 | const ( 14 | // EchoModeNormal displays the characters as they're being entered. 15 | EchoModeNormal EchoMode = iota 16 | 17 | // EchoModeNoEcho disables text display. This is useful for when the length 18 | // of the password should be kept secret. 19 | EchoModeNoEcho 20 | 21 | // EchoModePassword replaces all characters with asterisks. 22 | EchoModePassword 23 | ) 24 | 25 | // Entry is a one-line text editor. It lets the user supply the application 26 | // with text, e.g., to input user and password information. 27 | type Entry struct { 28 | WidgetBase 29 | 30 | text RuneBuffer 31 | 32 | onTextChange func(*Entry) 33 | onSubmit func(*Entry) 34 | 35 | echoMode EchoMode 36 | offset int 37 | } 38 | 39 | // NewEntry returns a new Entry. 40 | func NewEntry() *Entry { 41 | return &Entry{} 42 | } 43 | 44 | // Draw draws the entry. 45 | func (e *Entry) Draw(p *Painter) { 46 | style := "entry" 47 | if e.IsFocused() { 48 | style += ".focused" 49 | } 50 | p.WithStyle(style, func(p *Painter) { 51 | s := e.Size() 52 | e.text.SetMaxWidth(s.X) 53 | 54 | text := e.visibleText() 55 | 56 | p.FillRect(0, 0, s.X, 1) 57 | 58 | switch e.echoMode { 59 | case EchoModeNormal: 60 | p.DrawText(0, 0, text) 61 | case EchoModePassword: 62 | p.DrawText(0, 0, strings.Repeat("*", len(text))) 63 | } 64 | 65 | if e.IsFocused() { 66 | var off int 67 | if e.echoMode != EchoModeNoEcho { 68 | off = e.text.CursorPos().X - e.offset 69 | } 70 | p.DrawCursor(off, 0) 71 | } 72 | }) 73 | } 74 | 75 | // SizeHint returns the recommended size hint for the entry. 76 | func (e *Entry) SizeHint() image.Point { 77 | return image.Point{10, 1} 78 | } 79 | 80 | // OnKeyEvent handles key events. 81 | func (e *Entry) OnKeyEvent(ev KeyEvent) { 82 | if !e.IsFocused() { 83 | return 84 | } 85 | 86 | screenWidth := e.Size().X 87 | e.text.SetMaxWidth(screenWidth) 88 | 89 | if ev.Key != KeyRune { 90 | switch ev.Key { 91 | case KeyEnter: 92 | if e.onSubmit != nil { 93 | e.onSubmit(e) 94 | } 95 | case KeyBackspace: 96 | fallthrough 97 | case KeyBackspace2: 98 | e.text.Backspace() 99 | if e.offset > 0 && !e.isTextRemaining() { 100 | e.offset-- 101 | } 102 | if e.onTextChange != nil { 103 | e.onTextChange(e) 104 | } 105 | case KeyDelete, KeyCtrlD: 106 | e.text.Delete() 107 | if e.onTextChange != nil { 108 | e.onTextChange(e) 109 | } 110 | case KeyLeft, KeyCtrlB: 111 | e.text.MoveBackward() 112 | if e.offset > 0 { 113 | e.offset-- 114 | } 115 | case KeyRight, KeyCtrlF: 116 | e.text.MoveForward() 117 | 118 | isCursorTooFar := e.text.CursorPos().X >= screenWidth 119 | isTextLeft := (e.text.Width() - e.offset) > (screenWidth - 1) 120 | 121 | if isCursorTooFar && isTextLeft { 122 | e.offset++ 123 | } 124 | case KeyHome, KeyCtrlA: 125 | e.text.MoveToLineStart() 126 | e.offset = 0 127 | case KeyEnd, KeyCtrlE: 128 | e.text.MoveToLineEnd() 129 | e.ensureCursorIsVisible() 130 | case KeyCtrlK: 131 | e.text.Kill() 132 | } 133 | return 134 | } 135 | 136 | e.text.WriteRune(ev.Rune) 137 | if e.text.CursorPos().X >= screenWidth { 138 | e.offset++ 139 | } 140 | if e.onTextChange != nil { 141 | e.onTextChange(e) 142 | } 143 | } 144 | 145 | // OnChanged sets a function to be run whenever the content of the entry has 146 | // been changed. 147 | func (e *Entry) OnChanged(fn func(entry *Entry)) { 148 | e.onTextChange = fn 149 | } 150 | 151 | // OnSubmit sets a function to be run whenever the user submits the entry (by 152 | // pressing KeyEnter). 153 | func (e *Entry) OnSubmit(fn func(entry *Entry)) { 154 | e.onSubmit = fn 155 | } 156 | 157 | // SetEchoMode sets the echo mode of the entry. 158 | func (e *Entry) SetEchoMode(m EchoMode) { 159 | e.echoMode = m 160 | } 161 | 162 | // SetText sets the text content of the entry. 163 | func (e *Entry) SetText(text string) { 164 | e.text.Set([]rune(text)) 165 | // TODO: Enable when RuneBuf supports cursor movement for CJK. 166 | // e.ensureCursorIsVisible() 167 | e.offset = 0 168 | } 169 | 170 | func (e *Entry) ensureCursorIsVisible() { 171 | left := e.text.Width() - (e.Size().X - 1) 172 | if left >= 0 { 173 | e.offset = left 174 | } else { 175 | e.offset = 0 176 | } 177 | } 178 | 179 | // Text returns the text content of the entry. 180 | func (e *Entry) Text() string { 181 | return e.text.String() 182 | } 183 | 184 | func (e *Entry) visibleText() string { 185 | text := e.text 186 | if text.Len() == 0 { 187 | return "" 188 | } 189 | windowStart := e.offset 190 | windowEnd := e.Size().X + windowStart 191 | if windowEnd > text.Len() { 192 | windowEnd = text.Len() 193 | } 194 | return string(text.Runes()[windowStart:windowEnd]) 195 | } 196 | 197 | func (e *Entry) isTextRemaining() bool { 198 | return e.text.Width()-e.offset > e.Size().X 199 | } 200 | -------------------------------------------------------------------------------- /grid_test.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "image" 5 | "testing" 6 | ) 7 | 8 | func TestGridSizeHint(t *testing.T) { 9 | g := NewGrid(0, 0) 10 | g.AppendRow( 11 | NewLabel("foo"), 12 | NewLabel("this is a long column"), 13 | NewLabel("bar"), 14 | ) 15 | g.SetBorder(true) 16 | 17 | surface := NewTestSurface(g.SizeHint().X, g.SizeHint().Y) 18 | painter := NewPainter(surface, NewTheme()) 19 | painter.Repaint(g) 20 | 21 | want := ` 22 | ┌─────────────────────┬─────────────────────┬─────────────────────┐ 23 | │foo..................│this is a long column│bar..................│ 24 | └─────────────────────┴─────────────────────┴─────────────────────┘ 25 | ` 26 | 27 | if diff := surfaceEquals(surface, want); diff != "" { 28 | t.Error(diff) 29 | } 30 | } 31 | 32 | var drawGridTests = []struct { 33 | test string 34 | size image.Point 35 | setup func() *Grid 36 | want string 37 | }{ 38 | { 39 | test: "Empty grid with border", 40 | size: image.Point{15, 5}, 41 | setup: func() *Grid { 42 | g := NewGrid(0, 0) 43 | g.SetBorder(true) 44 | return g 45 | }, 46 | want: ` 47 | ┌─────────────┐ 48 | │.............│ 49 | │.............│ 50 | │.............│ 51 | └─────────────┘ 52 | `, 53 | }, 54 | { 55 | test: "Grid with empty labels", 56 | size: image.Point{15, 5}, 57 | setup: func() *Grid { 58 | g := NewGrid(2, 2) 59 | g.SetBorder(true) 60 | return g 61 | }, 62 | want: ` 63 | ┌──────┬──────┐ 64 | │......│......│ 65 | ├──────┼──────┤ 66 | │......│......│ 67 | └──────┴──────┘ 68 | `, 69 | }, 70 | { 71 | test: "Grid with short labels", 72 | size: image.Point{19, 9}, 73 | setup: func() *Grid { 74 | g := NewGrid(0, 0) 75 | g.SetBorder(true) 76 | l := NewLabel("testing") 77 | l.SetSizePolicy(Minimum, Preferred) 78 | g.AppendRow(l, NewLabel("test")) 79 | g.AppendRow(NewLabel("foo"), NewLabel("bar")) 80 | return g 81 | }, 82 | want: ` 83 | ┌────────┬────────┐ 84 | │testing.│test....│ 85 | │........│........│ 86 | │........│........│ 87 | ├────────┼────────┤ 88 | │foo.....│bar.....│ 89 | │........│........│ 90 | │........│........│ 91 | └────────┴────────┘ 92 | `, 93 | }, 94 | { 95 | test: "Grid with word wrap", 96 | size: image.Point{19, 5}, 97 | setup: func() *Grid { 98 | l := NewLabel("this will wrap") 99 | l.SetWordWrap(true) 100 | l.SetSizePolicy(Expanding, Preferred) 101 | 102 | g := NewGrid(0, 0) 103 | g.SetBorder(true) 104 | g.AppendRow(l, NewLabel("test")) 105 | return g 106 | }, 107 | want: ` 108 | ┌────────┬────────┐ 109 | │this....│test....│ 110 | │will....│........│ 111 | │wrap....│........│ 112 | └────────┴────────┘ 113 | `, 114 | }, 115 | { 116 | test: "Grid with column stretch", 117 | size: image.Point{24, 3}, 118 | setup: func() *Grid { 119 | g := NewGrid(3, 1) 120 | g.SetBorder(true) 121 | 122 | g.SetColumnStretch(0, 1) 123 | g.SetColumnStretch(1, 2) 124 | g.SetColumnStretch(2, 1) 125 | 126 | return g 127 | }, 128 | want: ` 129 | ┌─────┬──────────┬─────┐ 130 | │.....│..........│.....│ 131 | └─────┴──────────┴─────┘ 132 | `, 133 | }, 134 | { 135 | test: "Grid with one undefined column stretch", 136 | size: image.Point{19, 3}, 137 | setup: func() *Grid { 138 | g := NewGrid(3, 1) 139 | g.SetBorder(true) 140 | 141 | // First column stretch defaults to 0 142 | //g.SetColumnStretch(0, 0) 143 | 144 | g.SetColumnStretch(1, 2) 145 | g.SetColumnStretch(2, 1) 146 | 147 | return g 148 | }, 149 | want: ` 150 | ┌┬──────────┬─────┐ 151 | ││..........│.....│ 152 | └┴──────────┴─────┘ 153 | `, 154 | }, 155 | { 156 | test: "Grid with mixed column stretch", 157 | size: image.Point{34, 3}, 158 | setup: func() *Grid { 159 | g := NewGrid(3, 1) 160 | g.SetBorder(true) 161 | 162 | g.SetColumnStretch(0, 3) 163 | g.SetColumnStretch(1, 2) 164 | g.SetColumnStretch(2, 1) 165 | 166 | return g 167 | }, 168 | want: ` 169 | ┌───────────────┬──────────┬─────┐ 170 | │...............│..........│.....│ 171 | └───────────────┴──────────┴─────┘ 172 | `, 173 | }, 174 | { 175 | test: "Grid with single zero stretch column", 176 | size: image.Point{34, 3}, 177 | setup: func() *Grid { 178 | g := NewGrid(0, 0) 179 | g.SetBorder(true) 180 | 181 | g.AppendRow( 182 | NewLabel("foo"), 183 | NewLabel("bar"), 184 | NewLabel("test"), 185 | ) 186 | 187 | g.SetColumnStretch(0, 1) 188 | g.SetColumnStretch(1, 2) 189 | g.SetColumnStretch(2, 0) 190 | 191 | return g 192 | }, 193 | want: ` 194 | ┌──────────┬───────────────────┬─┐ 195 | │foo.......│bar................│t│ 196 | └──────────┴───────────────────┴─┘ 197 | `, 198 | }, 199 | { 200 | test: "Grid with multiple zero stretch columns", 201 | size: image.Point{34, 3}, 202 | setup: func() *Grid { 203 | g := NewGrid(0, 0) 204 | g.SetBorder(true) 205 | 206 | g.AppendRow( 207 | NewLabel("foo"), 208 | NewLabel("bar"), 209 | NewLabel("baz"), 210 | NewLabel("test"), 211 | ) 212 | 213 | g.SetColumnStretch(0, 0) 214 | g.SetColumnStretch(1, 1) 215 | g.SetColumnStretch(2, 2) 216 | g.SetColumnStretch(3, 0) 217 | 218 | return g 219 | }, 220 | want: ` 221 | ┌─┬─────────┬──────────────────┬─┐ 222 | │f│bar......│baz...............│t│ 223 | └─┴─────────┴──────────────────┴─┘ 224 | `, 225 | }, 226 | } 227 | 228 | func TestGrid_Draw(t *testing.T) { 229 | for _, tt := range drawGridTests { 230 | t.Run(tt.test, func(t *testing.T) { 231 | surface := NewTestSurface(tt.size.X, tt.size.Y) 232 | painter := NewPainter(surface, NewTheme()) 233 | painter.Repaint(tt.setup()) 234 | 235 | if diff := surfaceEquals(surface, tt.want); diff != "" { 236 | t.Error(diff) 237 | } 238 | }) 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /ui_tcell.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "image" 5 | 6 | "github.com/gdamore/tcell" 7 | ) 8 | 9 | var _ UI = &tcellUI{} 10 | 11 | type tcellUI struct { 12 | painter *Painter 13 | root Widget 14 | 15 | keybindings []*keybinding 16 | 17 | quit chan struct{} 18 | 19 | screen tcell.Screen 20 | 21 | kbFocus *kbFocusController 22 | 23 | eventQueue chan event 24 | } 25 | 26 | func newTcellUI(root Widget) (*tcellUI, error) { 27 | screen, err := tcell.NewScreen() 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | s := &tcellSurface{ 33 | screen: screen, 34 | } 35 | p := NewPainter(s, DefaultTheme) 36 | 37 | return &tcellUI{ 38 | painter: p, 39 | root: root, 40 | keybindings: make([]*keybinding, 0), 41 | quit: make(chan struct{}, 1), 42 | screen: screen, 43 | kbFocus: &kbFocusController{chain: DefaultFocusChain}, 44 | eventQueue: make(chan event), 45 | }, nil 46 | } 47 | 48 | func (ui *tcellUI) Repaint() { 49 | ui.painter.Repaint(ui.root) 50 | } 51 | 52 | func (ui *tcellUI) SetWidget(w Widget) { 53 | ui.root = w 54 | } 55 | 56 | func (ui *tcellUI) SetTheme(t *Theme) { 57 | ui.painter.theme = t 58 | } 59 | 60 | func (ui *tcellUI) SetFocusChain(chain FocusChain) { 61 | if ui.kbFocus.focusedWidget != nil { 62 | ui.kbFocus.focusedWidget.SetFocused(false) 63 | } 64 | 65 | ui.kbFocus.chain = chain 66 | ui.kbFocus.focusedWidget = chain.FocusDefault() 67 | 68 | if ui.kbFocus.focusedWidget != nil { 69 | ui.kbFocus.focusedWidget.SetFocused(true) 70 | } 71 | } 72 | 73 | func (ui *tcellUI) SetKeybinding(seq string, fn func()) { 74 | ui.keybindings = append(ui.keybindings, &keybinding{ 75 | sequence: seq, 76 | handler: fn, 77 | }) 78 | } 79 | 80 | // ClearKeybindings reinitialises ui.keybindings so as to revert to a 81 | // clear/original state 82 | func (ui *tcellUI) ClearKeybindings() { 83 | ui.keybindings = make([]*keybinding, 0) 84 | } 85 | 86 | func (ui *tcellUI) Run() error { 87 | if err := ui.screen.Init(); err != nil { 88 | return err 89 | } 90 | 91 | failed := true 92 | defer func() { 93 | if failed { 94 | ui.screen.Fini() 95 | } 96 | }() 97 | 98 | if w := ui.kbFocus.chain.FocusDefault(); w != nil { 99 | w.SetFocused(true) 100 | ui.kbFocus.focusedWidget = w 101 | } 102 | 103 | ui.screen.SetStyle(tcell.StyleDefault) 104 | ui.screen.Clear() 105 | 106 | go func() { 107 | for { 108 | switch ev := ui.screen.PollEvent().(type) { 109 | case *tcell.EventKey: 110 | ui.handleKeyEvent(ev) 111 | case *tcell.EventMouse: 112 | ui.handleMouseEvent(ev) 113 | case *tcell.EventResize: 114 | ui.handleResizeEvent(ev) 115 | } 116 | } 117 | }() 118 | 119 | for { 120 | select { 121 | case <-ui.quit: 122 | failed = false 123 | return nil 124 | case ev := <-ui.eventQueue: 125 | ui.handleEvent(ev) 126 | } 127 | } 128 | } 129 | 130 | func (ui *tcellUI) handleEvent(ev event) { 131 | switch e := ev.(type) { 132 | case KeyEvent: 133 | logger.Printf("Received key event: %s", e.Name()) 134 | 135 | for _, b := range ui.keybindings { 136 | if b.match(e) { 137 | b.handler() 138 | } 139 | } 140 | ui.kbFocus.OnKeyEvent(e) 141 | ui.root.OnKeyEvent(e) 142 | ui.painter.Repaint(ui.root) 143 | case callbackEvent: 144 | // Gets stuck in a print loop when the logger is a widget. 145 | //logger.Printf("Received callback event") 146 | e.cbFn() 147 | ui.painter.Repaint(ui.root) 148 | case paintEvent: 149 | logger.Printf("Received paint event") 150 | ui.painter.Repaint(ui.root) 151 | } 152 | } 153 | 154 | func (ui *tcellUI) handleKeyEvent(tev *tcell.EventKey) { 155 | ui.eventQueue <- KeyEvent{ 156 | Key: Key(tev.Key()), 157 | Rune: tev.Rune(), 158 | Modifiers: ModMask(tev.Modifiers()), 159 | } 160 | } 161 | 162 | func (ui *tcellUI) handleMouseEvent(ev *tcell.EventMouse) { 163 | x, y := ev.Position() 164 | ui.eventQueue <- MouseEvent{Pos: image.Pt(x, y)} 165 | } 166 | 167 | func (ui *tcellUI) handleResizeEvent(ev *tcell.EventResize) { 168 | ui.eventQueue <- paintEvent{} 169 | } 170 | 171 | // Quit signals to the UI to start shutting down. 172 | func (ui *tcellUI) Quit() { 173 | logger.Printf("Quitting") 174 | ui.screen.Fini() 175 | ui.quit <- struct{}{} 176 | } 177 | 178 | // Schedule an update of the UI, running the given 179 | // function in the UI goroutine. 180 | // 181 | // Use this to update the UI in response to external events, 182 | // like a timer tick. 183 | // This method should be used any time you call methods 184 | // to change UI objects after the first call to `UI.Run()`. 185 | // 186 | // Changes invoked outside of either this callback or the 187 | // other event handler callbacks may appear to work, but 188 | // is likely a race condition. (Run your program with 189 | // `go run -race` or `go install -race` to detect this!) 190 | // 191 | // Calling Update from within an event handler, or from within an Update call, 192 | // is an error, and will deadlock. 193 | func (ui *tcellUI) Update(fn func()) { 194 | blk := make(chan struct{}) 195 | ui.eventQueue <- callbackEvent{func() { 196 | fn() 197 | close(blk) 198 | }} 199 | <-blk 200 | } 201 | 202 | var _ Surface = &tcellSurface{} 203 | 204 | type tcellSurface struct { 205 | screen tcell.Screen 206 | } 207 | 208 | func (s *tcellSurface) SetCell(x, y int, ch rune, style Style) { 209 | st := tcell.StyleDefault.Normal(). 210 | Foreground(convertColor(style.Fg, false)). 211 | Background(convertColor(style.Bg, false)). 212 | Reverse(style.Reverse == DecorationOn). 213 | Bold(style.Bold == DecorationOn). 214 | Underline(style.Underline == DecorationOn) 215 | 216 | s.screen.SetContent(x, y, ch, nil, st) 217 | } 218 | 219 | func (s *tcellSurface) SetCursor(x, y int) { 220 | s.screen.ShowCursor(x, y) 221 | } 222 | 223 | func (s *tcellSurface) HideCursor() { 224 | s.screen.HideCursor() 225 | } 226 | 227 | func (s *tcellSurface) Begin() { 228 | s.screen.Clear() 229 | } 230 | 231 | func (s *tcellSurface) End() { 232 | s.screen.Show() 233 | } 234 | 235 | func (s *tcellSurface) Size() image.Point { 236 | w, h := s.screen.Size() 237 | return image.Point{w, h} 238 | } 239 | 240 | func convertColor(col Color, fg bool) tcell.Color { 241 | switch col { 242 | case ColorDefault: 243 | if fg { 244 | return tcell.ColorWhite 245 | } 246 | return tcell.ColorDefault 247 | case ColorBlack: 248 | return tcell.ColorBlack 249 | case ColorWhite: 250 | return tcell.ColorWhite 251 | case ColorRed: 252 | return tcell.ColorRed 253 | case ColorGreen: 254 | return tcell.ColorGreen 255 | case ColorBlue: 256 | return tcell.ColorBlue 257 | case ColorCyan: 258 | return tcell.ColorDarkCyan 259 | case ColorMagenta: 260 | return tcell.ColorDarkMagenta 261 | case ColorYellow: 262 | return tcell.ColorYellow 263 | default: 264 | if col > 0 { 265 | return tcell.Color(col) 266 | } 267 | return tcell.ColorDefault 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /scroll_area_test.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "fmt" 5 | "image" 6 | "testing" 7 | ) 8 | 9 | var drawScrollAreaTests = []struct { 10 | test string 11 | size image.Point 12 | setup func() *ScrollArea 13 | want string 14 | }{ 15 | { 16 | test: "Empty scroll area", 17 | size: image.Point{10, 3}, 18 | setup: func() *ScrollArea { 19 | b := NewVBox( 20 | NewLabel("foo"), 21 | NewLabel("bar"), 22 | NewLabel("test"), 23 | ) 24 | a := NewScrollArea(b) 25 | return a 26 | }, 27 | want: ` 28 | foo ...... 29 | bar ...... 30 | test...... 31 | `, 32 | }, 33 | { 34 | test: "Vertical scroll top", 35 | size: image.Point{10, 2}, 36 | setup: func() *ScrollArea { 37 | b := NewVBox( 38 | NewLabel("foo"), 39 | NewLabel("bar"), 40 | NewLabel("test"), 41 | ) 42 | a := NewScrollArea(b) 43 | return a 44 | }, 45 | want: ` 46 | foo ...... 47 | bar ...... 48 | `, 49 | }, 50 | { 51 | test: "Vertical scroll bottom", 52 | size: image.Point{10, 2}, 53 | setup: func() *ScrollArea { 54 | b := NewVBox( 55 | NewLabel("foo"), 56 | NewLabel("bar"), 57 | NewLabel("test"), 58 | ) 59 | a := NewScrollArea(b) 60 | a.Scroll(0, 1) 61 | return a 62 | }, 63 | want: ` 64 | bar ...... 65 | test...... 66 | `, 67 | }, 68 | { 69 | test: "Horizontal scroll left", 70 | size: image.Point{10, 1}, 71 | setup: func() *ScrollArea { 72 | b := NewVBox( 73 | NewLabel("Lorem ipsum dolor sit amet, consectetur adipiscing elit."), 74 | ) 75 | a := NewScrollArea(b) 76 | return a 77 | }, 78 | want: ` 79 | Lorem ipsu 80 | `, 81 | }, 82 | { 83 | test: "Horizontal scroll right", 84 | size: image.Point{10, 1}, 85 | setup: func() *ScrollArea { 86 | b := NewVBox( 87 | NewLabel("Lorem ipsum dolor sit amet, consectetur adipiscing elit."), 88 | ) 89 | a := NewScrollArea(b) 90 | a.Scroll(46, 0) 91 | return a 92 | }, 93 | want: ` 94 | cing elit. 95 | `, 96 | }, 97 | { 98 | test: "Scroll horizontally and vertically", 99 | size: image.Point{10, 3}, 100 | setup: func() *ScrollArea { 101 | b := NewVBox() 102 | for i := 0; i < 10; i++ { 103 | b.Append(NewLabel(fmt.Sprintf("row %d", i))) 104 | } 105 | a := NewScrollArea(b) 106 | a.Scroll(1, 2) 107 | return a 108 | }, 109 | want: ` 110 | ow 2...... 111 | ow 3...... 112 | ow 4...... 113 | `, 114 | }, 115 | } 116 | 117 | func TestScrollArea_Draw(t *testing.T) { 118 | for _, tt := range drawScrollAreaTests { 119 | tt := tt 120 | t.Run(tt.test, func(t *testing.T) { 121 | var surface *TestSurface 122 | if tt.size.X == 0 && tt.size.Y == 0 { 123 | surface = NewTestSurface(10, 5) 124 | } else { 125 | surface = NewTestSurface(tt.size.X, tt.size.Y) 126 | } 127 | painter := NewPainter(surface, NewTheme()) 128 | 129 | a := tt.setup() 130 | 131 | a.Resize(surface.size) 132 | a.Draw(painter) 133 | 134 | if diff := surfaceEquals(surface, tt.want); diff != "" { 135 | t.Error(diff) 136 | } 137 | }) 138 | } 139 | } 140 | 141 | func TestScrollArea_ScrollToBottom(t *testing.T) { 142 | surface := NewTestSurface(10, 3) 143 | painter := NewPainter(surface, NewTheme()) 144 | 145 | b := NewVBox() 146 | for i := 0; i < 10; i++ { 147 | b.Append(NewLabel(fmt.Sprintf("row %d", i))) 148 | } 149 | a := NewScrollArea(b) 150 | 151 | a.Resize(surface.size) 152 | 153 | a.ScrollToBottom() 154 | 155 | a.Draw(painter) 156 | 157 | want := ` 158 | row 7..... 159 | row 8..... 160 | row 9..... 161 | ` 162 | 163 | if diff := surfaceEquals(surface, want); diff != "" { 164 | t.Error(diff) 165 | } 166 | } 167 | 168 | func TestScrollArea_ScrollToBottom_MaintainHScroll(t *testing.T) { 169 | surface := NewTestSurface(10, 3) 170 | painter := NewPainter(surface, NewTheme()) 171 | 172 | b := NewVBox() 173 | for i := 0; i < 10; i++ { 174 | b.Append(NewLabel(fmt.Sprintf("row %d", i))) 175 | } 176 | a := NewScrollArea(b) 177 | 178 | a.Resize(surface.size) 179 | 180 | a.Scroll(2, 0) 181 | a.ScrollToBottom() 182 | 183 | a.Draw(painter) 184 | 185 | want := ` 186 | w 7....... 187 | w 8....... 188 | w 9....... 189 | ` 190 | 191 | if diff := surfaceEquals(surface, want); diff != "" { 192 | t.Error(diff) 193 | } 194 | } 195 | 196 | func TestScrollArea_ScrollToTop(t *testing.T) { 197 | surface := NewTestSurface(10, 3) 198 | painter := NewPainter(surface, NewTheme()) 199 | 200 | b := NewVBox() 201 | for i := 0; i < 10; i++ { 202 | b.Append(NewLabel(fmt.Sprintf("row %d", i))) 203 | } 204 | a := NewScrollArea(b) 205 | 206 | a.Resize(surface.size) 207 | 208 | a.Scroll(0, 2) 209 | a.ScrollToTop() 210 | 211 | a.Draw(painter) 212 | 213 | want := ` 214 | row 0..... 215 | row 1..... 216 | row 2..... 217 | ` 218 | 219 | if diff := surfaceEquals(surface, want); diff != "" { 220 | t.Error(diff) 221 | } 222 | } 223 | 224 | func TestScrollArea_ScrollToTop_MaintainHScroll(t *testing.T) { 225 | surface := NewTestSurface(10, 3) 226 | painter := NewPainter(surface, NewTheme()) 227 | 228 | b := NewVBox() 229 | for i := 0; i < 10; i++ { 230 | b.Append(NewLabel(fmt.Sprintf("row %d", i))) 231 | } 232 | a := NewScrollArea(b) 233 | 234 | a.Resize(surface.size) 235 | 236 | a.Scroll(2, 5) 237 | a.ScrollToTop() 238 | 239 | a.Draw(painter) 240 | 241 | want := ` 242 | w 0....... 243 | w 1....... 244 | w 2....... 245 | ` 246 | 247 | if diff := surfaceEquals(surface, want); diff != "" { 248 | t.Error(diff) 249 | } 250 | } 251 | 252 | var drawNestedScrollAreaTests = []struct { 253 | test string 254 | size image.Point 255 | setup func() *Box 256 | want string 257 | }{ 258 | { 259 | test: "Nested vertical scroll", 260 | size: image.Point{11, 12}, 261 | setup: func() *Box { 262 | l := NewList() 263 | l.AddItems("foo", "bar", "test") 264 | 265 | b1 := NewVBox(l) 266 | b1.SetBorder(true) 267 | 268 | nested := NewVBox(NewLabel("foo")) 269 | nested.SetBorder(true) 270 | 271 | nested2 := NewVBox(nested) 272 | nested2.SetBorder(true) 273 | 274 | s := NewScrollArea(nested2) 275 | s.Scroll(0, 4) 276 | 277 | b2 := NewVBox(s) 278 | b2.SetBorder(true) 279 | 280 | b3 := NewVBox(b1, b2) 281 | b3.SetBorder(true) 282 | 283 | return b3 284 | }, 285 | want: ` 286 | ┌─────────┐ 287 | │┌───────┐│ 288 | ││foo ││ 289 | ││bar ││ 290 | ││test ││ 291 | │└───────┘│ 292 | │┌───────┐│ 293 | ││└─────┘││ 294 | ││ ││ 295 | ││ ││ 296 | │└───────┘│ 297 | └─────────┘ 298 | `, 299 | }, 300 | { 301 | test: "Nested horizontal scroll", 302 | size: image.Point{20, 9}, 303 | setup: func() *Box { 304 | nested := NewVBox(NewLabel("foo")) 305 | nested.SetBorder(true) 306 | 307 | nested2 := NewVBox(nested) 308 | nested2.SetBorder(true) 309 | 310 | s := NewScrollArea(nested2) 311 | s.Scroll(-5, 0) 312 | 313 | b1 := NewVBox(s) 314 | b1.SetBorder(true) 315 | 316 | b2 := NewVBox(NewLabel("1234567")) 317 | b2.SetBorder(true) 318 | 319 | b3 := NewHBox(b1, b2) 320 | b3.SetBorder(true) 321 | 322 | return b3 323 | }, 324 | want: ` 325 | ┌──────────────────┐ 326 | │┌───────┐┌───────┐│ 327 | ││ ┌─││1234567││ 328 | ││ │┌││ ││ 329 | ││ ││││ ││ 330 | ││ │└││ ││ 331 | ││ └─││ ││ 332 | │└───────┘└───────┘│ 333 | └──────────────────┘ 334 | `, 335 | }, 336 | } 337 | 338 | func TestNestedScrollArea_Draw(t *testing.T) { 339 | for _, tt := range drawNestedScrollAreaTests { 340 | tt := tt 341 | t.Run(tt.test, func(t *testing.T) { 342 | var surface *TestSurface 343 | if tt.size.X == 0 && tt.size.Y == 0 { 344 | surface = NewTestSurface(10, 5) 345 | } else { 346 | surface = NewTestSurface(tt.size.X, tt.size.Y) 347 | } 348 | 349 | painter := NewPainter(surface, NewTheme()) 350 | painter.Repaint(tt.setup()) 351 | 352 | if diff := surfaceEquals(surface, tt.want); diff != "" { 353 | t.Error(diff) 354 | } 355 | }) 356 | } 357 | } 358 | -------------------------------------------------------------------------------- /runebuf_test.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "image" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func TestRuneBuffer_MoveForward(t *testing.T) { 10 | for _, tt := range []struct { 11 | text string 12 | in, out int 13 | }{ 14 | {"foo", 0, 1}, 15 | {"foo", 2, 3}, 16 | {"foo", 3, 3}, 17 | {"Lorem ipsum dolor \nsit amet.", 17, 18}, 18 | } { 19 | t.Run("", func(t *testing.T) { 20 | var buf RuneBuffer 21 | buf.SetWithIdx(tt.in, []rune(tt.text)) 22 | 23 | buf.MoveForward() 24 | 25 | if tt.out != buf.idx { 26 | t.Fatalf("want = %v; got = %v", tt.out, buf.idx) 27 | } 28 | }) 29 | } 30 | } 31 | 32 | func TestRuneBuffer_MoveBackward(t *testing.T) { 33 | for _, tt := range []struct { 34 | text string 35 | in, out int 36 | }{ 37 | {"foo", 0, 0}, 38 | {"foo", 2, 1}, 39 | {"foo", 3, 2}, 40 | {"Lorem ipsum dolor \nsit amet.", 18, 17}, 41 | } { 42 | t.Run("", func(t *testing.T) { 43 | var buf RuneBuffer 44 | buf.SetWithIdx(tt.in, []rune(tt.text)) 45 | 46 | buf.MoveBackward() 47 | 48 | if tt.out != buf.idx { 49 | t.Fatalf("want = %v; got = %v", tt.out, buf.idx) 50 | } 51 | }) 52 | } 53 | } 54 | 55 | func TestRuneBuffer_MoveToLineStart(t *testing.T) { 56 | for _, tt := range []struct { 57 | text string 58 | in, out int 59 | }{ 60 | {"foo", 3, 0}, 61 | {"foo", 0, 0}, 62 | {"Lorem ipsum dolor \nsit amet.", 21, 19}, 63 | {"Lorem ipsum dolor \n\nsit amet.", 21, 20}, 64 | {"Sed maximus tempor condimentum.\n\nNam et risus est. Cras ornare iaculis orci, \n\nsit amet fringilla nisl pharetra quis.", 40, 33}, 65 | {"Sed maximus tempor condimentum.\n\nNam et risus est. Cras ornare iaculis orci, \n\nsit amet fringilla nisl pharetra quis.", 90, 79}, 66 | {"Sed maximus tempor condimentum.\n\nNam et risus est. Cras ornare iaculis orci, \n\nsit amet fringilla nisl pharetra quis.", 79, 79}, 67 | // On a empty line. 68 | {"Sed maximus tempor condimentum.\n\nNam et risus est. Cras ornare iaculis orci, \n\nsit amet fringilla nisl pharetra quis.", 78, 78}, 69 | // On newline character. 70 | {"Sed maximus tempor condimentum.\n\nNam et risus est. Cras ornare iaculis orci, \n\nsit amet fringilla nisl pharetra quis.", 77, 33}, 71 | } { 72 | t.Run("", func(t *testing.T) { 73 | var buf RuneBuffer 74 | buf.SetWithIdx(tt.in, []rune(tt.text)) 75 | 76 | buf.MoveToLineStart() 77 | 78 | if tt.out != buf.idx { 79 | t.Fatalf("want = %v; got = %v", tt.out, buf.idx) 80 | } 81 | }) 82 | } 83 | } 84 | 85 | func TestRuneBuffer_MoveToLineEnd(t *testing.T) { 86 | for _, tt := range []struct { 87 | text string 88 | in, out int 89 | }{ 90 | {"foo", 0, 3}, 91 | {"foo", 3, 3}, 92 | {"Lorem ipsum dolor \nsit amet.", 0, 18}, 93 | {"Lorem ipsum dolor \n\nsit amet.", 20, 29}, 94 | {"Sed maximus tempor condimentum.\n\nNam et risus est. Cras ornare iaculis orci, \n\nsit amet fringilla nisl pharetra quis.", 0, 31}, 95 | {"Sed maximus tempor condimentum.\n\nNam et risus est. Cras ornare iaculis orci, \n\nsit amet fringilla nisl pharetra quis.", 33, 77}, 96 | {"Sed maximus tempor condimentum.\n\nNam et risus est. Cras ornare iaculis orci, \n\nsit amet fringilla nisl pharetra quis.", 79, 117}, 97 | {"Sed maximus tempor condimentum.\n\nNam et risus est. Cras ornare iaculis orci, \n\nsit amet fringilla nisl pharetra quis.", 77, 77}, 98 | } { 99 | t.Run("", func(t *testing.T) { 100 | var buf RuneBuffer 101 | buf.SetWithIdx(tt.in, []rune(tt.text)) 102 | 103 | buf.MoveToLineEnd() 104 | 105 | if tt.out != buf.idx { 106 | t.Fatalf("want = %v; got = %v", tt.out, buf.idx) 107 | } 108 | }) 109 | } 110 | } 111 | 112 | func TestRuneBuffer_Backspace(t *testing.T) { 113 | for _, tt := range []struct { 114 | curr RuneBuffer 115 | want RuneBuffer 116 | }{ 117 | {RuneBuffer{idx: 0, buf: []rune("foo bar")}, RuneBuffer{idx: 0, buf: []rune("foo bar")}}, 118 | {RuneBuffer{idx: 1, buf: []rune("foo bar")}, RuneBuffer{idx: 0, buf: []rune("oo bar")}}, 119 | {RuneBuffer{idx: 7, buf: []rune("foo bar")}, RuneBuffer{idx: 6, buf: []rune("foo ba")}}, 120 | {RuneBuffer{idx: 4, buf: []rune("foo bar")}, RuneBuffer{idx: 3, buf: []rune("foobar")}}, 121 | } { 122 | t.Run("", func(t *testing.T) { 123 | tt.curr.Backspace() 124 | 125 | if tt.want.idx != tt.curr.idx { 126 | t.Fatalf("want = %v; got = %v", tt.want.idx, tt.curr.idx) 127 | } 128 | if !reflect.DeepEqual(tt.want.buf, tt.curr.buf) { 129 | t.Fatalf("want = %q; got = %q", string(tt.want.buf), string(tt.curr.buf)) 130 | } 131 | }) 132 | } 133 | } 134 | 135 | func TestRuneBuffer_Kill(t *testing.T) { 136 | for _, tt := range []struct { 137 | curr RuneBuffer 138 | want RuneBuffer 139 | }{ 140 | {RuneBuffer{idx: 0, buf: []rune("foo bar")}, RuneBuffer{idx: 0, buf: []rune("")}}, 141 | {RuneBuffer{idx: 1, buf: []rune("foo bar")}, RuneBuffer{idx: 1, buf: []rune("f")}}, 142 | {RuneBuffer{idx: 7, buf: []rune("foo bar")}, RuneBuffer{idx: 7, buf: []rune("foo bar")}}, 143 | {RuneBuffer{idx: 4, buf: []rune("foo bar")}, RuneBuffer{idx: 4, buf: []rune("foo ")}}, 144 | {RuneBuffer{idx: 0, buf: []rune("foo \nbar")}, RuneBuffer{idx: 0, buf: []rune("bar")}}, 145 | {RuneBuffer{idx: 5, buf: []rune("foo \nbar")}, RuneBuffer{idx: 5, buf: []rune("foo \n")}}, 146 | {RuneBuffer{idx: 6, buf: []rune("foo \nbar")}, RuneBuffer{idx: 6, buf: []rune("foo \nb")}}, 147 | {RuneBuffer{idx: 0, buf: []rune("\n")}, RuneBuffer{idx: 0, buf: []rune("")}}, 148 | } { 149 | t.Run("", func(t *testing.T) { 150 | tt.curr.Kill() 151 | 152 | if tt.want.idx != tt.curr.idx { 153 | t.Fatalf("want = %v; got = %v", tt.want.idx, tt.curr.idx) 154 | } 155 | if !reflect.DeepEqual(tt.want.buf, tt.curr.buf) { 156 | t.Fatalf("want = %q; got = %q", string(tt.want.buf), string(tt.curr.buf)) 157 | } 158 | }) 159 | } 160 | } 161 | 162 | func TestRuneBuffer_SplitByLines(t *testing.T) { 163 | for _, tt := range []struct { 164 | text string 165 | width int 166 | wrap bool 167 | want []string 168 | }{ 169 | {"Lorem ipsum dolor sit amet.", 12, true, []string{"Lorem ipsum ", "dolor sit ", "amet."}}, 170 | {"Lorem ipsum dolor sit amet.", 27, true, []string{"Lorem ipsum dolor sit amet."}}, 171 | {"Lorem ipsum dolor sit amet.", 12, false, []string{"Lorem ipsum dolor sit amet."}}, 172 | } { 173 | var buf RuneBuffer 174 | buf.Set([]rune(tt.text)) 175 | buf.SetMaxWidth(tt.width) 176 | buf.wordwrap = tt.wrap 177 | 178 | got := buf.SplitByLine() 179 | if !reflect.DeepEqual(tt.want, got) { 180 | t.Fatalf("want = %#v; got = %#v", tt.want, got) 181 | } 182 | } 183 | } 184 | 185 | func TestRuneBuffer_CursorPos(t *testing.T) { 186 | for _, tt := range []struct { 187 | text string 188 | screenWidth int 189 | idx int 190 | wrap bool 191 | want image.Point 192 | }{ 193 | // Lorem ipsum 194 | // dolor sit amet. 195 | {"Lorem ipsum dolor sit amet.", 12, 11, true, image.Pt(11, 0)}, 196 | {"Lorem ipsum dolor sit amet.", 12, 12, true, image.Pt(12, 0)}, 197 | {"Lorem ipsum dolor sit amet.", 12, 13, true, image.Pt(0, 1)}, 198 | 199 | // Lorem ipsum dolor 200 | // sit amet. 201 | {"Lorem ipsum dolor sit amet.", 19, 17, true, image.Pt(17, 0)}, 202 | {"Lorem ipsum dolor sit amet.", 19, 18, true, image.Pt(18, 0)}, 203 | {"Lorem ipsum dolor sit amet.", 19, 19, true, image.Pt(0, 1)}, 204 | {"Lorem ipsum dolor sit amet.", 19, 20, true, image.Pt(1, 1)}, 205 | {"Lorem ipsum dolor sit amet.", 19, 21, true, image.Pt(2, 1)}, 206 | 207 | // aa bb 208 | // 209 | // cc dd 210 | {"aa bb\n\ncc dd", 10, 4, true, image.Pt(4, 0)}, 211 | {"aa bb\n\ncc dd", 10, 5, true, image.Pt(5, 0)}, 212 | {"aa bb\n\ncc dd", 10, 6, true, image.Pt(0, 1)}, 213 | {"aa bb\n\ncc dd", 10, 7, true, image.Pt(0, 2)}, 214 | } { 215 | t.Run("", func(t *testing.T) { 216 | var r RuneBuffer 217 | r.wordwrap = tt.wrap 218 | r.SetWithIdx(tt.idx, []rune(tt.text)) 219 | r.SetMaxWidth(tt.screenWidth) 220 | 221 | if got := r.CursorPos(); tt.want != got { 222 | t.Fatalf("want = %s; got = %s", tt.want, got) 223 | } 224 | }) 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /event.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "fmt" 5 | "image" 6 | "strings" 7 | ) 8 | 9 | // ModMask is a mask of modifier keys. 10 | type ModMask int16 11 | 12 | // Modifiers that can be sent with a KeyEvent or a MouseEvent. 13 | const ( 14 | ModShift ModMask = 1 << iota 15 | ModCtrl 16 | ModAlt 17 | ModMeta 18 | ModNone ModMask = 0 19 | ) 20 | 21 | // KeyEvent represents a key press. 22 | type KeyEvent struct { 23 | Key Key 24 | Rune rune 25 | Modifiers ModMask 26 | } 27 | 28 | // Name returns a user-friendly description of the key press. 29 | func (ev *KeyEvent) Name() string { 30 | s := "" 31 | m := []string{} 32 | if ev.Modifiers&ModShift != 0 { 33 | m = append(m, "Shift") 34 | } 35 | if ev.Modifiers&ModAlt != 0 { 36 | m = append(m, "Alt") 37 | } 38 | if ev.Modifiers&ModMeta != 0 { 39 | m = append(m, "Meta") 40 | } 41 | if ev.Modifiers&ModCtrl != 0 { 42 | m = append(m, "Ctrl") 43 | } 44 | 45 | ok := false 46 | if s, ok = keyNames[ev.Key]; !ok { 47 | if ev.Key == KeyRune { 48 | s = string(ev.Rune) 49 | } else { 50 | s = "Unknown" 51 | } 52 | } 53 | if len(m) != 0 { 54 | if ev.Modifiers&ModCtrl != 0 && strings.HasPrefix(s, "Ctrl-") { 55 | s = s[5:] 56 | } 57 | return fmt.Sprintf("%s+%s", strings.Join(m, "+"), s) 58 | } 59 | return s 60 | } 61 | 62 | // Key represents both normal and special keys. For normal letters, KeyRune is 63 | // used together with the Rune field in the KeyEvent. 64 | type Key int16 65 | 66 | // These are named keys that can be handled. 67 | const ( 68 | KeyRune Key = iota + 256 69 | KeyUp 70 | KeyDown 71 | KeyRight 72 | KeyLeft 73 | KeyUpLeft 74 | KeyUpRight 75 | KeyDownLeft 76 | KeyDownRight 77 | KeyCenter 78 | KeyPgUp 79 | KeyPgDn 80 | KeyHome 81 | KeyEnd 82 | KeyInsert 83 | KeyDelete 84 | KeyHelp 85 | KeyExit 86 | KeyClear 87 | KeyCancel 88 | KeyPrint 89 | KeyPause 90 | KeyBacktab 91 | KeyF1 92 | KeyF2 93 | KeyF3 94 | KeyF4 95 | KeyF5 96 | KeyF6 97 | KeyF7 98 | KeyF8 99 | KeyF9 100 | KeyF10 101 | KeyF11 102 | KeyF12 103 | KeyF13 104 | KeyF14 105 | KeyF15 106 | KeyF16 107 | KeyF17 108 | KeyF18 109 | KeyF19 110 | KeyF20 111 | KeyF21 112 | KeyF22 113 | KeyF23 114 | KeyF24 115 | KeyF25 116 | KeyF26 117 | KeyF27 118 | KeyF28 119 | KeyF29 120 | KeyF30 121 | KeyF31 122 | KeyF32 123 | KeyF33 124 | KeyF34 125 | KeyF35 126 | KeyF36 127 | KeyF37 128 | KeyF38 129 | KeyF39 130 | KeyF40 131 | KeyF41 132 | KeyF42 133 | KeyF43 134 | KeyF44 135 | KeyF45 136 | KeyF46 137 | KeyF47 138 | KeyF48 139 | KeyF49 140 | KeyF50 141 | KeyF51 142 | KeyF52 143 | KeyF53 144 | KeyF54 145 | KeyF55 146 | KeyF56 147 | KeyF57 148 | KeyF58 149 | KeyF59 150 | KeyF60 151 | KeyF61 152 | KeyF62 153 | KeyF63 154 | KeyF64 155 | ) 156 | 157 | // These are the supported control keys. 158 | const ( 159 | KeyCtrlSpace Key = iota 160 | KeyCtrlA 161 | KeyCtrlB 162 | KeyCtrlC 163 | KeyCtrlD 164 | KeyCtrlE 165 | KeyCtrlF 166 | KeyCtrlG 167 | KeyCtrlH 168 | KeyCtrlI 169 | KeyCtrlJ 170 | KeyCtrlK 171 | KeyCtrlL 172 | KeyCtrlM 173 | KeyCtrlN 174 | KeyCtrlO 175 | KeyCtrlP 176 | KeyCtrlQ 177 | KeyCtrlR 178 | KeyCtrlS 179 | KeyCtrlT 180 | KeyCtrlU 181 | KeyCtrlV 182 | KeyCtrlW 183 | KeyCtrlX 184 | KeyCtrlY 185 | KeyCtrlZ 186 | KeyCtrlLeftSq // Escape 187 | KeyCtrlBackslash 188 | KeyCtrlRightSq 189 | KeyCtrlCarat 190 | KeyCtrlUnderscore 191 | ) 192 | 193 | // These are the defined ASCII values for key codes. 194 | const ( 195 | KeyNUL Key = iota 196 | KeySOH 197 | KeySTX 198 | KeyETX 199 | KeyEOT 200 | KeyENQ 201 | KeyACK 202 | KeyBEL 203 | KeyBS 204 | KeyTAB 205 | KeyLF 206 | KeyVT 207 | KeyFF 208 | KeyCR 209 | KeySO 210 | KeySI 211 | KeyDLE 212 | KeyDC1 213 | KeyDC2 214 | KeyDC3 215 | KeyDC4 216 | KeyNAK 217 | KeySYN 218 | KeyETB 219 | KeyCAN 220 | KeyEM 221 | KeySUB 222 | KeyESC 223 | KeyFS 224 | KeyGS 225 | KeyRS 226 | KeyUS 227 | KeyDEL Key = 0x7F 228 | ) 229 | 230 | // These are aliases for other keys. 231 | const ( 232 | KeyBackspace = KeyBS 233 | KeyTab = KeyTAB 234 | KeyEsc = KeyESC 235 | KeyEscape = KeyESC 236 | KeyEnter = KeyCR 237 | KeyBackspace2 = KeyDEL 238 | ) 239 | 240 | var keyNames = map[Key]string{ 241 | KeyEnter: "Enter", 242 | KeyBackspace: "Backspace", 243 | KeyTab: "Tab", 244 | KeyBacktab: "Backtab", 245 | KeyEsc: "Esc", 246 | KeyBackspace2: "Backspace2", 247 | KeyInsert: "Insert", 248 | KeyDelete: "Delete", 249 | KeyHelp: "Help", 250 | KeyUp: "Up", 251 | KeyDown: "Down", 252 | KeyLeft: "Left", 253 | KeyRight: "Right", 254 | KeyHome: "Home", 255 | KeyEnd: "End", 256 | KeyUpLeft: "UpLeft", 257 | KeyUpRight: "UpRight", 258 | KeyDownLeft: "DownLeft", 259 | KeyDownRight: "DownRight", 260 | KeyCenter: "Center", 261 | KeyPgDn: "PgDn", 262 | KeyPgUp: "PgUp", 263 | KeyClear: "Clear", 264 | KeyExit: "Exit", 265 | KeyCancel: "Cancel", 266 | KeyPause: "Pause", 267 | KeyPrint: "Print", 268 | KeyF1: "F1", 269 | KeyF2: "F2", 270 | KeyF3: "F3", 271 | KeyF4: "F4", 272 | KeyF5: "F5", 273 | KeyF6: "F6", 274 | KeyF7: "F7", 275 | KeyF8: "F8", 276 | KeyF9: "F9", 277 | KeyF10: "F10", 278 | KeyF11: "F11", 279 | KeyF12: "F12", 280 | KeyF13: "F13", 281 | KeyF14: "F14", 282 | KeyF15: "F15", 283 | KeyF16: "F16", 284 | KeyF17: "F17", 285 | KeyF18: "F18", 286 | KeyF19: "F19", 287 | KeyF20: "F20", 288 | KeyF21: "F21", 289 | KeyF22: "F22", 290 | KeyF23: "F23", 291 | KeyF24: "F24", 292 | KeyF25: "F25", 293 | KeyF26: "F26", 294 | KeyF27: "F27", 295 | KeyF28: "F28", 296 | KeyF29: "F29", 297 | KeyF30: "F30", 298 | KeyF31: "F31", 299 | KeyF32: "F32", 300 | KeyF33: "F33", 301 | KeyF34: "F34", 302 | KeyF35: "F35", 303 | KeyF36: "F36", 304 | KeyF37: "F37", 305 | KeyF38: "F38", 306 | KeyF39: "F39", 307 | KeyF40: "F40", 308 | KeyF41: "F41", 309 | KeyF42: "F42", 310 | KeyF43: "F43", 311 | KeyF44: "F44", 312 | KeyF45: "F45", 313 | KeyF46: "F46", 314 | KeyF47: "F47", 315 | KeyF48: "F48", 316 | KeyF49: "F49", 317 | KeyF50: "F50", 318 | KeyF51: "F51", 319 | KeyF52: "F52", 320 | KeyF53: "F53", 321 | KeyF54: "F54", 322 | KeyF55: "F55", 323 | KeyF56: "F56", 324 | KeyF57: "F57", 325 | KeyF58: "F58", 326 | KeyF59: "F59", 327 | KeyF60: "F60", 328 | KeyF61: "F61", 329 | KeyF62: "F62", 330 | KeyF63: "F63", 331 | KeyF64: "F64", 332 | KeyCtrlUnderscore: "Ctrl-_", 333 | KeyCtrlRightSq: "Ctrl-]", 334 | KeyCtrlBackslash: "Ctrl-\\", 335 | KeyCtrlCarat: "Ctrl-^", 336 | KeyCtrlSpace: "Ctrl-Space", 337 | KeyCtrlA: "Ctrl-A", 338 | KeyCtrlB: "Ctrl-B", 339 | KeyCtrlC: "Ctrl-C", 340 | KeyCtrlD: "Ctrl-D", 341 | KeyCtrlE: "Ctrl-E", 342 | KeyCtrlF: "Ctrl-F", 343 | KeyCtrlG: "Ctrl-G", 344 | KeyCtrlJ: "Ctrl-J", 345 | KeyCtrlK: "Ctrl-K", 346 | KeyCtrlL: "Ctrl-L", 347 | KeyCtrlN: "Ctrl-N", 348 | KeyCtrlO: "Ctrl-O", 349 | KeyCtrlP: "Ctrl-P", 350 | KeyCtrlQ: "Ctrl-Q", 351 | KeyCtrlR: "Ctrl-R", 352 | KeyCtrlS: "Ctrl-S", 353 | KeyCtrlT: "Ctrl-T", 354 | KeyCtrlU: "Ctrl-U", 355 | KeyCtrlV: "Ctrl-V", 356 | KeyCtrlW: "Ctrl-W", 357 | KeyCtrlX: "Ctrl-X", 358 | KeyCtrlY: "Ctrl-Y", 359 | KeyCtrlZ: "Ctrl-Z", 360 | } 361 | 362 | // MouseEvent represents the event where a mouse button was pressed or 363 | // released. 364 | type MouseEvent struct { 365 | Pos image.Point 366 | } 367 | 368 | type paintEvent struct{} 369 | 370 | // callbackEvent holds a user-defined function which has been submitted 371 | // to be called on the render thread. 372 | type callbackEvent struct { 373 | cbFn func() 374 | } 375 | 376 | type event interface{} 377 | -------------------------------------------------------------------------------- /box.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "image" 5 | ) 6 | 7 | var _ Widget = &Box{} 8 | 9 | // Alignment is used to set the direction in which widgets are laid out. 10 | type Alignment int 11 | 12 | // Available alignment options. 13 | const ( 14 | Horizontal Alignment = iota 15 | Vertical 16 | ) 17 | 18 | // Box is a layout for placing widgets either horizontally or vertically. If 19 | // horizontally, all widgets will have the same height. If vertically, they 20 | // will all have the same width. 21 | type Box struct { 22 | WidgetBase 23 | 24 | children []Widget 25 | 26 | border bool 27 | title string 28 | 29 | alignment Alignment 30 | } 31 | 32 | // NewVBox returns a new vertically aligned Box. 33 | func NewVBox(c ...Widget) *Box { 34 | return &Box{ 35 | children: c, 36 | alignment: Vertical, 37 | } 38 | } 39 | 40 | // NewHBox returns a new horizontally aligned Box. 41 | func NewHBox(c ...Widget) *Box { 42 | return &Box{ 43 | children: c, 44 | alignment: Horizontal, 45 | } 46 | } 47 | 48 | // Append adds the given widget at the end of the Box. 49 | func (b *Box) Append(w Widget) { 50 | b.children = append(b.children, w) 51 | } 52 | 53 | // Prepend adds the given widget at the start of the Box. 54 | func (b *Box) Prepend(w Widget) { 55 | b.children = append([]Widget{w}, b.children...) 56 | } 57 | 58 | // Insert adds the widget into the Box at a given index. 59 | func (b *Box) Insert(i int, w Widget) { 60 | if len(b.children) < i || i < 0 { 61 | return 62 | } 63 | 64 | b.children = append(b.children, nil) 65 | copy(b.children[i+1:], b.children[i:]) 66 | b.children[i] = w 67 | } 68 | 69 | // Remove deletes the widget from the Box at a given index. 70 | func (b *Box) Remove(i int) { 71 | if len(b.children) <= i || i < 0 { 72 | return 73 | } 74 | 75 | b.children = append(b.children[:i], b.children[i+1:]...) 76 | } 77 | 78 | // Length returns the number of items in the box. 79 | func (b *Box) Length() int { 80 | return len(b.children) 81 | } 82 | 83 | // SetBorder sets whether the border is visible or not. 84 | func (b *Box) SetBorder(enabled bool) { 85 | b.border = enabled 86 | } 87 | 88 | // SetTitle sets the title of the box. 89 | func (b *Box) SetTitle(title string) { 90 | b.title = title 91 | } 92 | 93 | // Alignment returns the current alignment of the Box. 94 | func (b *Box) Alignment() Alignment { 95 | return b.alignment 96 | } 97 | 98 | // IsFocused return true if one of the children is focused. 99 | func (b *Box) IsFocused() bool { 100 | for _, w := range b.children { 101 | if w.IsFocused() { 102 | return true 103 | } 104 | } 105 | return false 106 | } 107 | 108 | // Draw recursively draws the widgets it contains. 109 | func (b *Box) Draw(p *Painter) { 110 | style := "box" 111 | if b.IsFocused() { 112 | style += ".focused" 113 | } 114 | 115 | p.WithStyle(style, func(p *Painter) { 116 | 117 | sz := b.Size() 118 | 119 | if b.border { 120 | p.WithStyle(style+".border", func(p *Painter) { 121 | p.DrawRect(0, 0, sz.X, sz.Y) 122 | }) 123 | p.WithStyle(style, func(p *Painter) { 124 | p.WithMask(image.Rect(0, 0, sz.X-1, 1), func(p *Painter) { 125 | p.DrawText(1, 0, b.title) 126 | }) 127 | }) 128 | p.FillRect(1, 1, sz.X-2, sz.Y-2) 129 | p.Translate(1, 1) 130 | defer p.Restore() 131 | } else { 132 | p.FillRect(0, 0, sz.X, sz.Y) 133 | } 134 | 135 | var off image.Point 136 | for _, child := range b.children { 137 | switch b.Alignment() { 138 | case Horizontal: 139 | p.Translate(off.X, 0) 140 | case Vertical: 141 | p.Translate(0, off.Y) 142 | } 143 | 144 | p.WithMask(image.Rectangle{ 145 | Min: image.Point{}, 146 | Max: child.Size(), 147 | }, func(p *Painter) { 148 | child.Draw(p) 149 | }) 150 | 151 | p.Restore() 152 | 153 | off = off.Add(child.Size()) 154 | } 155 | }) 156 | } 157 | 158 | // MinSizeHint returns the minimum size hint for the layout. 159 | func (b *Box) MinSizeHint() image.Point { 160 | var minSize image.Point 161 | 162 | for _, child := range b.children { 163 | size := child.MinSizeHint() 164 | if b.Alignment() == Horizontal { 165 | minSize.X += size.X 166 | if size.Y > minSize.Y { 167 | minSize.Y = size.Y 168 | } 169 | } else { 170 | minSize.Y += size.Y 171 | if size.X > minSize.X { 172 | minSize.X = size.X 173 | } 174 | } 175 | } 176 | 177 | if b.border { 178 | minSize = minSize.Add(image.Point{2, 2}) 179 | } 180 | 181 | return minSize 182 | } 183 | 184 | // SizeHint returns the recommended size hint for the layout. 185 | func (b *Box) SizeHint() image.Point { 186 | var sizeHint image.Point 187 | 188 | for _, child := range b.children { 189 | size := child.SizeHint() 190 | if b.Alignment() == Horizontal { 191 | sizeHint.X += size.X 192 | if size.Y > sizeHint.Y { 193 | sizeHint.Y = size.Y 194 | } 195 | } else { 196 | sizeHint.Y += size.Y 197 | if size.X > sizeHint.X { 198 | sizeHint.X = size.X 199 | } 200 | } 201 | } 202 | 203 | if b.border { 204 | sizeHint = sizeHint.Add(image.Point{2, 2}) 205 | } 206 | 207 | return sizeHint 208 | } 209 | 210 | // OnKeyEvent handles an event and propagates it to all children. 211 | func (b *Box) OnKeyEvent(ev KeyEvent) { 212 | for _, child := range b.children { 213 | child.OnKeyEvent(ev) 214 | } 215 | } 216 | 217 | // Resize recursively updates the size of the Box and all the widgets it 218 | // contains. This is a potentially expensive operation and should be invoked 219 | // with restraint. 220 | // 221 | // Resize is called by the layout engine and is not intended to be used by end 222 | // users. 223 | func (b *Box) Resize(size image.Point) { 224 | b.size = size 225 | inner := b.size 226 | if b.border { 227 | inner = b.size.Sub(image.Point{2, 2}) 228 | } 229 | b.layoutChildren(inner) 230 | } 231 | 232 | func (b *Box) layoutChildren(size image.Point) { 233 | space := doLayout(b.children, dim(b.Alignment(), size), b.Alignment()) 234 | 235 | for i, s := range space { 236 | switch b.Alignment() { 237 | case Horizontal: 238 | b.children[i].Resize(image.Point{s, size.Y}) 239 | case Vertical: 240 | b.children[i].Resize(image.Point{size.X, s}) 241 | } 242 | } 243 | } 244 | 245 | func doLayout(ws []Widget, space int, a Alignment) []int { 246 | sizes := make([]int, len(ws)) 247 | 248 | if len(sizes) == 0 { 249 | return sizes 250 | } 251 | 252 | remaining := space 253 | 254 | // Distribute MinSizeHint 255 | for { 256 | var changed bool 257 | for i, sz := range sizes { 258 | if sz < dim(a, ws[i].MinSizeHint()) { 259 | sizes[i] = sz + 1 260 | remaining-- 261 | if remaining <= 0 { 262 | goto Resize 263 | } 264 | changed = true 265 | } 266 | } 267 | if !changed { 268 | break 269 | } 270 | } 271 | 272 | // Distribute Minimum 273 | for { 274 | var changed bool 275 | for i, sz := range sizes { 276 | p := alignedSizePolicy(a, ws[i]) 277 | if p == Minimum && sz < dim(a, ws[i].SizeHint()) { 278 | sizes[i] = sz + 1 279 | remaining-- 280 | if remaining <= 0 { 281 | goto Resize 282 | } 283 | changed = true 284 | } 285 | } 286 | if !changed { 287 | break 288 | } 289 | } 290 | 291 | // Distribute Preferred 292 | for { 293 | var changed bool 294 | for i, sz := range sizes { 295 | p := alignedSizePolicy(a, ws[i]) 296 | if (p == Preferred || p == Maximum) && sz < dim(a, ws[i].SizeHint()) { 297 | sizes[i] = sz + 1 298 | remaining-- 299 | if remaining <= 0 { 300 | goto Resize 301 | } 302 | changed = true 303 | } 304 | } 305 | if !changed { 306 | break 307 | } 308 | } 309 | 310 | // Distribute Expanding 311 | for { 312 | var changed bool 313 | for i, sz := range sizes { 314 | p := alignedSizePolicy(a, ws[i]) 315 | if p == Expanding { 316 | sizes[i] = sz + 1 317 | remaining-- 318 | if remaining <= 0 { 319 | goto Resize 320 | } 321 | changed = true 322 | } 323 | } 324 | if !changed { 325 | break 326 | } 327 | } 328 | 329 | // Distribute remaining space 330 | for { 331 | min := maxInt 332 | for i, s := range sizes { 333 | p := alignedSizePolicy(a, ws[i]) 334 | if (p == Preferred || p == Minimum) && s <= min { 335 | min = s 336 | } 337 | } 338 | var changed bool 339 | for i, sz := range sizes { 340 | if sz != min { 341 | continue 342 | } 343 | p := alignedSizePolicy(a, ws[i]) 344 | if p == Preferred || p == Minimum { 345 | sizes[i] = sz + 1 346 | remaining-- 347 | if remaining <= 0 { 348 | goto Resize 349 | } 350 | changed = true 351 | } 352 | } 353 | if !changed { 354 | break 355 | } 356 | } 357 | 358 | Resize: 359 | 360 | return sizes 361 | } 362 | 363 | func dim(a Alignment, pt image.Point) int { 364 | if a == Horizontal { 365 | return pt.X 366 | } 367 | return pt.Y 368 | } 369 | 370 | func alignedSizePolicy(a Alignment, w Widget) SizePolicy { 371 | hpol, vpol := w.SizePolicy() 372 | if a == Horizontal { 373 | return hpol 374 | } 375 | return vpol 376 | } 377 | -------------------------------------------------------------------------------- /painter_test.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "image" 5 | "testing" 6 | ) 7 | 8 | func TestMask_Full(t *testing.T) { 9 | surface := NewTestSurface(10, 10) 10 | 11 | p := NewPainter(surface, NewTheme()) 12 | p.WithMask(image.Rect(0, 0, 10, 10), func(p *Painter) { 13 | p.WithMask(image.Rect(0, 0, 10, 10), func(p *Painter) { 14 | sz := p.surface.Size() 15 | for x := 0; x < sz.X; x++ { 16 | for y := 0; y < sz.Y; y++ { 17 | p.DrawRune(x, y, '█') 18 | } 19 | } 20 | }) 21 | }) 22 | 23 | want := ` 24 | ██████████ 25 | ██████████ 26 | ██████████ 27 | ██████████ 28 | ██████████ 29 | ██████████ 30 | ██████████ 31 | ██████████ 32 | ██████████ 33 | ██████████ 34 | ` 35 | if diff := surfaceEquals(surface, want); diff != "" { 36 | t.Error(diff) 37 | } 38 | } 39 | 40 | func TestMask_Inset(t *testing.T) { 41 | surface := NewTestSurface(10, 10) 42 | 43 | p := NewPainter(surface, NewTheme()) 44 | p.WithMask(image.Rect(0, 0, 10, 10), func(p *Painter) { 45 | p.WithMask(image.Rect(1, 1, 9, 9), func(p *Painter) { 46 | sz := p.surface.Size() 47 | for x := 0; x < sz.X; x++ { 48 | for y := 0; y < sz.Y; y++ { 49 | p.DrawRune(x, y, '█') 50 | } 51 | } 52 | }) 53 | }) 54 | 55 | want := ` 56 | .......... 57 | .████████. 58 | .████████. 59 | .████████. 60 | .████████. 61 | .████████. 62 | .████████. 63 | .████████. 64 | .████████. 65 | .......... 66 | ` 67 | if diff := surfaceEquals(surface, want); diff != "" { 68 | t.Error(diff) 69 | } 70 | } 71 | 72 | func TestMask_FirstCell(t *testing.T) { 73 | surface := NewTestSurface(10, 10) 74 | 75 | p := NewPainter(surface, NewTheme()) 76 | p.WithMask(image.Rect(0, 0, 10, 10), func(p *Painter) { 77 | p.WithMask(image.Rect(0, 0, 1, 1), func(p *Painter) { 78 | sz := p.surface.Size() 79 | for x := 0; x < sz.X; x++ { 80 | for y := 0; y < sz.Y; y++ { 81 | p.DrawRune(x, y, '█') 82 | } 83 | } 84 | }) 85 | }) 86 | 87 | want := ` 88 | █......... 89 | .......... 90 | .......... 91 | .......... 92 | .......... 93 | .......... 94 | .......... 95 | .......... 96 | .......... 97 | .......... 98 | ` 99 | if diff := surfaceEquals(surface, want); diff != "" { 100 | t.Error(diff) 101 | } 102 | } 103 | 104 | func TestMask_LastCell(t *testing.T) { 105 | surface := NewTestSurface(10, 10) 106 | 107 | p := NewPainter(surface, NewTheme()) 108 | p.WithMask(image.Rect(0, 0, 10, 10), func(p *Painter) { 109 | p.WithMask(image.Rect(9, 9, 10, 10), func(p *Painter) { 110 | sz := p.surface.Size() 111 | for x := 0; x < sz.X; x++ { 112 | for y := 0; y < sz.Y; y++ { 113 | p.DrawRune(x, y, '█') 114 | } 115 | } 116 | }) 117 | }) 118 | 119 | want := ` 120 | .......... 121 | .......... 122 | .......... 123 | .......... 124 | .......... 125 | .......... 126 | .......... 127 | .......... 128 | .......... 129 | .........█ 130 | ` 131 | if diff := surfaceEquals(surface, want); diff != "" { 132 | t.Error(diff) 133 | } 134 | } 135 | 136 | func TestMask_MaskWithinEmptyMaskIsHidden(t *testing.T) { 137 | surface := NewTestSurface(10, 10) 138 | 139 | p := NewPainter(surface, NewTheme()) 140 | p.WithMask(image.Rect(0, 0, 0, 0), func(p *Painter) { 141 | p.WithMask(image.Rect(1, 1, 9, 9), func(p *Painter) { 142 | sz := p.surface.Size() 143 | for x := 0; x < sz.X; x++ { 144 | for y := 0; y < sz.Y; y++ { 145 | p.DrawRune(x, y, '█') 146 | } 147 | } 148 | }) 149 | }) 150 | 151 | want := ` 152 | .......... 153 | .......... 154 | .......... 155 | .......... 156 | .......... 157 | .......... 158 | .......... 159 | .......... 160 | .......... 161 | .......... 162 | ` 163 | if diff := surfaceEquals(surface, want); diff != "" { 164 | t.Error(diff) 165 | } 166 | } 167 | 168 | func TestWithStyle_ApplyStyle(t *testing.T) { 169 | surface := NewTestSurface(5, 5) 170 | 171 | theme := NewTheme() 172 | theme.SetStyle("explicit", Style{Fg: ColorWhite, Bg: ColorBlack}) 173 | 174 | p := NewPainter(surface, theme) 175 | p.WithMask(image.Rect(0, 0, 5, 5), func(p *Painter) { 176 | p.WithMask(image.Rect(1, 1, 4, 4), func(p *Painter) { 177 | sz := p.surface.Size() 178 | for x := 0; x < sz.X; x++ { 179 | for y := 0; y < sz.Y; y++ { 180 | p.DrawRune(x, y, ' ') 181 | } 182 | } 183 | 184 | p.WithMask(image.Rect(2, 2, 4, 4), func(p *Painter) { 185 | p.WithStyle("explicit", func(p *Painter) { 186 | sz := p.surface.Size() 187 | for x := 0; x < sz.X; x++ { 188 | for y := 0; y < sz.Y; y++ { 189 | p.DrawRune(x, y, '!') 190 | } 191 | } 192 | 193 | }) 194 | }) 195 | }) 196 | }) 197 | 198 | wantFg := ` 199 | ..... 200 | .000. 201 | .022. 202 | .022. 203 | ..... 204 | ` 205 | 206 | wantBg := ` 207 | ..... 208 | .000. 209 | .011. 210 | .011. 211 | ..... 212 | ` 213 | 214 | if surface.FgColors() != wantFg { 215 | t.Errorf("got = \n%s\n\nwant = \n%s", surface.FgColors(), wantFg) 216 | } 217 | if surface.BgColors() != wantBg { 218 | t.Errorf("got = \n%s\n\nwant = \n%s", surface.BgColors(), wantBg) 219 | } 220 | 221 | } 222 | 223 | func TestWithStyle_Stacks(t *testing.T) { 224 | surface := NewTestSurface(10, 10) 225 | 226 | theme := NewTheme() 227 | theme.SetStyle("explicit", Style{Fg: Color(3)}) 228 | theme.SetStyle("auxiliary", Style{Fg: Color(2)}) 229 | 230 | p := NewPainter(surface, theme) 231 | p.WithMask(image.Rect(0, 0, 10, 10), func(p *Painter) { 232 | 233 | // Set "explicit" and draw upper-left and upper-right. 234 | p.WithStyle("explicit", func(p *Painter) { 235 | p.WithMask(image.Rect(1, 1, 4, 4), func(p *Painter) { 236 | sz := p.surface.Size() 237 | for x := 0; x < sz.X; x++ { 238 | for y := 0; y < sz.Y; y++ { 239 | p.DrawRune(x, y, ' ') 240 | } 241 | } 242 | }) 243 | // set "auxiliary" before drawing upper-right. 244 | p.WithStyle("auxiliary", func(p *Painter) { 245 | p.WithMask(image.Rect(7, 1, 9, 3), func(p *Painter) { 246 | sz := p.surface.Size() 247 | for x := 0; x < sz.X; x++ { 248 | for y := 0; y < sz.Y; y++ { 249 | p.DrawRune(x, y, ' ') 250 | } 251 | } 252 | }) 253 | }) 254 | // Then draw bottom-right, falling back to "explicit". 255 | p.WithMask(image.Rect(1, 6, 4, 9), func(p *Painter) { 256 | sz := p.surface.Size() 257 | for x := 0; x < sz.X; x++ { 258 | for y := 0; y < sz.Y; y++ { 259 | p.DrawRune(x, y, ' ') 260 | } 261 | } 262 | }) 263 | }) 264 | 265 | // Use global default for bottom-right. 266 | p.WithMask(image.Rect(6, 6, 9, 9), func(p *Painter) { 267 | sz := p.surface.Size() 268 | for x := 0; x < sz.X; x++ { 269 | for y := 0; y < sz.Y; y++ { 270 | p.DrawRune(x, y, ' ') 271 | } 272 | } 273 | }) 274 | }) 275 | 276 | wantFg := ` 277 | .......... 278 | .333...22. 279 | .333...22. 280 | .333...... 281 | .......... 282 | .......... 283 | .333..000. 284 | .333..000. 285 | .333..000. 286 | .......... 287 | ` 288 | 289 | if surface.FgColors() != wantFg { 290 | t.Errorf("got = \n%s\n\nwant = \n%s", surface.FgColors(), wantFg) 291 | } 292 | } 293 | 294 | var styleInheritTests = []struct { 295 | test string 296 | theme func() *Theme 297 | wantFg string 298 | wantDecorations string 299 | }{ 300 | { 301 | test: "no second style, keeps first", 302 | theme: func() *Theme { 303 | r := NewTheme() 304 | r.SetStyle("first", Style{Fg: Color(3), Bold: DecorationOn}) 305 | return r 306 | }, 307 | wantFg: ` 308 | 333..333.. 309 | 333..333.. 310 | 333..333.. 311 | `, 312 | wantDecorations: ` 313 | 222..222.. 314 | 222..222.. 315 | 222..222.. 316 | `, 317 | }, 318 | { 319 | test: "empty second style, inherits from first", 320 | theme: func() *Theme { 321 | r := NewTheme() 322 | r.SetStyle("first", Style{Fg: Color(3), Bold: DecorationOn}) 323 | r.SetStyle("second", Style{}) 324 | return r 325 | }, 326 | wantFg: ` 327 | 333..333.. 328 | 333..333.. 329 | 333..333.. 330 | `, 331 | wantDecorations: ` 332 | 222..222.. 333 | 222..222.. 334 | 222..222.. 335 | `, 336 | }, 337 | { 338 | test: "second style overrides only explicit properties", 339 | theme: func() *Theme { 340 | r := NewTheme() 341 | r.SetStyle("first", Style{Fg: Color(3), Bold: DecorationOn}) 342 | r.SetStyle("second", Style{Underline: DecorationOn}) 343 | return r 344 | }, 345 | wantFg: ` 346 | 333..333.. 347 | 333..333.. 348 | 333..333.. 349 | `, 350 | wantDecorations: ` 351 | 222..666.. 352 | 222..666.. 353 | 222..666.. 354 | `}, 355 | { 356 | test: "second style resets a previously set property", 357 | theme: func() *Theme { 358 | r := NewTheme() 359 | r.SetStyle("first", Style{Bold: DecorationOn}) 360 | r.SetStyle("second", Style{Bold: DecorationOff}) 361 | return r 362 | }, 363 | wantDecorations: ` 364 | 222..000.. 365 | 222..000.. 366 | 222..000.. 367 | `}, 368 | } 369 | 370 | func TestWithStyle_Inherit(t *testing.T) { 371 | for _, tt := range styleInheritTests { 372 | tt := tt 373 | t.Run(tt.test, func(t *testing.T) { 374 | 375 | surface := NewTestSurface(10, 3) 376 | theme := tt.theme() 377 | 378 | fill := func(p *Painter) { 379 | sz := p.surface.Size() 380 | for x := 0; x < sz.X; x++ { 381 | for y := 0; y < sz.Y; y++ { 382 | p.DrawRune(x, y, ' ') 383 | } 384 | } 385 | } 386 | 387 | p := NewPainter(surface, theme) 388 | p.WithMask(image.Rect(0, 0, 10, 3), func(p *Painter) { 389 | p.WithStyle("first", func(p *Painter) { 390 | p.WithMask(image.Rect(0, 0, 3, 3), fill) 391 | 392 | p.WithStyle("second", func(p *Painter) { 393 | p.WithMask(image.Rect(5, 0, 8, 3), fill) 394 | }) 395 | }) 396 | }) 397 | 398 | gotColors := surface.FgColors() 399 | gotDecorations := surface.Decorations() 400 | 401 | if tt.wantFg != "" && gotColors != tt.wantFg { 402 | t.Errorf("unexpected colors: got = \n%s\nwant = \n%s", gotColors, tt.wantFg) 403 | } 404 | if gotDecorations != tt.wantDecorations { 405 | t.Errorf("unexpected decorations: got = \n%s\nwant = \n%s", gotDecorations, tt.wantDecorations) 406 | } 407 | }) 408 | } 409 | } 410 | -------------------------------------------------------------------------------- /grid.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "image" 5 | ) 6 | 7 | var _ Widget = &Grid{} 8 | 9 | // Grid is a widget that lays out widgets in a grid. 10 | type Grid struct { 11 | WidgetBase 12 | 13 | rows, cols int 14 | 15 | rowHeights []int 16 | colWidths []int 17 | 18 | hasBorder bool 19 | 20 | cells map[image.Point]Widget 21 | 22 | columnStretch map[int]int 23 | rowStretch map[int]int 24 | } 25 | 26 | // NewGrid returns a new Grid. 27 | func NewGrid(cols, rows int) *Grid { 28 | return &Grid{ 29 | cols: cols, 30 | rows: rows, 31 | cells: make(map[image.Point]Widget), 32 | columnStretch: make(map[int]int), 33 | rowStretch: make(map[int]int), 34 | } 35 | } 36 | 37 | // Draw draws the grid. 38 | func (g *Grid) Draw(p *Painter) { 39 | s := g.Size() 40 | 41 | if g.hasBorder { 42 | border := 1 43 | 44 | // Draw outmost border. 45 | p.DrawRect(0, 0, s.X, s.Y) 46 | 47 | // Draw column dividers. 48 | var coloff int 49 | for i := 0; i < g.cols-1; i++ { 50 | x := g.colWidths[i] + coloff + border 51 | p.DrawVerticalLine(x, 0, s.Y-1) 52 | p.DrawRune(x, 0, '┬') 53 | p.DrawRune(x, s.Y-1, '┴') 54 | coloff = x 55 | } 56 | 57 | // Draw row dividers. 58 | var rowoff int 59 | for j := 0; j < g.rows-1; j++ { 60 | y := g.rowHeights[j] + rowoff + border 61 | p.DrawHorizontalLine(0, s.X-1, y) 62 | p.DrawRune(0, y, '├') 63 | p.DrawRune(s.X-1, y, '┤') 64 | rowoff = y 65 | } 66 | 67 | // Polish the intersections. 68 | rowoff = 0 69 | for j := 0; j < g.rows-1; j++ { 70 | y := g.rowHeights[j] + rowoff + border 71 | coloff = 0 72 | for i := 0; i < g.cols-1; i++ { 73 | x := g.colWidths[i] + coloff + border 74 | p.DrawRune(x, y, '┼') 75 | coloff = x 76 | } 77 | rowoff = y 78 | } 79 | } 80 | 81 | // Draw cell content. 82 | for i := 0; i < g.cols; i++ { 83 | for j := 0; j < g.rows; j++ { 84 | pos := image.Point{i, j} 85 | wp := g.mapCellToLocal(pos) 86 | 87 | if w, ok := g.cells[pos]; ok { 88 | p.Translate(wp.X, wp.Y) 89 | p.WithMask(image.Rectangle{ 90 | Min: image.Point{}, 91 | Max: w.Size(), 92 | }, func(p *Painter) { 93 | w.Draw(p) 94 | }) 95 | p.Restore() 96 | } 97 | } 98 | } 99 | } 100 | 101 | // MinSizeHint returns the minimum size hint for the grid. 102 | func (g *Grid) MinSizeHint() image.Point { 103 | if g.cols == 0 || g.rows == 0 { 104 | return image.Point{} 105 | } 106 | 107 | var width int 108 | for i := 0; i < g.cols; i++ { 109 | width += g.minColumnWidth(i) 110 | } 111 | 112 | var height int 113 | for j := 0; j < g.rows; j++ { 114 | height += g.minRowHeight(j) 115 | } 116 | 117 | if g.hasBorder { 118 | width += g.cols + 1 119 | height += g.rows + 1 120 | } 121 | 122 | return image.Point{width, height} 123 | } 124 | 125 | // SizeHint returns the recommended size hint for the grid. 126 | func (g *Grid) SizeHint() image.Point { 127 | if g.cols == 0 || g.rows == 0 { 128 | return image.Point{} 129 | } 130 | 131 | var maxWidth int 132 | for i := 0; i < g.cols; i++ { 133 | if w := g.columnWidth(i); w > maxWidth { 134 | maxWidth = w 135 | } 136 | } 137 | 138 | var maxHeight int 139 | for j := 0; j < g.rows; j++ { 140 | if h := g.rowHeight(j); h > maxHeight { 141 | maxHeight = h 142 | } 143 | } 144 | 145 | width := maxWidth * g.cols 146 | height := maxHeight * g.rows 147 | 148 | if g.hasBorder { 149 | width += g.cols + 1 150 | height += g.rows + 1 151 | } 152 | 153 | return image.Point{width, height} 154 | } 155 | 156 | // Resize recursively updates the size of the Grid and all the widgets it 157 | // contains. This is a potentially expensive operation and should be invoked 158 | // with restraint. 159 | // 160 | // Resize is called by the layout engine and is not intended to be used by end 161 | // users. 162 | func (g *Grid) Resize(size image.Point) { 163 | g.size = size 164 | inner := g.size 165 | if g.hasBorder { 166 | inner.X = g.size.X - (g.cols + 1) 167 | inner.Y = g.size.Y - (g.rows + 1) 168 | } 169 | g.layoutChildren(inner) 170 | } 171 | 172 | func (g *Grid) layoutChildren(size image.Point) { 173 | g.colWidths = g.doLayout(dim(Horizontal, size), Horizontal) 174 | g.rowHeights = g.doLayout(dim(Vertical, size), Vertical) 175 | 176 | for pos, w := range g.cells { 177 | w.Resize(image.Point{g.colWidths[pos.X], g.rowHeights[pos.Y]}) 178 | } 179 | } 180 | 181 | func (g *Grid) doLayout(space int, a Alignment) []int { 182 | var sizes []int 183 | var stretch map[int]int 184 | 185 | if a == Horizontal { 186 | sizes = make([]int, g.cols) 187 | stretch = g.columnStretch 188 | } else if a == Vertical { 189 | sizes = make([]int, g.rows) 190 | stretch = g.rowStretch 191 | } 192 | 193 | var nonZeroStretchFactors int 194 | for _, s := range stretch { 195 | if s > 0 { 196 | nonZeroStretchFactors++ 197 | } 198 | } 199 | 200 | remaining := space 201 | 202 | // Distribute MinSizeHint 203 | for { 204 | var changed bool 205 | for i, sz := range sizes { 206 | if stretch[i] > 0 { 207 | continue 208 | } 209 | 210 | ws := g.rowcol(i, a) 211 | var sizeHint int 212 | for _, w := range ws { 213 | s := dim(a, w.MinSizeHint()) 214 | if sizeHint < s { 215 | sizeHint = s 216 | } 217 | } 218 | if sz < sizeHint { 219 | sizes[i] = sz + 1 220 | remaining-- 221 | if remaining <= 0 { 222 | goto Resize 223 | } 224 | changed = true 225 | } 226 | } 227 | if !changed { 228 | break 229 | } 230 | } 231 | 232 | // Distribute Minimum 233 | for { 234 | var changed bool 235 | for i, sz := range sizes { 236 | if stretch[i] > 0 { 237 | continue 238 | } 239 | 240 | ws := g.rowcol(i, a) 241 | var sizeHint int 242 | for _, w := range ws { 243 | s := dim(a, w.SizeHint()) 244 | p := alignedSizePolicy(a, w) 245 | if p == Minimum && sizeHint < s { 246 | sizeHint = s 247 | } 248 | } 249 | if sz < sizeHint { 250 | sizes[i] = sz + 1 251 | remaining-- 252 | if remaining <= 0 { 253 | goto Resize 254 | } 255 | changed = true 256 | } 257 | } 258 | if !changed { 259 | break 260 | } 261 | } 262 | 263 | // Distribute remaining space 264 | for { 265 | var changed bool 266 | if nonZeroStretchFactors == 0 { 267 | min := maxInt 268 | for _, sz := range sizes { 269 | if sz < min { 270 | min = sz 271 | } 272 | } 273 | for i, sz := range sizes { 274 | if sz == min { 275 | sizes[i] = sz + 1 276 | remaining-- 277 | if remaining <= 0 { 278 | goto Resize 279 | } 280 | changed = true 281 | } 282 | } 283 | } else { 284 | for i, sz := range sizes { 285 | s := stretch[i] 286 | if s > 0 { 287 | if remaining-s < 0 { 288 | s = remaining 289 | } 290 | sizes[i] = sz + s 291 | remaining -= s 292 | if remaining <= 0 { 293 | goto Resize 294 | } 295 | changed = true 296 | } 297 | } 298 | } 299 | if !changed { 300 | break 301 | } 302 | } 303 | 304 | Resize: 305 | 306 | return sizes 307 | } 308 | 309 | func (g *Grid) rowcol(i int, a Alignment) []Widget { 310 | cells := make([]Widget, 0) 311 | for p, w := range g.cells { 312 | if dim(a, p) == i { 313 | cells = append(cells, w) 314 | } 315 | } 316 | return cells 317 | } 318 | 319 | func (g *Grid) mapCellToLocal(p image.Point) image.Point { 320 | var lx, ly int 321 | 322 | for x := 0; x < p.X; x++ { 323 | lx += g.colWidths[x] 324 | } 325 | for y := 0; y < p.Y; y++ { 326 | ly += g.rowHeights[y] 327 | } 328 | 329 | if g.hasBorder { 330 | lx += p.X + 1 331 | ly += p.Y + 1 332 | } 333 | 334 | return image.Point{lx, ly} 335 | } 336 | 337 | func (g *Grid) rowHeight(i int) int { 338 | result := 0 339 | for pos, w := range g.cells { 340 | if pos.Y != i { 341 | continue 342 | } 343 | 344 | if w.SizeHint().Y > result { 345 | result = w.SizeHint().Y 346 | } 347 | } 348 | 349 | return result 350 | } 351 | 352 | func (g *Grid) columnWidth(i int) int { 353 | result := 0 354 | for pos, w := range g.cells { 355 | if pos.X != i { 356 | continue 357 | } 358 | 359 | if w.SizeHint().X > result { 360 | result = w.SizeHint().X 361 | } 362 | } 363 | return result 364 | } 365 | 366 | func (g *Grid) minRowHeight(i int) int { 367 | result := 0 368 | for pos, w := range g.cells { 369 | if pos.Y != i { 370 | continue 371 | } 372 | 373 | if w.MinSizeHint().Y > result { 374 | result = w.MinSizeHint().Y 375 | } 376 | } 377 | 378 | return result 379 | } 380 | 381 | func (g *Grid) minColumnWidth(i int) int { 382 | result := 0 383 | for pos, w := range g.cells { 384 | if pos.X != i { 385 | continue 386 | } 387 | 388 | if w.MinSizeHint().X > result { 389 | result = w.MinSizeHint().X 390 | } 391 | } 392 | return result 393 | } 394 | 395 | // OnKeyEvent handles key events. 396 | func (g *Grid) OnKeyEvent(ev KeyEvent) { 397 | for _, w := range g.cells { 398 | w.OnKeyEvent(ev) 399 | } 400 | } 401 | 402 | // SetCell sets or replaces the contents of a cell. 403 | func (g *Grid) SetCell(pos image.Point, w Widget) { 404 | g.cells[pos] = w 405 | } 406 | 407 | // SetBorder sets whether the border is visible or not. 408 | func (g *Grid) SetBorder(enabled bool) { 409 | g.hasBorder = enabled 410 | } 411 | 412 | // AppendRow adds a new row at the end. 413 | func (g *Grid) AppendRow(row ...Widget) { 414 | g.rows++ 415 | 416 | if len(row) > g.cols { 417 | g.cols = len(row) 418 | } 419 | 420 | for i, cell := range row { 421 | pos := image.Point{i, g.rows - 1} 422 | g.SetCell(pos, cell) 423 | } 424 | } 425 | 426 | // RemoveRow removes the row ( at index ) from the grid 427 | func (g *Grid) RemoveRow(index int) { 428 | if index < g.rows { 429 | g.rows-- 430 | for i := index; i <= g.rows; i++ { 431 | for j := 0; j < g.cols; j++ { 432 | if i == g.rows { 433 | delete(g.cells, image.Point{j, g.rows}) 434 | } else { 435 | g.cells[image.Point{j, i}] = g.cells[image.Point{j, i + 1}] 436 | } 437 | } 438 | } 439 | } 440 | } 441 | 442 | // RemoveRows will remove all the rows in grid 443 | func (g *Grid) RemoveRows() { 444 | g.rows = 0 445 | g.cells = make(map[image.Point]Widget) 446 | } 447 | 448 | // SetColumnStretch sets the stretch factor for a given column. If stretch > 0, 449 | // the column will expand to fill up available space. If multiple columns have 450 | // a stretch factor > 0, stretch determines how much space the column get in 451 | // respect to the others. E.g. by setting SetColumnStretch(0, 1) and 452 | // SetColumnStretch(1, 2), the second column will fill up twice as much space 453 | // as the first one. 454 | func (g *Grid) SetColumnStretch(col, stretch int) { 455 | g.columnStretch[col] = stretch 456 | } 457 | 458 | // SetRowStretch sets the stretch factor for a given row. For more on stretch 459 | // factors, see SetColumnStretch. 460 | func (g *Grid) SetRowStretch(row, stretch int) { 461 | g.rowStretch[row] = stretch 462 | } 463 | -------------------------------------------------------------------------------- /entry_test.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "image" 5 | "testing" 6 | ) 7 | 8 | var drawEntryTests = []struct { 9 | test string 10 | size image.Point 11 | setup func() *Entry 12 | want string 13 | }{ 14 | { 15 | test: "Empty entry", 16 | size: image.Point{15, 2}, 17 | setup: func() *Entry { 18 | return NewEntry() 19 | }, 20 | want: ` 21 | 22 | ............... 23 | `, 24 | }, 25 | { 26 | test: "Entry with text", 27 | size: image.Point{15, 2}, 28 | setup: func() *Entry { 29 | e := NewEntry() 30 | e.SetText("test") 31 | return e 32 | }, 33 | want: ` 34 | test 35 | ............... 36 | `, 37 | }, 38 | { 39 | test: "Scrolling entry", 40 | size: image.Point{15, 2}, 41 | setup: func() *Entry { 42 | e := NewEntry() 43 | e.SetText("Lorem ipsum dolor sit amet") 44 | e.offset = 11 45 | return e 46 | }, 47 | want: ` 48 | dolor sit amet 49 | ............... 50 | `, 51 | }, 52 | { 53 | test: "Scrolling entry when focused", 54 | size: image.Point{15, 2}, 55 | setup: func() *Entry { 56 | e := NewEntry() 57 | e.SetText("Lorem ipsum dolor sit amet") 58 | e.SetFocused(true) 59 | e.offset = 12 60 | return e 61 | }, 62 | want: ` 63 | dolor sit amet 64 | ............... 65 | `, 66 | }, 67 | } 68 | 69 | func TestEntry_Draw(t *testing.T) { 70 | for _, tt := range drawEntryTests { 71 | tt := tt 72 | t.Run(tt.test, func(t *testing.T) { 73 | var surface *TestSurface 74 | if tt.size.X == 0 && tt.size.Y == 0 { 75 | surface = NewTestSurface(10, 5) 76 | } else { 77 | surface = NewTestSurface(tt.size.X, tt.size.Y) 78 | } 79 | 80 | painter := NewPainter(surface, NewTheme()) 81 | painter.Repaint(tt.setup()) 82 | 83 | if diff := surfaceEquals(surface, tt.want); diff != "" { 84 | t.Error(diff) 85 | } 86 | }) 87 | } 88 | } 89 | 90 | func TestEntry_OnChanged(t *testing.T) { 91 | e := NewEntry() 92 | 93 | var invoked bool 94 | e.OnChanged(func(e *Entry) { 95 | invoked = true 96 | if e.Text() != "t" { 97 | t.Errorf("e.Text() = %s; want = %s", e.Text(), "t") 98 | } 99 | }) 100 | 101 | ev := KeyEvent{ 102 | Key: KeyRune, 103 | Rune: 't', 104 | } 105 | 106 | t.Run("When entry is not focused", func(t *testing.T) { 107 | e.OnKeyEvent(ev) 108 | if invoked { 109 | t.Errorf("entry should not receive key events") 110 | } 111 | }) 112 | 113 | invoked = false 114 | e.SetFocused(true) 115 | 116 | t.Run("When entry is focused", func(t *testing.T) { 117 | e.OnKeyEvent(ev) 118 | if !invoked { 119 | t.Errorf("entry should receive key events") 120 | } 121 | }) 122 | } 123 | 124 | func TestEntry_OnSubmit(t *testing.T) { 125 | e := NewEntry() 126 | 127 | var invoked bool 128 | e.OnSubmit(func(e *Entry) { 129 | invoked = true 130 | }) 131 | 132 | ev := KeyEvent{ 133 | Key: KeyEnter, 134 | } 135 | 136 | t.Run("When entry is not focused", func(t *testing.T) { 137 | e.OnKeyEvent(ev) 138 | if invoked { 139 | t.Errorf("entry should not be submitted") 140 | } 141 | }) 142 | 143 | invoked = false 144 | e.SetFocused(true) 145 | 146 | t.Run("When entry is focused", func(t *testing.T) { 147 | e.OnKeyEvent(ev) 148 | if !invoked { 149 | t.Errorf("button should be submitted") 150 | } 151 | }) 152 | } 153 | 154 | var layoutEntryTests = []struct { 155 | test string 156 | setup func() *Box 157 | want string 158 | }{ 159 | { 160 | test: "Preferred", 161 | setup: func() *Box { 162 | e := NewEntry() 163 | e.SetSizePolicy(Preferred, Preferred) 164 | 165 | b := NewHBox(e) 166 | b.SetBorder(true) 167 | b.SetSizePolicy(Expanding, Expanding) 168 | 169 | return b 170 | }, 171 | want: ` 172 | ┌──────────────────┐ 173 | │ │ 174 | │ │ 175 | │ │ 176 | └──────────────────┘ 177 | `, 178 | }, 179 | { 180 | test: "Preferred/Preferred", 181 | setup: func() *Box { 182 | e1 := NewEntry() 183 | e1.SetSizePolicy(Preferred, Preferred) 184 | e1.SetText("0123456789foo") 185 | e1.offset = 4 186 | 187 | e2 := NewEntry() 188 | e2.SetSizePolicy(Preferred, Preferred) 189 | e2.SetText("0123456789bar") 190 | e2.offset = 4 191 | 192 | b := NewHBox(e1, e2) 193 | b.SetBorder(true) 194 | b.SetSizePolicy(Expanding, Expanding) 195 | 196 | return b 197 | }, 198 | want: ` 199 | ┌──────────────────┐ 200 | │456789foo456789bar│ 201 | │ │ 202 | │ │ 203 | └──────────────────┘ 204 | `, 205 | }, 206 | { 207 | test: "Preferred/Minimum", 208 | setup: func() *Box { 209 | e1 := NewEntry() 210 | e1.SetSizePolicy(Preferred, Preferred) 211 | e1.SetText("0123456789foo") 212 | e1.offset = 5 213 | 214 | e2 := NewEntry() 215 | e2.SetSizePolicy(Minimum, Preferred) 216 | e2.SetText("0123456789bar") 217 | e2.offset = 3 218 | 219 | b := NewHBox(e1, e2) 220 | b.SetBorder(true) 221 | 222 | return b 223 | }, 224 | want: ` 225 | ┌──────────────────┐ 226 | │56789foo3456789bar│ 227 | │ │ 228 | │ │ 229 | └──────────────────┘ 230 | `, 231 | }, 232 | { 233 | test: "Minimum/Preferred", 234 | setup: func() *Box { 235 | e1 := NewEntry() 236 | e1.SetSizePolicy(Minimum, Preferred) 237 | e1.SetText("0123456789foo") 238 | e1.offset = 3 239 | 240 | e2 := NewEntry() 241 | e2.SetSizePolicy(Preferred, Preferred) 242 | e2.SetText("0123456789bar") 243 | e2.offset = 5 244 | 245 | b := NewHBox(e1, e2) 246 | b.SetBorder(true) 247 | 248 | return b 249 | }, 250 | want: ` 251 | ┌──────────────────┐ 252 | │3456789foo56789bar│ 253 | │ │ 254 | │ │ 255 | └──────────────────┘ 256 | `, 257 | }, 258 | { 259 | test: "Preferred/Expanding", 260 | setup: func() *Box { 261 | e1 := NewEntry() 262 | e1.SetSizePolicy(Preferred, Preferred) 263 | e1.SetText("foo") 264 | 265 | e2 := NewEntry() 266 | e2.SetSizePolicy(Expanding, Preferred) 267 | e2.SetText("bar") 268 | 269 | b := NewHBox(e1, e2) 270 | b.SetBorder(true) 271 | b.SetSizePolicy(Expanding, Expanding) 272 | 273 | return b 274 | }, 275 | want: ` 276 | ┌──────────────────┐ 277 | │foo bar │ 278 | │ │ 279 | │ │ 280 | └──────────────────┘ 281 | `, 282 | }, 283 | { 284 | test: "Expanding/Preferred", 285 | setup: func() *Box { 286 | e1 := NewEntry() 287 | e1.SetText("foo") 288 | e1.SetSizePolicy(Expanding, Preferred) 289 | 290 | e2 := NewEntry() 291 | e2.SetText("bar") 292 | e2.SetSizePolicy(Preferred, Preferred) 293 | 294 | b := NewHBox(e1, e2) 295 | b.SetBorder(true) 296 | 297 | return b 298 | }, 299 | want: ` 300 | ┌──────────────────┐ 301 | │foo bar │ 302 | │ │ 303 | │ │ 304 | └──────────────────┘ 305 | `, 306 | }, 307 | } 308 | 309 | func TestEntry_Layout(t *testing.T) { 310 | for _, tt := range layoutEntryTests { 311 | tt := tt 312 | t.Run(tt.test, func(t *testing.T) { 313 | surface := NewTestSurface(20, 5) 314 | painter := NewPainter(surface, NewTheme()) 315 | painter.Repaint(tt.setup()) 316 | 317 | if diff := surfaceEquals(surface, tt.want); diff != "" { 318 | t.Error(diff) 319 | } 320 | }) 321 | } 322 | } 323 | 324 | func TestEntry_OnEvent(t *testing.T) { 325 | e := NewEntry() 326 | e.SetText("Lorem ipsum") 327 | e.SetFocused(true) 328 | 329 | surface := NewTestSurface(4, 1) 330 | painter := NewPainter(surface, NewTheme()) 331 | painter.Repaint(e) 332 | 333 | want := ` 334 | Lore 335 | ` 336 | if e.offset != 0 { 337 | t.Errorf("offset = %d; want = %d", e.offset, 0) 338 | } 339 | if diff := surfaceEquals(surface, want); diff != "" { 340 | t.Error(diff) 341 | } 342 | 343 | e.OnKeyEvent(KeyEvent{Key: KeyRight}) 344 | painter.Repaint(e) 345 | 346 | want = ` 347 | orem 348 | ` 349 | if e.offset != 1 { 350 | t.Errorf("offset = %d; want = %d", e.offset, 1) 351 | } 352 | if diff := surfaceEquals(surface, want); diff != "" { 353 | t.Error(diff) 354 | } 355 | 356 | e.OnKeyEvent(KeyEvent{Key: KeyLeft}) 357 | e.OnKeyEvent(KeyEvent{Key: KeyLeft}) 358 | painter.Repaint(e) 359 | 360 | want = ` 361 | Lore 362 | ` 363 | if e.offset != 0 { 364 | t.Errorf("offset = %d; want = %d", e.offset, 0) 365 | } 366 | if diff := surfaceEquals(surface, want); diff != "" { 367 | t.Error(diff) 368 | } 369 | 370 | repeatKeyEvent(e, KeyEvent{Key: KeyRight}, 20) 371 | painter.Repaint(e) 372 | 373 | want = ` 374 | sum 375 | ` 376 | if e.offset != 8 { 377 | t.Errorf("offset = %d; want = %d", e.offset, 8) 378 | } 379 | if diff := surfaceEquals(surface, want); diff != "" { 380 | t.Error(diff) 381 | } 382 | } 383 | 384 | func TestEntry_MoveToStartAndEnd(t *testing.T) { 385 | t.Run("Given an entry with too long text", func(t *testing.T) { 386 | e := NewEntry() 387 | e.SetText("Lorem ipsum") 388 | e.SetFocused(true) 389 | e.text.SetMaxWidth(5) 390 | e.offset = 6 391 | 392 | surface := NewTestSurface(5, 1) 393 | painter := NewPainter(surface, NewTheme()) 394 | 395 | t.Run("When cursor is moved to the start", func(t *testing.T) { 396 | repeatKeyEvent(e, KeyEvent{Key: KeyCtrlA}, 1) 397 | painter.Repaint(e) 398 | 399 | want := "\nLorem\n" 400 | 401 | if got := e.text.CursorPos(); got.X != 0 { 402 | t.Errorf("cursor position should be %d, but was %d", 0, got.X) 403 | } 404 | if e.offset != 0 { 405 | t.Errorf("offset should be %d, but was %d", 0, e.offset) 406 | } 407 | if surface.String() != want { 408 | t.Errorf("surface should be:\n%s\nbut was:\n%s", want, surface.String()) 409 | } 410 | }) 411 | t.Run("When cursor is moved to the end", func(t *testing.T) { 412 | repeatKeyEvent(e, KeyEvent{Key: KeyCtrlE}, 1) 413 | painter.Repaint(e) 414 | 415 | want := "\npsum \n" 416 | 417 | if got := e.text.CursorPos(); got.X != 11 { 418 | t.Errorf("cursor position should be %d, but was %d", 11, got.X) 419 | } 420 | if e.offset != 7 { 421 | t.Errorf("offset should be %d, but was %d", 7, e.offset) 422 | } 423 | if surface.String() != want { 424 | t.Errorf("surface should be:\n%s\nbut was:\n%s", want, surface.String()) 425 | } 426 | }) 427 | }) 428 | } 429 | func TestEntry_OnKeyBackspaceEvent(t *testing.T) { 430 | t.Run("Given an entry with too long text", func(t *testing.T) { 431 | e := NewEntry() 432 | e.SetText("Lorem ipsum") 433 | e.SetFocused(true) 434 | e.text.SetMaxWidth(5) 435 | e.offset = 6 436 | 437 | surface := NewTestSurface(5, 1) 438 | painter := NewPainter(surface, NewTheme()) 439 | 440 | t.Run("When cursor is moved to the middle", func(t *testing.T) { 441 | repeatKeyEvent(e, KeyEvent{Key: KeyLeft}, 2) 442 | painter.Repaint(e) 443 | 444 | want := "\nm ips\n" 445 | 446 | if got := e.text.CursorPos(); got.X != 9 { 447 | t.Errorf("cursor position should be %d, but was %d", 9, got.X) 448 | } 449 | if e.offset != 4 { 450 | t.Errorf("offset should be %d, but was %d", 4, e.offset) 451 | } 452 | if surface.String() != want { 453 | t.Errorf("surface should be:\n%s\nbut was:\n%s", want, surface.String()) 454 | } 455 | }) 456 | t.Run("When character in the middle is deleted", func(t *testing.T) { 457 | repeatKeyEvent(e, KeyEvent{Key: KeyBackspace2}, 1) 458 | painter.Repaint(e) 459 | 460 | want := "\nm ipu\n" 461 | 462 | if e.offset != 4 { 463 | t.Errorf("offset should be %d, but was %d", 4, e.offset) 464 | } 465 | if surface.String() != want { 466 | t.Errorf("surface should be:\n%s\nbut was:\n%s", want, surface.String()) 467 | } 468 | }) 469 | t.Run("When cursor is moved to the end", func(t *testing.T) { 470 | repeatKeyEvent(e, KeyEvent{Key: KeyRight}, 6) 471 | painter.Repaint(e) 472 | 473 | want := "\nipum \n" 474 | 475 | if e.offset != 6 { 476 | t.Errorf("offset should be %d, but was %d", 6, e.offset) 477 | } 478 | if surface.String() != want { 479 | t.Errorf("surface should be:\n%s\nbut was:\n%s", want, surface.String()) 480 | } 481 | }) 482 | t.Run("When last character is deleted", func(t *testing.T) { 483 | repeatKeyEvent(e, KeyEvent{Key: KeyBackspace2}, 1) 484 | painter.Repaint(e) 485 | 486 | want := "\n ipu \n" 487 | 488 | if e.offset != 5 { 489 | t.Errorf("offset should be %d, but was %d", 5, e.offset) 490 | } 491 | if surface.String() != want { 492 | t.Errorf("surface should be:\n%s\nbut was:\n%s", want, surface.String()) 493 | } 494 | }) 495 | t.Run("When all characters are deleted", func(t *testing.T) { 496 | repeatKeyEvent(e, KeyEvent{Key: KeyBackspace2}, 9) 497 | painter.Repaint(e) 498 | 499 | want := "\n \n" 500 | 501 | if e.offset != 0 { 502 | t.Errorf("offset should be %d, but was %d", 0, e.offset) 503 | } 504 | if surface.String() != want { 505 | t.Errorf("surface should be:\n%s\nbut was:\n%s", want, surface.String()) 506 | } 507 | }) 508 | t.Run("When deleting an empty text", func(t *testing.T) { 509 | repeatKeyEvent(e, KeyEvent{Key: KeyBackspace2}, 1) 510 | painter.Repaint(e) 511 | 512 | want := "\n \n" 513 | 514 | if e.offset != 0 { 515 | t.Errorf("offset should be %d, but was %d", 0, e.offset) 516 | } 517 | if surface.String() != want { 518 | t.Errorf("surface should be:\n%s\nbut was:\n%s", want, surface.String()) 519 | } 520 | }) 521 | }) 522 | } 523 | 524 | func TestIsTextRemaining(t *testing.T) { 525 | for _, tt := range []struct { 526 | text string 527 | offset int 528 | width int 529 | want bool 530 | }{ 531 | {"Lorem ipsum", 0, 11, false}, 532 | {"Lorem ipsum", 1, 11, false}, 533 | {"Lorem ipsum", 0, 10, true}, 534 | {"Lorem ipsum", 5, 5, true}, 535 | } { 536 | t.Run("", func(t *testing.T) { 537 | e := NewEntry() 538 | e.SetText(tt.text) 539 | e.SetFocused(true) 540 | e.Resize(image.Pt(tt.width, 1)) 541 | 542 | e.offset = tt.offset 543 | 544 | if e.isTextRemaining() != tt.want { 545 | t.Fatalf("want = %v; got = %v", tt.want, e.isTextRemaining()) 546 | } 547 | }) 548 | } 549 | } 550 | 551 | var echoModeTests = []struct { 552 | test string 553 | size image.Point 554 | setup func() *Entry 555 | want string 556 | }{ 557 | { 558 | test: "Echo", 559 | size: image.Point{15, 5}, 560 | setup: func() *Entry { 561 | e := NewEntry() 562 | e.SetText("Lorem ipsum") 563 | return e 564 | }, 565 | want: ` 566 | Lorem ipsum 567 | ............... 568 | ............... 569 | ............... 570 | ............... 571 | `, 572 | }, 573 | { 574 | test: "NoEcho", 575 | size: image.Point{15, 5}, 576 | setup: func() *Entry { 577 | e := NewEntry() 578 | e.SetEchoMode(EchoModeNoEcho) 579 | e.SetText("Lorem ipsum") 580 | return e 581 | }, 582 | want: ` 583 | 584 | ............... 585 | ............... 586 | ............... 587 | ............... 588 | `, 589 | }, 590 | { 591 | test: "Password", 592 | size: image.Point{15, 5}, 593 | setup: func() *Entry { 594 | e := NewEntry() 595 | e.SetEchoMode(EchoModePassword) 596 | e.SetText("Lorem ipsum") 597 | return e 598 | }, 599 | want: ` 600 | *********** 601 | ............... 602 | ............... 603 | ............... 604 | ............... 605 | `, 606 | }, 607 | } 608 | 609 | func TestEchoMode(t *testing.T) { 610 | for _, tt := range echoModeTests { 611 | tt := tt 612 | t.Run(tt.test, func(t *testing.T) { 613 | var surface *TestSurface 614 | if tt.size.X == 0 && tt.size.Y == 0 { 615 | surface = NewTestSurface(10, 5) 616 | } else { 617 | surface = NewTestSurface(tt.size.X, tt.size.Y) 618 | } 619 | 620 | painter := NewPainter(surface, NewTheme()) 621 | painter.Repaint(tt.setup()) 622 | 623 | if diff := surfaceEquals(surface, tt.want); diff != "" { 624 | t.Error(diff) 625 | } 626 | }) 627 | } 628 | } 629 | 630 | func repeatKeyEvent(e *Entry, ev KeyEvent, n int) { 631 | for i := 0; i < n; i++ { 632 | e.OnKeyEvent(ev) 633 | } 634 | } 635 | -------------------------------------------------------------------------------- /box_test.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "image" 7 | "testing" 8 | 9 | "github.com/google/go-cmp/cmp" 10 | ) 11 | 12 | var drawBoxTests = []struct { 13 | test string 14 | size image.Point 15 | setup func() *Box 16 | want string 17 | }{ 18 | { 19 | test: "Empty horizontal box", 20 | setup: func() *Box { 21 | b := NewHBox() 22 | b.SetBorder(true) 23 | return b 24 | }, 25 | want: ` 26 | ┌────────┐ 27 | │ │ 28 | │ │ 29 | │ │ 30 | └────────┘ 31 | `, 32 | }, 33 | { 34 | test: "Horizontal box containing one widget", 35 | setup: func() *Box { 36 | b := NewHBox(NewLabel("test")) 37 | b.SetBorder(true) 38 | return b 39 | }, 40 | want: ` 41 | ┌────────┐ 42 | │test │ 43 | │ │ 44 | │ │ 45 | └────────┘ 46 | `, 47 | }, 48 | { 49 | test: "Horizontal box containing multiple widgets", 50 | setup: func() *Box { 51 | b := NewHBox(NewLabel("test"), NewLabel("foo")) 52 | b.SetBorder(true) 53 | return b 54 | }, 55 | want: ` 56 | ┌────────┐ 57 | │testfoo │ 58 | │ │ 59 | │ │ 60 | └────────┘ 61 | `, 62 | }, 63 | { 64 | test: "Empty vertical box", 65 | setup: func() *Box { 66 | b := NewVBox() 67 | b.SetBorder(true) 68 | return b 69 | }, 70 | want: ` 71 | ┌────────┐ 72 | │ │ 73 | │ │ 74 | │ │ 75 | └────────┘ 76 | `, 77 | }, 78 | { 79 | test: "Vertical box containing one widget", 80 | setup: func() *Box { 81 | b := NewVBox(NewLabel("test")) 82 | b.SetBorder(true) 83 | return b 84 | }, 85 | want: ` 86 | ┌────────┐ 87 | │test │ 88 | │ │ 89 | │ │ 90 | └────────┘ 91 | `, 92 | }, 93 | { 94 | test: "Vertical box containing multiple widgets", 95 | setup: func() *Box { 96 | b := NewVBox(NewLabel("test"), NewLabel("foo")) 97 | b.SetBorder(true) 98 | return b 99 | }, 100 | want: ` 101 | ┌────────┐ 102 | │test │ 103 | │ │ 104 | │foo │ 105 | └────────┘ 106 | `, 107 | }, 108 | { 109 | test: "Horizontally centered box", 110 | size: image.Point{32, 5}, 111 | setup: func() *Box { 112 | nested := NewVBox(NewLabel("test")) 113 | nested.SetBorder(true) 114 | 115 | b := NewHBox(NewSpacer(), nested, NewSpacer()) 116 | b.SetBorder(true) 117 | return b 118 | }, 119 | want: ` 120 | ┌──────────────────────────────┐ 121 | │ ┌────┐ │ 122 | │ │test│ │ 123 | │ └────┘ │ 124 | └──────────────────────────────┘ 125 | `, 126 | }, 127 | { 128 | test: "Two columns", 129 | size: image.Point{32, 10}, 130 | setup: func() *Box { 131 | first := NewVBox(NewLabel("test")) 132 | first.SetBorder(true) 133 | 134 | second := NewVBox(NewLabel("test")) 135 | second.SetBorder(true) 136 | 137 | third := NewHBox(first, second) 138 | third.SetBorder(true) 139 | 140 | return third 141 | }, 142 | want: ` 143 | ┌──────────────────────────────┐ 144 | │┌─────────────┐┌─────────────┐│ 145 | ││test ││test ││ 146 | ││ ││ ││ 147 | ││ ││ ││ 148 | ││ ││ ││ 149 | ││ ││ ││ 150 | ││ ││ ││ 151 | │└─────────────┘└─────────────┘│ 152 | └──────────────────────────────┘ 153 | `, 154 | }, 155 | { 156 | test: "Two rows with two columns", 157 | size: image.Point{32, 22}, 158 | setup: func() *Box { 159 | col0 := NewVBox(NewLabel("test")) 160 | col0.SetBorder(true) 161 | col1 := NewVBox(NewLabel("test")) 162 | col1.SetBorder(true) 163 | 164 | row0 := NewHBox(col0, col1) 165 | row0.SetBorder(true) 166 | 167 | col2 := NewVBox(NewLabel("test")) 168 | col2.SetBorder(true) 169 | col3 := NewVBox(NewLabel("test")) 170 | col3.SetBorder(true) 171 | 172 | row1 := NewHBox(col2, col3) 173 | row1.SetBorder(true) 174 | 175 | b := NewVBox(row0, row1) 176 | b.SetBorder(true) 177 | 178 | return b 179 | }, 180 | want: ` 181 | ┌──────────────────────────────┐ 182 | │┌────────────────────────────┐│ 183 | ││┌────────────┐┌────────────┐││ 184 | │││test ││test │││ 185 | │││ ││ │││ 186 | │││ ││ │││ 187 | │││ ││ │││ 188 | │││ ││ │││ 189 | │││ ││ │││ 190 | ││└────────────┘└────────────┘││ 191 | │└────────────────────────────┘│ 192 | │┌────────────────────────────┐│ 193 | ││┌────────────┐┌────────────┐││ 194 | │││test ││test │││ 195 | │││ ││ │││ 196 | │││ ││ │││ 197 | │││ ││ │││ 198 | │││ ││ │││ 199 | │││ ││ │││ 200 | ││└────────────┘└────────────┘││ 201 | │└────────────────────────────┘│ 202 | └──────────────────────────────┘ 203 | `, 204 | }, 205 | { 206 | test: "Maximum/Preferred/Preferred", 207 | size: image.Point{32, 24}, 208 | setup: func() *Box { 209 | edit0 := NewLabel("") 210 | edit0.SetText("test\ntesting\nfoo\nbar") 211 | row0 := NewVBox(edit0) 212 | row0.SetSizePolicy(Preferred, Maximum) 213 | row0.SetBorder(true) 214 | 215 | edit1 := NewLabel("") 216 | edit1.SetText("test\ntesting\nfoo\nbar") 217 | row1 := NewVBox(edit1) 218 | row1.SetSizePolicy(Preferred, Preferred) 219 | row1.SetBorder(true) 220 | 221 | edit2 := NewLabel("") 222 | edit2.SetText("foo") 223 | row2 := NewVBox(edit2) 224 | row2.SetSizePolicy(Preferred, Preferred) 225 | row2.SetBorder(true) 226 | 227 | b := NewVBox(row0, row1, row2) 228 | b.SetBorder(true) 229 | 230 | return b 231 | }, 232 | want: ` 233 | ┌──────────────────────────────┐ 234 | │┌────────────────────────────┐│ 235 | ││test ││ 236 | ││testing ││ 237 | ││foo ││ 238 | ││bar ││ 239 | │└────────────────────────────┘│ 240 | │┌────────────────────────────┐│ 241 | ││test ││ 242 | ││testing ││ 243 | ││foo ││ 244 | ││bar ││ 245 | ││ ││ 246 | ││ ││ 247 | │└────────────────────────────┘│ 248 | │┌────────────────────────────┐│ 249 | ││foo ││ 250 | ││ ││ 251 | ││ ││ 252 | ││ ││ 253 | ││ ││ 254 | ││ ││ 255 | │└────────────────────────────┘│ 256 | └──────────────────────────────┘ 257 | `, 258 | }, 259 | { 260 | test: "Box with title", 261 | setup: func() *Box { 262 | b := NewVBox(NewLabel("test")) 263 | b.SetTitle("Title") 264 | b.SetBorder(true) 265 | return b 266 | }, 267 | want: ` 268 | ┌Title───┐ 269 | │test │ 270 | │ │ 271 | │ │ 272 | └────────┘ 273 | `, 274 | }, 275 | { 276 | test: "Box with very long title", 277 | setup: func() *Box { 278 | b := NewVBox(NewLabel("test")) 279 | b.SetTitle("Very long title") 280 | b.SetBorder(true) 281 | return b 282 | }, 283 | want: ` 284 | ┌Very lon┐ 285 | │test │ 286 | │ │ 287 | │ │ 288 | └────────┘ 289 | `, 290 | }, 291 | } 292 | 293 | func TestBox_Draw(t *testing.T) { 294 | for _, tt := range drawBoxTests { 295 | tt := tt 296 | t.Run(tt.test, func(t *testing.T) { 297 | var surface *TestSurface 298 | if tt.size.X == 0 && tt.size.Y == 0 { 299 | surface = NewTestSurface(10, 5) 300 | } else { 301 | surface = NewTestSurface(tt.size.X, tt.size.Y) 302 | } 303 | 304 | painter := NewPainter(surface, NewTheme()) 305 | painter.Repaint(tt.setup()) 306 | 307 | if diff := surfaceEquals(surface, tt.want); diff != "" { 308 | t.Error(diff) 309 | } 310 | }) 311 | } 312 | } 313 | 314 | var styleBoxTests = []struct { 315 | test string 316 | setup func() *Box 317 | theme func() *Theme 318 | wantContents string 319 | wantColors string 320 | wantDecorations string 321 | }{ 322 | { 323 | test: "Red horizontal box", 324 | setup: func() *Box { 325 | b := NewHBox() 326 | b.SetBorder(true) 327 | return b 328 | }, 329 | theme: func() *Theme { 330 | t := NewTheme() 331 | t.SetStyle("box", Style{ 332 | Fg: Color(3), 333 | }) 334 | return t 335 | }, 336 | wantContents: ` 337 | ┌────────┐ 338 | │ │ 339 | │ │ 340 | │ │ 341 | └────────┘ 342 | `, 343 | wantColors: ` 344 | 3333333333 345 | 3333333333 346 | 3333333333 347 | 3333333333 348 | 3333333333 349 | `, 350 | }, 351 | { 352 | test: "red box, styled and unstyled labels", 353 | setup: func() *Box { 354 | unstyled := NewLabel("unstyled") 355 | styled := NewLabel("styled") 356 | styled.SetStyleName("blue") 357 | box := NewVBox(unstyled, styled) 358 | box.SetBorder(true) 359 | return box 360 | }, 361 | theme: func() *Theme { 362 | t := NewTheme() 363 | t.SetStyle("box", Style{ 364 | Fg: Color(3), 365 | Bold: DecorationOn, 366 | }) 367 | t.SetStyle("label.blue", Style{ 368 | Fg: Color(4), 369 | }) 370 | return t 371 | }, 372 | wantContents: ` 373 | ┌────────┐ 374 | │unstyled│ 375 | │ │ 376 | │styled │ 377 | └────────┘ 378 | `, 379 | wantColors: ` 380 | 3333333333 381 | 3333333333 382 | 3333333333 383 | 3444444333 384 | 3333333333 385 | `, 386 | wantDecorations: ` 387 | 2222222222 388 | 2222222222 389 | 2222222222 390 | 2222222222 391 | 2222222222 392 | `, 393 | }, 394 | { 395 | test: "Styled box, labels inherit", 396 | setup: func() *Box { 397 | return NewVBox( 398 | NewLabel("label 1"), 399 | NewLabel("label 2"), 400 | ) 401 | }, 402 | theme: func() *Theme { 403 | t := NewTheme() 404 | t.SetStyle("box", Style{ 405 | Fg: Color(3), 406 | Bold: DecorationOn, 407 | }) 408 | return t 409 | }, 410 | wantContents: ` 411 | label 1 412 | 413 | 414 | label 2 415 | 416 | `, 417 | wantColors: ` 418 | 3333333333 419 | 3333333333 420 | 3333333333 421 | 3333333333 422 | 3333333333 423 | `, 424 | wantDecorations: ` 425 | 2222222222 426 | 2222222222 427 | 2222222222 428 | 2222222222 429 | 2222222222 430 | `, 431 | }, 432 | { 433 | test: "blue box, bold border", 434 | setup: func() *Box { 435 | r := NewVBox( 436 | NewLabel("label 1"), 437 | NewLabel("label 2"), 438 | ) 439 | r.SetBorder(true) 440 | return r 441 | }, 442 | theme: func() *Theme { 443 | t := NewTheme() 444 | t.SetStyle("box", Style{ 445 | Fg: Color(3), 446 | }) 447 | t.SetStyle("box.border", Style{ 448 | Bold: DecorationOn, 449 | }) 450 | return t 451 | }, 452 | wantContents: ` 453 | ┌────────┐ 454 | │label 1 │ 455 | │ │ 456 | │label 2 │ 457 | └────────┘ 458 | `, 459 | wantColors: ` 460 | 3333333333 461 | 3333333333 462 | 3333333333 463 | 3333333333 464 | 3333333333 465 | `, 466 | wantDecorations: ` 467 | 2222222222 468 | 2000000002 469 | 2000000002 470 | 2000000002 471 | 2222222222 472 | `, 473 | }, 474 | } 475 | 476 | func TestBox_Style(t *testing.T) { 477 | for _, tt := range styleBoxTests { 478 | tt := tt 479 | t.Run(tt.test, func(t *testing.T) { 480 | surface := NewTestSurface(10, 5) 481 | painter := NewPainter(surface, tt.theme()) 482 | painter.Repaint(tt.setup()) 483 | 484 | if tt.wantContents != "" && surface.String() != tt.wantContents { 485 | t.Errorf("wrong contents: got = \n%s\n\nwant = \n%s", surface.String(), tt.wantContents) 486 | } 487 | if tt.wantColors != "" && surface.FgColors() != tt.wantColors { 488 | t.Errorf("wrong colors: got = \n%s\n\nwant = \n%s", surface.FgColors(), tt.wantColors) 489 | } 490 | if tt.wantDecorations != "" && surface.Decorations() != tt.wantDecorations { 491 | t.Errorf("wrong decorations: got = \n%s\n\nwant = \n%s", surface.Decorations(), tt.wantDecorations) 492 | } 493 | }) 494 | } 495 | } 496 | 497 | func TestBox_IsFocused(t *testing.T) { 498 | btn := NewButton("Test box focus") 499 | box := NewVBox(btn) 500 | want := false 501 | if box.IsFocused() != want { 502 | t.Errorf("got = \n%t\n\nwant = \n%t", box.IsFocused(), want) 503 | } 504 | btn.SetFocused(true) 505 | want = true 506 | if box.IsFocused() != want { 507 | t.Errorf("got = \n%t\n\nwant = \n%t", box.IsFocused(), want) 508 | } 509 | } 510 | 511 | var insertWidgetTests = []struct { 512 | test string 513 | size image.Point 514 | setup func() *Box 515 | index int 516 | want string 517 | }{ 518 | { 519 | test: "Insert at beginning of box", 520 | index: 0, 521 | want: ` 522 | ┌──────────────────┐ 523 | │Insertion │ 524 | │ │ 525 | │Test 0 │ 526 | │ │ 527 | │Test 1 │ 528 | │ │ 529 | │Test 2 │ 530 | │ │ 531 | └──────────────────┘ 532 | `, 533 | }, 534 | { 535 | test: "Insert in the middle", 536 | index: 1, 537 | want: ` 538 | ┌──────────────────┐ 539 | │Test 0 │ 540 | │ │ 541 | │Insertion │ 542 | │ │ 543 | │Test 1 │ 544 | │ │ 545 | │Test 2 │ 546 | │ │ 547 | └──────────────────┘ 548 | `, 549 | }, 550 | { 551 | test: "Slice index out of range", 552 | index: 5, 553 | want: ` 554 | ┌──────────────────┐ 555 | │Test 0 │ 556 | │ │ 557 | │ │ 558 | │Test 1 │ 559 | │ │ 560 | │ │ 561 | │Test 2 │ 562 | │ │ 563 | └──────────────────┘ 564 | `, 565 | }, 566 | { 567 | test: "Append widget", 568 | index: 3, 569 | want: ` 570 | ┌──────────────────┐ 571 | │Test 0 │ 572 | │ │ 573 | │Test 1 │ 574 | │ │ 575 | │Test 2 │ 576 | │ │ 577 | │Insertion │ 578 | │ │ 579 | └──────────────────┘ 580 | `, 581 | }, 582 | } 583 | 584 | func TestBox_Insert(t *testing.T) { 585 | for _, tt := range insertWidgetTests { 586 | tt := tt 587 | t.Run(tt.test, func(t *testing.T) { 588 | surface := NewTestSurface(20, 10) 589 | painter := NewPainter(surface, NewTheme()) 590 | 591 | label0 := NewLabel("Test 0") 592 | label1 := NewLabel("Test 1") 593 | label2 := NewLabel("Test 2") 594 | 595 | b := NewVBox(label0, label1, label2) 596 | 597 | insertLabel := NewLabel("Insertion") 598 | b.Insert(tt.index, insertLabel) 599 | 600 | b.SetBorder(true) 601 | 602 | painter.Repaint(b) 603 | 604 | if diff := surfaceEquals(surface, tt.want); diff != "" { 605 | t.Error(diff) 606 | } 607 | }) 608 | } 609 | } 610 | 611 | func TestBox_Prepend(t *testing.T) { 612 | want := ` 613 | ┌──────────────────┐ 614 | │Prepend │ 615 | │ │ 616 | │Test 0 │ 617 | │ │ 618 | │Test 1 │ 619 | │ │ 620 | │Test 2 │ 621 | │ │ 622 | └──────────────────┘ 623 | ` 624 | surface := NewTestSurface(20, 10) 625 | painter := NewPainter(surface, NewTheme()) 626 | 627 | label0 := NewLabel("Test 0") 628 | label1 := NewLabel("Test 1") 629 | label2 := NewLabel("Test 2") 630 | 631 | b := NewVBox(label0, label1, label2) 632 | 633 | label := NewLabel("Prepend") 634 | b.Prepend(label) 635 | 636 | b.SetBorder(true) 637 | 638 | painter.Repaint(b) 639 | 640 | if diff := surfaceEquals(surface, want); diff != "" { 641 | t.Error(diff) 642 | } 643 | } 644 | 645 | func TestBox_Remove(t *testing.T) { 646 | want := ` 647 | ┌──────────────────┐ 648 | │Test 0 │ 649 | │ │ 650 | │Test 2 │ 651 | │ │ 652 | └──────────────────┘ 653 | ` 654 | surface := NewTestSurface(20, 6) 655 | painter := NewPainter(surface, NewTheme()) 656 | 657 | label0 := NewLabel("Test 0") 658 | label1 := NewLabel("Test 1") 659 | label2 := NewLabel("Test 2") 660 | 661 | b := NewVBox(label0, label1, label2) 662 | 663 | b.Remove(1) 664 | b.Remove(10) 665 | 666 | b.SetBorder(true) 667 | 668 | painter.Repaint(b) 669 | 670 | if diff := surfaceEquals(surface, want); diff != "" { 671 | t.Error(diff) 672 | } 673 | } 674 | 675 | var wideTests = []struct { 676 | name string 677 | setup func() Widget 678 | want string 679 | }{ 680 | { 681 | name: "Maximum on right", 682 | setup: func() Widget { 683 | bang := NewLabel("!") 684 | bang.SetSizePolicy(Maximum, Maximum) 685 | 686 | w := NewHBox( 687 | bang, 688 | NewHBox( 689 | NewLabel("hello"), 690 | NewSpacer(), 691 | NewLabel("world"), 692 | ), 693 | ) 694 | return w 695 | }, 696 | want: ` 697 | !hello world 698 | `, 699 | }, 700 | { 701 | name: "Unnested", 702 | setup: func() Widget { 703 | bang := NewLabel("!") 704 | bang.SetSizePolicy(Preferred, Preferred) 705 | 706 | w := NewHBox( 707 | bang, 708 | NewLabel("hello"), 709 | NewSpacer(), 710 | NewLabel("world"), 711 | ) 712 | return w 713 | }, 714 | want: ` 715 | !hello world 716 | `, 717 | }, 718 | } 719 | 720 | func TestBox_Wide(t *testing.T) { 721 | for _, tt := range wideTests { 722 | tt := tt 723 | t.Run(tt.name, func(t *testing.T) { 724 | // Reproduce https://github.com/cceckman/discoirc/issues/18 725 | surface := NewTestSurface(140, 1) 726 | p := NewPainter(surface, NewTheme()) 727 | 728 | p.Repaint(tt.setup()) 729 | 730 | if got := surface.String(); !cmp.Equal(got, tt.want) { 731 | t.Error("unexpected contents:") 732 | t.Error("got:") 733 | 734 | g := bufio.NewScanner(bytes.NewBufferString(got)) 735 | for g.Scan() { 736 | t.Errorf("%q", g.Text()) 737 | } 738 | 739 | t.Error("want:") 740 | w := bufio.NewScanner(bytes.NewBufferString(tt.want)) 741 | for w.Scan() { 742 | t.Errorf("%q", w.Text()) 743 | } 744 | } 745 | 746 | }) 747 | } 748 | } 749 | --------------------------------------------------------------------------------