diff --git a/README.md b/README.md index 1ebfc01..89b866c 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Among these components are: - __Input forms__ (include __input/password fields__, __drop-down selections__, __checkboxes__, and __buttons__) - Navigable multi-color __text views__ - Sophisticated navigable __table views__ +- Flexible __tree views__ - Selectable __lists__ - __Grid__, __Flexbox__ and __page layouts__ - Modal __message windows__ @@ -64,6 +65,8 @@ Add your issue here on GitHub. Feel free to get in touch if you have any questio (There are no corresponding tags in the project. I only keep such a history in this README.) +- v0.17 (2018-06-20) + - Added `TreeView`. - v0.15 (2018-05-02) - `Flex` and `Grid` don't clear their background per default, thus allowing for custom modals. See the [Wiki](https://github.com/rivo/tview/wiki/Modal) for an example. - v0.14 (2018-04-13) diff --git a/demos/presentation/main.go b/demos/presentation/main.go index dabea18..c75fdc0 100644 --- a/demos/presentation/main.go +++ b/demos/presentation/main.go @@ -40,6 +40,7 @@ func main() { TextView1, TextView2, Table, + TreeView, Flex, Grid, Colors, @@ -58,12 +59,14 @@ func main() { pages := tview.NewPages() previousSlide := func() { currentSlide = (currentSlide - 1 + len(slides)) % len(slides) - info.Highlight(strconv.Itoa(currentSlide)) + info.Highlight(strconv.Itoa(currentSlide)). + ScrollToHighlight() pages.SwitchToPage(strconv.Itoa(currentSlide)) } nextSlide := func() { currentSlide = (currentSlide + 1) % len(slides) - info.Highlight(strconv.Itoa(currentSlide)) + info.Highlight(strconv.Itoa(currentSlide)). + ScrollToHighlight() pages.SwitchToPage(strconv.Itoa(currentSlide)) } for index, slide := range slides { diff --git a/demos/presentation/textview.go b/demos/presentation/textview.go index c0ba862..65f2d2c 100644 --- a/demos/presentation/textview.go +++ b/demos/presentation/textview.go @@ -68,7 +68,7 @@ const textView2 = `[green]package[white] main [yellow]SetDoneFunc[white]([yellow]func[white](key tcell.Key) { highlights := ["2"]textView[""].[yellow]GetHighlights[white]() hasHighlights := [yellow]len[white](highlights) > [red]0 -[white] [yellow]switch[white] key { + [yellow]switch[white] key { [yellow]case[white] tcell.KeyEnter: [yellow]if[white] hasHighlights { ["3"]textView[""].[yellow]Highlight[white]() @@ -80,14 +80,14 @@ const textView2 = `[green]package[white] main [yellow]if[white] hasHighlights { current, _ := strconv.[yellow]Atoi[white](highlights[[red]0[white]]) next := (current + [red]1[white]) % [red]9 -[white] ["5"]textView[""].[yellow]Highlight[white](strconv.[yellow]Itoa[white](next)). + ["5"]textView[""].[yellow]Highlight[white](strconv.[yellow]Itoa[white](next)). [yellow]ScrollToHighlight[white]() } [yellow]case[white] tcell.KeyBacktab: [yellow]if[white] hasHighlights { current, _ := strconv.[yellow]Atoi[white](highlights[[red]0[white]]) next := (current - [red]1[white] + [red]9[white]) % [red]9 -[white] ["6"]textView[""].[yellow]Highlight[white](strconv.[yellow]Itoa[white](next)). + ["6"]textView[""].[yellow]Highlight[white](strconv.[yellow]Itoa[white](next)). [yellow]ScrollToHighlight[white]() } } diff --git a/demos/presentation/treeview.go b/demos/presentation/treeview.go new file mode 100644 index 0000000..68349cd --- /dev/null +++ b/demos/presentation/treeview.go @@ -0,0 +1,149 @@ +package main + +import ( + "strings" + + "github.com/gdamore/tcell" + "github.com/rivo/tview" +) + +const treeAllCode = `[green]package[white] main + +[green]import[white] [red]"github.com/rivo/tview"[white] + +[green]func[white] [yellow]main[white]() { + $$$ + + root := tview.[yellow]NewTreeNode[white]([red]"Root"[white]). + [yellow]AddChild[white](tview.[yellow]NewTreeNode[white]([red]"First child"[white]). + [yellow]AddChild[white](tview.[yellow]NewTreeNode[white]([red]"Grandchild A"[white])). + [yellow]AddChild[white](tview.[yellow]NewTreeNode[white]([red]"Grandchild B"[white]))). + [yellow]AddChild[white](tview.[yellow]NewTreeNode[white]([red]"Second child"[white]). + [yellow]AddChild[white](tview.[yellow]NewTreeNode[white]([red]"Grandchild C"[white])). + [yellow]AddChild[white](tview.[yellow]NewTreeNode[white]([red]"Grandchild D"[white]))). + [yellow]AddChild[white](tview.[yellow]NewTreeNode[white]([red]"Third child"[white])) + + tree.[yellow]SetRoot[white](root). + [yellow]SetCurrentNode[white](root) + + tview.[yellow]NewApplication[white](). + [yellow]SetRoot[white](tree, true). + [yellow]Run[white]() +}` + +const treeBasicCode = `tree := tview.[yellow]NewTreeView[white]()` + +const treeTopLevelCode = `tree := tview.[yellow]NewTreeView[white](). + [yellow]SetTopLevel[white]([red]1[white])` + +const treeAlignCode = `tree := tview.[yellow]NewTreeView[white](). + [yellow]SetAlign[white](true)` + +const treePrefixCode = `tree := tview.[yellow]NewTreeView[white](). + [yellow]SetGraphics[white](false). + [yellow]SetTopLevel[white]([red]1[white]). + [yellow]SetPrefixes[white]([][green]string[white]{ + [red]"[red[]* "[white], + [red]"[darkcyan[]- "[white], + [red]"[darkmagenta[]- "[white], + })` + +type node struct { + text string + expand bool + selected func() + children []*node +} + +var ( + tree = tview.NewTreeView() + treeNextSlide func() + treeCode = tview.NewTextView().SetWrap(false).SetDynamicColors(true) +) + +var rootNode = &node{ + text: "Root", + children: []*node{ + {text: "Expand all", selected: func() { tree.GetRoot().ExpandAll() }}, + {text: "Collapse all", selected: func() { + for _, child := range tree.GetRoot().GetChildren() { + child.CollapseAll() + } + }}, + {text: "Hide root node", expand: true, children: []*node{ + {text: "Tree list starts one level down"}, + {text: "Works better for lists where no top node is needed"}, + {text: "Switch to this layout", selected: func() { + tree.SetAlign(false).SetTopLevel(1).SetGraphics(true).SetPrefixes(nil) + treeCode.SetText(strings.Replace(treeAllCode, "$$$", treeTopLevelCode, -1)) + }}, + }}, + {text: "Align node text", expand: true, children: []*node{ + {text: "For trees that are similar to lists"}, + {text: "Hierarchy shown only in line drawings"}, + {text: "Switch to this layout", selected: func() { + tree.SetAlign(true).SetTopLevel(0).SetGraphics(true).SetPrefixes(nil) + treeCode.SetText(strings.Replace(treeAllCode, "$$$", treeAlignCode, -1)) + }}, + }}, + {text: "Prefixes", expand: true, children: []*node{ + {text: "Best for hierarchical bullet point lists"}, + {text: "You can define your own prefixes per level"}, + {text: "Switch to this layout", selected: func() { + tree.SetAlign(false).SetTopLevel(1).SetGraphics(false).SetPrefixes([]string{"[red]* ", "[darkcyan]- ", "[darkmagenta]- "}) + treeCode.SetText(strings.Replace(treeAllCode, "$$$", treePrefixCode, -1)) + }}, + }}, + {text: "Basic tree with graphics", expand: true, children: []*node{ + {text: "Lines illustrate hierarchy"}, + {text: "Basic indentation"}, + {text: "Switch to this layout", selected: func() { + tree.SetAlign(false).SetTopLevel(0).SetGraphics(true).SetPrefixes(nil) + treeCode.SetText(strings.Replace(treeAllCode, "$$$", treeBasicCode, -1)) + }}, + }}, + {text: "Next slide", selected: func() { treeNextSlide() }}, + }} + +// TreeView demonstrates the tree view. +func TreeView(nextSlide func()) (title string, content tview.Primitive) { + treeNextSlide = nextSlide + tree.SetBorder(true). + SetTitle("TreeView") + + // Add nodes. + var add func(target *node) *tview.TreeNode + add = func(target *node) *tview.TreeNode { + node := tview.NewTreeNode(target.text). + SetSelectable(target.expand || target.selected != nil). + SetExpanded(target == rootNode). + SetReference(target) + if target.expand { + node.SetColor(tcell.ColorGreen) + } else if target.selected != nil { + node.SetColor(tcell.ColorRed) + } + for _, child := range target.children { + node.AddChild(add(child)) + } + return node + } + root := add(rootNode) + tree.SetRoot(root). + SetCurrentNode(root). + SetSelectedFunc(func(n *tview.TreeNode) { + original := n.GetReference().(*node) + if original.expand { + n.SetExpanded(!n.IsExpanded()) + } else if original.selected != nil { + original.selected() + } + }) + + treeCode.SetText(strings.Replace(treeAllCode, "$$$", treeBasicCode, -1)). + SetBorderPadding(1, 1, 2, 0) + + return "Tree", tview.NewFlex(). + AddItem(tree, 0, 1, true). + AddItem(treeCode, codeWidth, 1, false) +} diff --git a/demos/treeview/main.go b/demos/treeview/main.go new file mode 100644 index 0000000..6657b73 --- /dev/null +++ b/demos/treeview/main.go @@ -0,0 +1,61 @@ +package main + +import ( + "io/ioutil" + "path/filepath" + + "github.com/gdamore/tcell" + "github.com/rivo/tview" +) + +// Show a navigable tree view of the current directory. +func main() { + rootDir := "." + root := tview.NewTreeNode(rootDir). + SetColor(tcell.ColorRed) + tree := tview.NewTreeView(). + SetRoot(root). + SetCurrentNode(root) + + // A helper function which adds the files and directories of the given path + // to the given target node. + add := func(target *tview.TreeNode, path string) { + files, err := ioutil.ReadDir(path) + if err != nil { + panic(err) + } + for _, file := range files { + node := tview.NewTreeNode(file.Name()). + SetReference(filepath.Join(path, file.Name())). + SetSelectable(file.IsDir()) + if file.IsDir() { + node.SetColor(tcell.ColorGreen) + } + target.AddChild(node) + } + } + + // Add the current directory to the root node. + add(root, rootDir) + + // If a directory was selected, open it. + tree.SetSelectedFunc(func(node *tview.TreeNode) { + reference := node.GetReference() + if reference == nil { + return // Selecting the root node does nothing. + } + children := node.GetChildren() + if len(children) == 0 { + // Load and show files in this directory. + path := reference.(string) + add(node, path) + } else { + // Collapse if visible, expand if collapsed. + node.SetExpanded(!node.IsExpanded()) + } + }) + + if err := tview.NewApplication().SetRoot(tree, true).Run(); err != nil { + panic(err) + } +} diff --git a/demos/treeview/screenshot.png b/demos/treeview/screenshot.png new file mode 100644 index 0000000..c25fcf5 Binary files /dev/null and b/demos/treeview/screenshot.png differ diff --git a/doc.go b/doc.go index 211185f..1b51a27 100644 --- a/doc.go +++ b/doc.go @@ -7,10 +7,12 @@ Widgets The package implements the following widgets: - - TextView: Scrollable windows that display multi-colored text. Text may also + - TextView: A scrollable window that display multi-colored text. Text may also be highlighted. - - Table: Scrollable display of tabular data. Table cells, rows, or columns may - also be highlighted. + - Table: A scrollable display of tabular data. Table cells, rows, or columns + may also be highlighted. + - TreeView: A scrollable display for hierarchical data. Tree nodes can be + highlighted, collapsed, expanded, and more. - List: A navigable text list with optional keyboard shortcuts. - InputField: One-line input fields to enter text. - DropDown: Drop-down selection fields. @@ -83,7 +85,7 @@ tag is as follows: [::] -Each of the three fields can be left blank and trailing fields can be ommitted. +Each of the three fields can be left blank and trailing fields can be omitted. (Empty square brackets "[]", however, are not considered color tags.) Colors that are not specified will be left unchanged. A field with just a dash ("-") means "reset to default". diff --git a/table.go b/table.go index 10f8399..38f5006 100644 --- a/table.go +++ b/table.go @@ -644,7 +644,6 @@ ColumnLoop: } expWidth := toDistribute * expansion / expansionTotal widths[index] += expWidth - tableWidth += expWidth toDistribute -= expWidth expansionTotal -= expansion } diff --git a/textview.go b/textview.go index d49e7bd..a88791c 100644 --- a/textview.go +++ b/textview.go @@ -104,6 +104,10 @@ type TextView struct { // during re-indexing. Set to -1 if there is no current highlight. fromHighlight, toHighlight int + // The screen space column of the highlight in its first line. Set to -1 if + // there is no current highlight. + posHighlight int + // A set of region IDs that are currently highlighted. highlights map[string]struct{} @@ -171,6 +175,7 @@ func NewTextView() *TextView { align: AlignLeft, wrap: true, textColor: Styles.PrimaryTextColor, + regions: false, dynamicColors: false, } } @@ -503,7 +508,7 @@ func (t *TextView) reindexBuffer(width int) { return // Nothing has changed. We can still use the current index. } t.index = nil - t.fromHighlight, t.toHighlight = -1, -1 + t.fromHighlight, t.toHighlight, t.posHighlight = -1, -1, -1 // If there's no space, there's no index. if width < 1 { @@ -522,8 +527,9 @@ func (t *TextView) reindexBuffer(width int) { colorTags [][]string escapeIndices [][]int ) + strippedStr := str if t.dynamicColors { - colorTagIndices, colorTags, escapeIndices, str, _ = decomposeString(str) + colorTagIndices, colorTags, escapeIndices, strippedStr, _ = decomposeString(str) } // Find all regions in this line. Then remove them. @@ -534,14 +540,18 @@ func (t *TextView) reindexBuffer(width int) { if t.regions { regionIndices = regionPattern.FindAllStringIndex(str, -1) regions = regionPattern.FindAllStringSubmatch(str, -1) - str = regionPattern.ReplaceAllString(str, "") - if !t.dynamicColors { - // We haven't detected escape tags yet. Do it now. - escapeIndices = escapePattern.FindAllStringIndex(str, -1) - str = escapePattern.ReplaceAllString(str, "[$1$2]") - } + strippedStr = regionPattern.ReplaceAllString(strippedStr, "") } + // Find all escape tags in this line. Escape them. + if t.dynamicColors || t.regions { + escapeIndices = escapePattern.FindAllStringIndex(str, -1) + strippedStr = escapePattern.ReplaceAllString(strippedStr, "[$1$2]") + } + + // We don't need the original string anymore for now. + str = strippedStr + // Split the line if required. var splitLines []string if t.wrap && len(str) > 0 { @@ -585,15 +595,53 @@ func (t *TextView) reindexBuffer(width int) { // Shift original position with tags. lineLength := len(splitLine) + remainingLength := lineLength + tagEnd := originalPos + totalTagLength := 0 for { - if colorPos < len(colorTagIndices) && colorTagIndices[colorPos][0] <= originalPos+lineLength { + // Which tag comes next? + nextTag := make([][3]int, 0, 3) + if colorPos < len(colorTagIndices) { + nextTag = append(nextTag, [3]int{colorTagIndices[colorPos][0], colorTagIndices[colorPos][1], 0}) // 0 = color tag. + } + if regionPos < len(regionIndices) { + nextTag = append(nextTag, [3]int{regionIndices[regionPos][0], regionIndices[regionPos][1], 1}) // 1 = region tag. + } + if escapePos < len(escapeIndices) { + nextTag = append(nextTag, [3]int{escapeIndices[escapePos][0], escapeIndices[escapePos][1], 2}) // 2 = escape tag. + } + minPos := -1 + tagIndex := -1 + for index, pair := range nextTag { + if minPos < 0 || pair[0] < minPos { + minPos = pair[0] + tagIndex = index + } + } + + // Is the next tag in range? + if tagIndex < 0 || minPos >= tagEnd+remainingLength { + break // No. We're done with this line. + } + + // Advance. + strippedTagStart := nextTag[tagIndex][0] - originalPos - totalTagLength + tagEnd = nextTag[tagIndex][1] + tagLength := tagEnd - nextTag[tagIndex][0] + if nextTag[tagIndex][2] == 2 { + tagLength = 1 + } + totalTagLength += tagLength + remainingLength = lineLength - (tagEnd - originalPos - totalTagLength) + + // Process the tag. + switch nextTag[tagIndex][2] { + case 0: // Process color tags. - originalPos += colorTagIndices[colorPos][1] - colorTagIndices[colorPos][0] foregroundColor, backgroundColor, attributes = styleFromTag(foregroundColor, backgroundColor, attributes, colorTags[colorPos]) colorPos++ - } else if regionPos < len(regionIndices) && regionIndices[regionPos][0] <= originalPos+lineLength { + case 1: // Process region tags. - originalPos += regionIndices[regionPos][1] - regionIndices[regionPos][0] regionID = regions[regionPos][1] _, highlighted = t.highlights[regionID] @@ -602,23 +650,21 @@ func (t *TextView) reindexBuffer(width int) { line := len(t.index) if t.fromHighlight < 0 { t.fromHighlight, t.toHighlight = line, line + t.posHighlight = runewidth.StringWidth(splitLine[:strippedTagStart]) } else if line > t.toHighlight { t.toHighlight = line } } regionPos++ - } else if escapePos < len(escapeIndices) && escapeIndices[escapePos][0] <= originalPos+lineLength { + case 2: // Process escape tags. - originalPos++ escapePos++ - } else { - break } } // Advance to next line. - originalPos += lineLength + originalPos += lineLength + totalTagLength // Append this line. line.NextPos = originalPos @@ -683,6 +729,16 @@ func (t *TextView) Draw(screen tcell.Screen) { // No, let's move to the start of the highlights. t.lineOffset = t.fromHighlight } + + // If the highlight is too far to the right, move it to the middle. + if t.posHighlight-t.columnOffset > 3*width/4 { + t.columnOffset = t.posHighlight - width/2 + } + + // If the highlight is off-screen on the left, move it on-screen. + if t.posHighlight-t.columnOffset < 0 { + t.columnOffset = t.posHighlight - width/4 + } } t.scrollToHighlights = false diff --git a/treeview.go b/treeview.go new file mode 100644 index 0000000..4f8d446 --- /dev/null +++ b/treeview.go @@ -0,0 +1,663 @@ +package tview + +import ( + "github.com/gdamore/tcell" +) + +// Tree navigation events. +const ( + treeNone int = iota + treeHome + treeEnd + treeUp + treeDown + treePageUp + treePageDown +) + +// TreeNode represents one node in a tree view. +type TreeNode struct { + // The reference object. + reference interface{} + + // This node's child nodes. + children []*TreeNode + + // The item's text. + text string + + // The text color. + color tcell.Color + + // Whether or not this node can be selected. + selectable bool + + // Whether or not this node's children should be displayed. + expanded bool + + // The additional horizontal indent of this node's text. + indent int + + // An optional function which is called when the user selects this node. + selected func() + + // Temporary member variables. + parent *TreeNode // The parent node (nil for the root). + level int // The hierarchy level (0 for the root, 1 for its children, and so on). + graphicsX int // The x-coordinate of the left-most graphics rune. + textX int // The x-coordinate of the first rune of the text. +} + +// NewTreeNode returns a new tree node. +func NewTreeNode(text string) *TreeNode { + return &TreeNode{ + text: text, + color: Styles.PrimaryTextColor, + indent: 2, + expanded: true, + selectable: true, + } +} + +// Walk traverses this node's subtree in depth-first, pre-order (NLR) order and +// calls the provided callback function on each traversed node (which includes +// this node) with the traversed node and its parent node (nil for this node). +// The callback returns whether traversal should continue with the traversed +// node's child nodes (true) or not recurse any deeper (false). +func (n *TreeNode) Walk(callback func(node, parent *TreeNode) bool) *TreeNode { + n.parent = nil + nodes := []*TreeNode{n} + for len(nodes) > 0 { + // Pop the top node and process it. + node := nodes[len(nodes)-1] + nodes = nodes[:len(nodes)-1] + if !callback(node, node.parent) { + // Don't add any children. + continue + } + + // Add children in reverse order. + for index := len(node.children) - 1; index >= 0; index-- { + node.children[index].parent = node + nodes = append(nodes, node.children[index]) + } + } + + return n +} + +// SetReference allows you to store a reference of any type in this node. This +// will allow you to establish a mapping between the TreeView hierarchy and your +// internal tree structure. +func (n *TreeNode) SetReference(reference interface{}) *TreeNode { + n.reference = reference + return n +} + +// GetReference returns this node's reference object. +func (n *TreeNode) GetReference() interface{} { + return n.reference +} + +// SetChildren sets this node's child nodes. +func (n *TreeNode) SetChildren(childNodes []*TreeNode) *TreeNode { + n.children = childNodes + return n +} + +// GetChildren returns this node's children. +func (n *TreeNode) GetChildren() []*TreeNode { + return n.children +} + +// ClearChildren removes all child nodes from this node. +func (n *TreeNode) ClearChildren() *TreeNode { + n.children = nil + return n +} + +// AddChild adds a new child node to this node. +func (n *TreeNode) AddChild(node *TreeNode) *TreeNode { + n.children = append(n.children, node) + return n +} + +// SetSelectable sets a flag indicating whether this node can be selected by +// the user. +func (n *TreeNode) SetSelectable(selectable bool) *TreeNode { + n.selectable = selectable + return n +} + +// SetSelectedFunc sets a function which is called when the user selects this +// node by hitting Enter when it is selected. +func (n *TreeNode) SetSelectedFunc(handler func()) *TreeNode { + n.selected = handler + return n +} + +// SetExpanded sets whether or not this node's child nodes should be displayed. +func (n *TreeNode) SetExpanded(expanded bool) *TreeNode { + n.expanded = expanded + return n +} + +// Expand makes the child nodes of this node appear. +func (n *TreeNode) Expand() *TreeNode { + n.expanded = true + return n +} + +// Collapse makes the child nodes of this node disappear. +func (n *TreeNode) Collapse() *TreeNode { + n.expanded = false + return n +} + +// ExpandAll expands this node and all descendent nodes. +func (n *TreeNode) ExpandAll() *TreeNode { + n.Walk(func(node, parent *TreeNode) bool { + node.expanded = true + return true + }) + return n +} + +// CollapseAll collapses this node and all descendent nodes. +func (n *TreeNode) CollapseAll() *TreeNode { + n.Walk(func(node, parent *TreeNode) bool { + n.expanded = false + return true + }) + return n +} + +// IsExpanded returns whether the child nodes of this node are visible. +func (n *TreeNode) IsExpanded() bool { + return n.expanded +} + +// SetText sets the node's text which is displayed. +func (n *TreeNode) SetText(text string) *TreeNode { + n.text = text + return n +} + +// SetColor sets the node's text color. +func (n *TreeNode) SetColor(color tcell.Color) *TreeNode { + n.color = color + return n +} + +// SetIndent sets an additional indentation for this node's text. A value of 0 +// keeps the text as far left as possible with a minimum of line graphics. Any +// value greater than that moves the text to the right. +func (n *TreeNode) SetIndent(indent int) *TreeNode { + n.indent = indent + return n +} + +// TreeView displays tree structures. A tree consists of nodes (TreeNode +// objects) where each node has zero or more child nodes and exactly one parent +// node (except for the root node which has no parent node). +// +// The SetRoot() function is used to specify the root of the tree. Other nodes +// are added locally to the root node or any of its descendents. See the +// TreeNode documentation for details on node attributes. (You can use +// SetReference() to store a reference to nodes of your own tree structure.) +// +// Nodes can be selected by calling SetCurrentNode(). The user can navigate the +// selection or the tree by using the following keys: +// +// - j, down arrow, right arrow: Move (the selection) down by one node. +// - k, up arrow, left arrow: Move (the selection) up by one node. +// - g, home: Move (the selection) to the top. +// - G, end: Move (the selection) to the bottom. +// - Ctrl-F, page down: Move (the selection) down by one page. +// - Ctrl-B, page up: Move (the selection) up by one page. +// +// Selected nodes can trigger the "selected" callback when the user hits Enter. +// +// The root node corresponds to level 0, its children correspond to level 1, +// their children to level 2, and so on. Per default, the first level that is +// displayed is 0, i.e. the root node. You can call SetTopLevel() to skip +// levels. +// +// If graphics are turned on (see SetGraphics()), lines indicate the tree's +// hierarchy. Alternative (or additionally), you can set different prefixes +// using SetPrefixes() for different levels, for example to display hierarchical +// bullet point lists. +type TreeView struct { + *Box + + // The root node. + root *TreeNode + + // The currently selected node or nil if no node is selected. + currentNode *TreeNode + + // The movement to be performed during the call to Draw(), one of the + // constants defined above. + movement int + + // The top hierarchical level shown. (0 corresponds to the root level.) + topLevel int + + // Strings drawn before the nodes, based on their level. + prefixes []string + + // Vertical scroll offset. + offsetY int + + // If set to true, all node texts will be aligned horizontally. + align bool + + // If set to true, the tree structure is drawn using lines. + graphics bool + + // The color of the lines. + graphicsColor tcell.Color + + // An optional function which is called when the user has navigated to a new + // tree node. + changed func(node *TreeNode) + + // An optional function which is called when a tree item was selected. + selected func(node *TreeNode) +} + +// NewTreeView returns a new tree view. +func NewTreeView() *TreeView { + return &TreeView{ + Box: NewBox(), + graphics: true, + graphicsColor: Styles.GraphicsColor, + } +} + +// SetRoot sets the root node of the tree. +func (t *TreeView) SetRoot(root *TreeNode) *TreeView { + t.root = root + return t +} + +// GetRoot returns the root node of the tree. If no such node was previously +// set, nil is returned. +func (t *TreeView) GetRoot() *TreeNode { + return t.root +} + +// SetCurrentNode sets the currently selected node. Provide nil to clear all +// selections. Selected nodes must be visible and selectable, or else the +// selection will be changed to the top-most selectable and visible node. +// +// This function does NOT trigger the "changed" callback. +func (t *TreeView) SetCurrentNode(node *TreeNode) *TreeView { + t.currentNode = node + return t +} + +// GetCurrentNode returns the currently selected node or nil of no node is +// currently selected. +func (t *TreeView) GetCurrentNode() *TreeNode { + return t.currentNode +} + +// SetTopLevel sets the first tree level that is visible with 0 referring to the +// root, 1 to the root's child nodes, and so on. Nodes above the top level are +// not displayed. +func (t *TreeView) SetTopLevel(topLevel int) *TreeView { + t.topLevel = topLevel + return t +} + +// SetPrefixes defines the strings drawn before the nodes' texts. This is a +// slice of strings where each element corresponds to a node's hierarchy level, +// i.e. 0 for the root, 1 for the root's children, and so on (levels will +// cycle). +// +// For example, to display a hierarchical list with bullet points: +// +// treeView.SetGraphics(false). +// SetPrefixes([]string{"* ", "- ", "x "}) +func (t *TreeView) SetPrefixes(prefixes []string) *TreeView { + t.prefixes = prefixes + return t +} + +// SetAlign controls the horizontal alignment of the node texts. If set to true, +// all texts except that of top-level nodes will be placed in the same column. +// If set to false, they will indent with the hierarchy. +func (t *TreeView) SetAlign(align bool) *TreeView { + t.align = align + return t +} + +// SetGraphics sets a flag which determines whether or not line graphics are +// drawn to illustrate the tree's hierarchy. +func (t *TreeView) SetGraphics(showGraphics bool) *TreeView { + t.graphics = showGraphics + return t +} + +// SetGraphicsColor sets the colors of the lines used to draw the tree structure. +func (t *TreeView) SetGraphicsColor(color tcell.Color) *TreeView { + t.graphicsColor = color + return t +} + +// SetChangedFunc sets the function which is called when the user navigates to +// a new tree node. +func (t *TreeView) SetChangedFunc(handler func(node *TreeNode)) *TreeView { + t.changed = handler + return t +} + +// SetSelectedFunc sets the function which is called when the user selects a +// node by pressing Enter on the current selection. +func (t *TreeView) SetSelectedFunc(handler func(node *TreeNode)) *TreeView { + t.selected = handler + return t +} + +// Draw draws this primitive onto the screen. +func (t *TreeView) Draw(screen tcell.Screen) { + t.Box.Draw(screen) + if t.root == nil { + return + } + x, y, width, height := t.GetInnerRect() + + // Determine visible nodes and their placement. + var graphicsOffset, maxTextX int + var nodes []*TreeNode + selectedIndex := -1 + topLevelGraphicsX := -1 + if t.graphics { + graphicsOffset = 1 + } + t.root.Walk(func(node, parent *TreeNode) bool { + // Set node attributes. + node.parent = parent + if parent == nil { + node.level = 0 + node.graphicsX = 0 + node.textX = 0 + } else { + node.level = parent.level + 1 + node.graphicsX = parent.textX + node.textX = node.graphicsX + graphicsOffset + node.indent + } + if !t.graphics && t.align { + // Without graphics, we align nodes on the first column. + node.textX = 0 + } + if node.level == t.topLevel { + // No graphics for top level nodes. + node.graphicsX = 0 + node.textX = 0 + } + if node.textX > maxTextX { + maxTextX = node.textX + } + if node == t.currentNode && node.selectable { + selectedIndex = len(nodes) + } + + // Maybe we want to skip this level. + if t.topLevel == node.level && (topLevelGraphicsX < 0 || node.graphicsX < topLevelGraphicsX) { + topLevelGraphicsX = node.graphicsX + } + + // Add and recurse (if desired). + if node.level >= t.topLevel { + nodes = append(nodes, node) + } + return node.expanded + }) + + // Post-process positions. + for _, node := range nodes { + // If text must align, we correct the positions. + if t.align && node.level > t.topLevel { + node.textX = maxTextX + } + + // If we skipped levels, shift to the left. + if topLevelGraphicsX > 0 { + node.graphicsX -= topLevelGraphicsX + node.textX -= topLevelGraphicsX + } + } + + // Process selection. (Also trigger events if necessary.) + if selectedIndex >= 0 { + // Move the selection. + newSelectedIndex := selectedIndex + MovementSwitch: + switch t.movement { + case treeUp: + for newSelectedIndex > 0 { + newSelectedIndex-- + if nodes[newSelectedIndex].selectable { + break MovementSwitch + } + } + newSelectedIndex = selectedIndex + case treeDown: + for newSelectedIndex < len(nodes)-1 { + newSelectedIndex++ + if nodes[newSelectedIndex].selectable { + break MovementSwitch + } + } + newSelectedIndex = selectedIndex + case treeHome: + for newSelectedIndex = 0; newSelectedIndex < len(nodes); newSelectedIndex++ { + if nodes[newSelectedIndex].selectable { + break MovementSwitch + } + } + newSelectedIndex = selectedIndex + case treeEnd: + for newSelectedIndex = len(nodes) - 1; newSelectedIndex >= 0; newSelectedIndex-- { + if nodes[newSelectedIndex].selectable { + break MovementSwitch + } + } + newSelectedIndex = selectedIndex + case treePageUp: + if newSelectedIndex+height < len(nodes) { + newSelectedIndex += height + } else { + newSelectedIndex = len(nodes) - 1 + } + for ; newSelectedIndex < len(nodes); newSelectedIndex++ { + if nodes[newSelectedIndex].selectable { + break MovementSwitch + } + } + newSelectedIndex = selectedIndex + case treePageDown: + if newSelectedIndex >= height { + newSelectedIndex -= height + } else { + newSelectedIndex = 0 + } + for ; newSelectedIndex >= 0; newSelectedIndex-- { + if nodes[newSelectedIndex].selectable { + break MovementSwitch + } + } + newSelectedIndex = selectedIndex + } + t.currentNode = nodes[newSelectedIndex] + if newSelectedIndex != selectedIndex { + t.movement = treeNone + if t.changed != nil { + t.changed(t.currentNode) + } + } + selectedIndex = newSelectedIndex + + // Move selection into viewport. + if selectedIndex-t.offsetY >= height { + t.offsetY = selectedIndex - height + 1 + } + if selectedIndex < t.offsetY { + t.offsetY = selectedIndex + } + } else { + // If selection is not visible or selectable, select the first candidate. + if t.currentNode != nil { + for index, node := range nodes { + if node.selectable { + selectedIndex = index + t.currentNode = node + break + } + } + } + if selectedIndex < 0 { + t.currentNode = nil + } + } + + // Scroll the tree. + switch t.movement { + case treeUp: + t.offsetY-- + case treeDown: + t.offsetY++ + case treeHome: + t.offsetY = 0 + case treeEnd: + t.offsetY = len(nodes) + case treePageUp: + t.offsetY -= height + case treePageDown: + t.offsetY += height + } + t.movement = treeNone + + // Fix invalid offsets. + if t.offsetY >= len(nodes)-height { + t.offsetY = len(nodes) - height + } + if t.offsetY < 0 { + t.offsetY = 0 + } + + // Draw the tree. + posY := y + lineStyle := tcell.StyleDefault.Foreground(t.graphicsColor) + for index, node := range nodes { + // Skip invisible parts. + if posY >= y+height+1 { + break + } + if index < t.offsetY { + continue + } + + // Draw the graphics. + if t.graphics { + // Draw ancestor branches. + ancestor := node.parent + for ancestor != nil && ancestor.parent != nil && ancestor.parent.level >= t.topLevel { + if ancestor.graphicsX >= width { + continue + } + + // Draw a branch if this ancestor is not a last child. + if ancestor.parent.children[len(ancestor.parent.children)-1] != ancestor { + if posY-1 >= y && ancestor.textX > ancestor.graphicsX { + PrintJoinedSemigraphics(screen, x+ancestor.graphicsX, posY-1, Borders.Vertical, t.graphicsColor) + } + if posY < y+height { + screen.SetContent(x+ancestor.graphicsX, posY, Borders.Vertical, nil, lineStyle) + } + } + ancestor = ancestor.parent + } + + if node.textX > node.graphicsX && node.graphicsX < width { + // Connect to the node above. + if posY-1 >= y && nodes[index-1].graphicsX <= node.graphicsX && nodes[index-1].textX > node.graphicsX { + PrintJoinedSemigraphics(screen, x+node.graphicsX, posY-1, Borders.TopLeft, t.graphicsColor) + } + + // Join this node. + if posY < y+height { + screen.SetContent(x+node.graphicsX, posY, Borders.BottomLeft, nil, lineStyle) + for pos := node.graphicsX + 1; pos < node.textX && pos < width; pos++ { + screen.SetContent(x+pos, posY, Borders.Horizontal, nil, lineStyle) + } + } + } + } + + // Draw the prefix and the text. + if node.textX < width && posY < y+height { + // Prefix. + var prefixWidth int + if len(t.prefixes) > 0 { + _, prefixWidth = Print(screen, t.prefixes[(node.level-t.topLevel)%len(t.prefixes)], x+node.textX, posY, width-node.textX, AlignLeft, node.color) + } + + // Text. + if node.textX+prefixWidth < width { + style := tcell.StyleDefault.Foreground(node.color) + if index == selectedIndex { + style = tcell.StyleDefault.Background(node.color).Foreground(t.backgroundColor) + } + printWithStyle(screen, node.text, x+node.textX+prefixWidth, posY, width-node.textX-prefixWidth, AlignLeft, style) + } + } + + // Advance. + posY++ + } +} + +// InputHandler returns the handler for this primitive. +func (t *TreeView) InputHandler() func(event *tcell.EventKey, setFocus func(p Primitive)) { + return t.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p Primitive)) { + // Because the tree is flattened into a list only at drawing time, we also + // postpone the (selection) movement to drawing time. + switch key := event.Key(); key { + case tcell.KeyTab, tcell.KeyDown, tcell.KeyRight: + t.movement = treeDown + case tcell.KeyBacktab, tcell.KeyUp, tcell.KeyLeft: + t.movement = treeUp + case tcell.KeyHome: + t.movement = treeHome + case tcell.KeyEnd: + t.movement = treeEnd + case tcell.KeyPgDn, tcell.KeyCtrlF: + t.movement = treePageDown + case tcell.KeyPgUp, tcell.KeyCtrlB: + t.movement = treePageUp + case tcell.KeyRune: + switch event.Rune() { + case 'g': + t.movement = treeHome + case 'G': + t.movement = treeEnd + case 'j': + t.movement = treeDown + case 'k': + t.movement = treeUp + } + case tcell.KeyEnter: + if t.currentNode != nil { + if t.selected != nil { + t.selected(t.currentNode) + } + if t.currentNode.selected != nil { + t.currentNode.selected() + } + } + } + }) +} diff --git a/util.go b/util.go index 7ac8f74..44c8057 100644 --- a/util.go +++ b/util.go @@ -120,13 +120,12 @@ func overlayStyle(background tcell.Color, defaultStyle tcell.Style, fgColor, bgC defFg, defBg, defAttr := defaultStyle.Decompose() style := defaultStyle.Background(background) - if fgColor == "-" { - style = style.Foreground(defFg) - } else if fgColor != "" { + style = style.Foreground(defFg) + if fgColor != "" { style = style.Foreground(tcell.GetColor(fgColor)) } - if bgColor == "-" { + if bgColor == "-" || bgColor == "" && defBg != tcell.ColorDefault { style = style.Background(defBg) } else if bgColor != "" { style = style.Background(tcell.GetColor(bgColor))