From be3cc6d20d40532cee0b3ff88d4a2e5299e41691 Mon Sep 17 00:00:00 2001 From: Igor German Date: Tue, 16 Apr 2019 14:57:06 +0300 Subject: [PATCH] Tree widget --- README.md | 1 + _examples/tree.go | 127 ++++++++++++++++++++++++ go.mod | 1 - go.sum | 2 - symbols.go | 3 + theme.go | 13 +++ widgets/tree.go | 242 ++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 386 insertions(+), 3 deletions(-) create mode 100644 _examples/tree.go create mode 100644 widgets/tree.go diff --git a/README.md b/README.md index 8ea1ff2..38ab49a 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ func main() { - [Gauge](./_examples/gauge.go) - [Image](./_examples/image.go) - [List](./_examples/list.go) +- [Tree](./_examples/tree.go) - [Paragraph](./_examples/paragraph.go) - [PieChart](./_examples/piechart.go) - [Plot](./_examples/plot.go) (for scatterplots and linecharts) diff --git a/_examples/tree.go b/_examples/tree.go new file mode 100644 index 0000000..18753c9 --- /dev/null +++ b/_examples/tree.go @@ -0,0 +1,127 @@ +/// +build ignore + +package main + +import ( + "log" + + ui "github.com/gizak/termui/v3" + "github.com/gizak/termui/v3/widgets" +) + +type nodeValue string + +func (nv nodeValue) String() string { + return string(nv) +} + +func main() { + if err := ui.Init(); err != nil { + log.Fatalf("failed to initialize termui: %v", err) + } + defer ui.Close() + + nodes := []*widgets.TreeNode{ + { + Value: nodeValue("Key 1"), + Nodes: []*widgets.TreeNode{ + { + Value: nodeValue("Key 1.1"), + Nodes: []*widgets.TreeNode{ + { + Value: nodeValue("Key 1.1.1"), + Nodes: nil, + }, + { + Value: nodeValue("Key 1.1.2"), + Nodes: nil, + }, + }, + }, + { + Value: nodeValue("Key 1.2"), + Nodes: nil, + }, + }, + }, + { + Value: nodeValue("Key 2"), + Nodes: []*widgets.TreeNode{ + { + Value: nodeValue("Key 2.1"), + Nodes: nil, + }, + { + Value: nodeValue("Key 2.2"), + Nodes: nil, + }, + { + Value: nodeValue("Key 2.3"), + Nodes: nil, + }, + }, + }, + { + Value: nodeValue("Key 3"), + Nodes: nil, + }, + } + + l := widgets.NewTree() + l.TextStyle = ui.NewStyle(ui.ColorYellow) + l.WrapText = false + l.Nodes = nodes + + x, y := ui.TerminalDimensions() + + l.SetRect(0, 0, x, y) + + ui.Render(l) + + previousKey := "" + uiEvents := ui.PollEvents() + for { + e := <-uiEvents + switch e.ID { + case "q", "": + return + case "j", "": + l.ScrollDown() + case "k", "": + l.ScrollUp() + case "": + l.ScrollHalfPageDown() + case "": + l.ScrollHalfPageUp() + case "": + l.ScrollPageDown() + case "": + l.ScrollPageUp() + case "g": + if previousKey == "g" { + l.ScrollTop() + } + case "": + l.ScrollTop() + case "": + l.ToggleExpand() + case "G", "": + l.ScrollBottom() + case "E": + l.ExpandAll() + case "C": + l.CollapseAll() + case "": + x, y := ui.TerminalDimensions() + l.SetRect(0, 0, x, y) + } + + if previousKey == "g" { + previousKey = "" + } else { + previousKey = e.ID + } + + ui.Render(l) + } +} diff --git a/go.mod b/go.mod index ef0b95f..89c979e 100644 --- a/go.mod +++ b/go.mod @@ -5,5 +5,4 @@ require ( github.com/mattn/go-runewidth v0.0.2 github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d - golang.org/x/arch v0.0.0-20181203225421-5a4828bb7045 // indirect ) diff --git a/go.sum b/go.sum index 121df84..495684f 100644 --- a/go.sum +++ b/go.sum @@ -6,5 +6,3 @@ github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzC github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d h1:x3S6kxmy49zXVVyhcnrFqxvNVCBPb2KZ9hV2RBdS840= github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d/go.mod h1:IuKpRQcYE1Tfu+oAQqaLisqDeXgjyyltCfsaoYN18NQ= -golang.org/x/arch v0.0.0-20181203225421-5a4828bb7045 h1:Pn8fQdvx+z1avAi7fdM2kRYWQNxGlavNDSyzrQg2SsU= -golang.org/x/arch v0.0.0-20181203225421-5a4828bb7045/go.mod h1:cYlCBUl1MsqxdiKgmc4uh7TxZfWSFLOGSRR090WDxt8= diff --git a/symbols.go b/symbols.go index 6db7d1c..c3db260 100644 --- a/symbols.go +++ b/symbols.go @@ -6,6 +6,9 @@ const ( UP_ARROW = '▲' DOWN_ARROW = '▼' + + COLLAPSED = '+' + EXPANDED = '−' ) var ( diff --git a/theme.go b/theme.go index c9a5094..8d75174 100644 --- a/theme.go +++ b/theme.go @@ -33,6 +33,7 @@ type RootTheme struct { Gauge GaugeTheme Plot PlotTheme List ListTheme + Tree TreeTheme Paragraph ParagraphTheme PieChart PieChartTheme Sparkline SparklineTheme @@ -66,6 +67,12 @@ type ListTheme struct { Text Style } +type TreeTheme struct { + Text Style + Collapse rune + Expand rune +} + type ParagraphTheme struct { Text Style } @@ -122,6 +129,12 @@ var Theme = RootTheme{ Text: NewStyle(ColorWhite), }, + Tree: TreeTheme{ + Text: NewStyle(ColorWhite), + Collapse: COLLAPSED, + Expand: EXPANDED, + }, + StackedBarChart: StackedBarChartTheme{ Bars: StandardColors, Nums: StandardStyles, diff --git a/widgets/tree.go b/widgets/tree.go new file mode 100644 index 0000000..6217a68 --- /dev/null +++ b/widgets/tree.go @@ -0,0 +1,242 @@ +package widgets + +import ( + "fmt" + "image" + "strings" + + . "github.com/gizak/termui/v3" + rw "github.com/mattn/go-runewidth" +) + +const treeIndent = " " + +// TreeNode is a tree node. +type TreeNode struct { + Value fmt.Stringer + Expanded bool + Nodes []*TreeNode + + //level stores the node level in the tree. + level int +} + +// TreeWalkFn is a function used for walking a Tree. +// To interrupt the walking process function should return false. +type TreeWalkFn func(*TreeNode) bool + +func (self *TreeNode) parseStyles(style Style) []Cell { + var sb strings.Builder + if len(self.Nodes) == 0 { + sb.WriteString(strings.Repeat(treeIndent, self.level+1)) + } else { + sb.WriteString(strings.Repeat(treeIndent, self.level)) + if self.Expanded { + sb.WriteRune(Theme.Tree.Expand) + } else { + sb.WriteRune(Theme.Tree.Collapse) + } + sb.WriteByte(' ') + } + sb.WriteString(self.Value.String()) + return ParseStyles(sb.String(), style) +} + +// Tree is a tree widget. +type Tree struct { + Block + Nodes []*TreeNode + TextStyle Style + SelectedRowStyle Style + WrapText bool + SelectedRow int + topRow int + + //rows is flatten nodes for rendering. + rows []*TreeNode +} + +// NewTree creates a new Tree widget. +func NewTree() *Tree { + return &Tree{ + Block: *NewBlock(), + TextStyle: Theme.Tree.Text, + SelectedRowStyle: Theme.Tree.Text, + WrapText: true, + } +} + +func (self *Tree) prepareNodes() { + self.rows = self.rows[:0] + for _, node := range self.Nodes { + self.prepareNode(node, 0) + } +} + +func (self *Tree) prepareNode(node *TreeNode, level int) { + self.rows = append(self.rows, node) + node.level = level + + if node.Expanded { + for _, n := range node.Nodes { + self.prepareNode(n, level+1) + } + } +} + +func (self *Tree) Walk(fn TreeWalkFn) { + for _, n := range self.Nodes { + if !self.walk(n, fn) { + break + } + } +} + +func (self *Tree) walk(n *TreeNode, fn TreeWalkFn) bool { + if !fn(n) { + return false + } + + for _, node := range n.Nodes { + if !self.walk(node, fn) { + return false + } + } + + return true +} + +func (self *Tree) Draw(buf *Buffer) { + self.Block.Draw(buf) + self.prepareNodes() + + point := self.Inner.Min + + // adjusts view into widget + if self.SelectedRow >= self.Inner.Dy()+self.topRow { + self.topRow = self.SelectedRow - self.Inner.Dy() + 1 + } else if self.SelectedRow < self.topRow { + self.topRow = self.SelectedRow + } + + // draw rows + for row := self.topRow; row < len(self.rows) && point.Y < self.Inner.Max.Y; row++ { + cells := self.rows[row].parseStyles(self.TextStyle) + if self.WrapText { + cells = WrapCells(cells, uint(self.Inner.Dx())) + } + for j := 0; j < len(cells) && point.Y < self.Inner.Max.Y; j++ { + style := cells[j].Style + if row == self.SelectedRow { + style = self.SelectedRowStyle + } + if point.X+1 == self.Inner.Max.X+1 && len(cells) > self.Inner.Dx() { + buf.SetCell(NewCell(ELLIPSES, style), point.Add(image.Pt(-1, 0))) + } else { + buf.SetCell(NewCell(cells[j].Rune, style), point) + point = point.Add(image.Pt(rw.RuneWidth(cells[j].Rune), 0)) + } + } + point = image.Pt(self.Inner.Min.X, point.Y+1) + } + + // draw UP_ARROW if needed + if self.topRow > 0 { + buf.SetCell( + NewCell(UP_ARROW, NewStyle(ColorWhite)), + image.Pt(self.Inner.Max.X-1, self.Inner.Min.Y), + ) + } + + // draw DOWN_ARROW if needed + if len(self.rows) > int(self.topRow)+self.Inner.Dy() { + buf.SetCell( + NewCell(DOWN_ARROW, NewStyle(ColorWhite)), + image.Pt(self.Inner.Max.X-1, self.Inner.Max.Y-1), + ) + } +} + +// ScrollAmount scrolls by amount given. If amount is < 0, then scroll up. +// There is no need to set self.topRow, as this will be set automatically when drawn, +// since if the selected item is off screen then the topRow variable will change accordingly. +func (self *Tree) ScrollAmount(amount int) { + if len(self.rows)-int(self.SelectedRow) <= amount { + self.SelectedRow = len(self.rows) - 1 + } else if int(self.SelectedRow)+amount < 0 { + self.SelectedRow = 0 + } else { + self.SelectedRow += amount + } +} + +func (self *Tree) ScrollUp() { + self.ScrollAmount(-1) +} + +func (self *Tree) ScrollDown() { + self.ScrollAmount(1) +} + +func (self *Tree) ScrollPageUp() { + // If an item is selected below top row, then go to the top row. + if self.SelectedRow > self.topRow { + self.SelectedRow = self.topRow + } else { + self.ScrollAmount(-self.Inner.Dy()) + } +} + +func (self *Tree) ScrollPageDown() { + self.ScrollAmount(self.Inner.Dy()) +} + +func (self *Tree) ScrollHalfPageUp() { + self.ScrollAmount(-int(FloorFloat64(float64(self.Inner.Dy()) / 2))) +} + +func (self *Tree) ScrollHalfPageDown() { + self.ScrollAmount(int(FloorFloat64(float64(self.Inner.Dy()) / 2))) +} + +func (self *Tree) ScrollTop() { + self.SelectedRow = 0 +} + +func (self *Tree) ScrollBottom() { + self.SelectedRow = len(self.rows) - 1 +} + +func (self *Tree) Collapse() { + self.rows[self.SelectedRow].Expanded = false +} + +func (self *Tree) Expand() { + node := self.rows[self.SelectedRow] + if len(node.Nodes) > 0 { + self.rows[self.SelectedRow].Expanded = true + } +} + +func (self *Tree) ToggleExpand() { + node := self.rows[self.SelectedRow] + if len(node.Nodes) > 0 { + node.Expanded = !node.Expanded + } +} + +func (self *Tree) ExpandAll() { + self.Walk(func(n *TreeNode) bool { + if len(n.Nodes) > 0 { + n.Expanded = true + } + return true + }) +} + +func (self *Tree) CollapseAll() { + self.Walk(func(n *TreeNode) bool { + n.Expanded = false + return true + }) +}