diff --git a/README.md b/README.md
index a6e0ad8f..2b62a6d9 100644
--- a/README.md
+++ b/README.md
@@ -184,6 +184,17 @@ go run widgets/segmentdisplay/segmentdisplaydemo/segmentdisplaydemo.go
[
](widgets/segmentdisplay/segmentdisplaydemo/segmentdisplaydemo.go)
+## The TreeView
+
+Displays a tree view which provides a hierarchical and collapsible view for displaying and interacting with nested data structures.
+[treeviewdemo](widgets/treeview/treeviewdemo/treeviewdemo.go).
+
+```go
+go run widgets/treeview/treeviewdemo/treeviewdemo.go
+```
+
+[
](widgets/treeview/treeviewdemo/treeviewdemo.go)
+
# Contributing
If you are willing to contribute, improve the infrastructure or develop a
@@ -219,8 +230,8 @@ Termdash uses [this branching model](https://nvie.com/posts/a-successful-git-bra
- [perfstat](https://github.com/flaviostutz/perfstat): Analyze and show tips about possible bottlenecks in Linux systems.
- [gex](https://github.com/Tosch110/gex): Cosmos SDK explorer in-terminal.
- [ali](https://github.com/nakabonne/ali): ALI HTTP load testing tool with realtime analysis.
-- [suimon](https://github.com/bartosian/suimon): SUI blockchain explorer and monitor.
-
+- [suimon](https://github.com/bartosian/suimon): SUI blockchain explorer and monitor.
# Disclaimer
This is not an official Google product.
+
diff --git a/doc/images/treeviewdemo.gif b/doc/images/treeviewdemo.gif
new file mode 100644
index 00000000..f6462dbb
Binary files /dev/null and b/doc/images/treeviewdemo.gif differ
diff --git a/widgets/treeview/options.go b/widgets/treeview/options.go
new file mode 100644
index 00000000..264486ad
--- /dev/null
+++ b/widgets/treeview/options.go
@@ -0,0 +1,128 @@
+package treeview
+
+import "github.com/mum4k/termdash/cell"
+
+// Option represents a configuration option for the TreeView.
+type Option func(*options)
+
+// options holds the configuration for the TreeView.
+type options struct {
+ // nodes are the root nodes of the TreeView.
+ nodes []*TreeNode
+ // labelColor is the color of the node labels.
+ labelColor cell.Color
+ // expandedIcon is the icon used for expanded nodes.
+ expandedIcon string
+ // collapsedIcon is the icon used for collapsed nodes.
+ collapsedIcon string
+ // leafIcon is the icon used for leaf nodes.
+ leafIcon string
+ // indentation is the number of spaces per indentation level.
+ indentation int
+ // waitingIcons are the icons used for the spinner.
+ waitingIcons []string
+ // truncate indicates whether to truncate long labels.
+ truncate bool
+ // enableLogging enables or disables logging for debugging.
+ enableLogging bool
+}
+
+// newOptions initializes default options.
+func newOptions() *options {
+ return &options{
+ nodes: []*TreeNode{},
+ labelColor: cell.ColorWhite,
+ expandedIcon: "▼",
+ collapsedIcon: "▶",
+ leafIcon: "→",
+ waitingIcons: []string{"◐", "◓", "◑", "◒"},
+ truncate: false,
+ indentation: 2, // Default indentation
+ }
+}
+
+// Nodes sets the root nodes of the TreeView.
+func Nodes(nodes ...*TreeNode) Option {
+ return func(o *options) {
+ o.nodes = nodes
+ }
+}
+
+// Indentation sets the number of spaces for each indentation level.
+func Indentation(spaces int) Option {
+ return func(o *options) {
+ o.indentation = spaces
+ }
+}
+
+// Icons sets custom icons for expanded, collapsed, and leaf nodes.
+func Icons(expanded, collapsed, leaf string) Option {
+ return func(o *options) {
+ o.expandedIcon = expanded
+ o.collapsedIcon = collapsed
+ o.leafIcon = leaf
+ }
+}
+
+// LabelColor sets the color of the node labels.
+func LabelColor(color cell.Color) Option {
+ return func(o *options) {
+ o.labelColor = color
+ }
+}
+
+// WaitingIcons sets the icons for the spinner.
+func WaitingIcons(icons []string) Option {
+ return func(o *options) {
+ o.waitingIcons = icons
+ }
+}
+
+// Truncate enables or disables label truncation.
+func Truncate(truncate bool) Option {
+ return func(o *options) {
+ o.truncate = truncate
+ }
+}
+
+// EnableLogging enables or disables logging for debugging.
+func EnableLogging(enable bool) Option {
+ return func(o *options) {
+ o.enableLogging = enable
+ }
+}
+
+// Label sets the widget's label.
+// Note: If the widget's label is managed by the container, this can be a no-op.
+func Label(label string) Option {
+ return func(o *options) {
+ // No action needed; label is set in container's BorderTitle.
+ }
+}
+
+// CollapsedIcon sets the icon for collapsed nodes.
+func CollapsedIcon(icon string) Option {
+ return func(o *options) {
+ o.collapsedIcon = icon
+ }
+}
+
+// ExpandedIcon sets the icon for expanded nodes.
+func ExpandedIcon(icon string) Option {
+ return func(o *options) {
+ o.expandedIcon = icon
+ }
+}
+
+// LeafIcon sets the icon for leaf nodes.
+func LeafIcon(icon string) Option {
+ return func(o *options) {
+ o.leafIcon = icon
+ }
+}
+
+// IndentationPerLevel sets the indentation per level.
+// Alias to Indentation for compatibility with demo code.
+func IndentationPerLevel(spaces int) Option {
+ return Indentation(spaces)
+}
diff --git a/widgets/treeview/treeview.go b/widgets/treeview/treeview.go
new file mode 100644
index 00000000..024dce5d
--- /dev/null
+++ b/widgets/treeview/treeview.go
@@ -0,0 +1,780 @@
+package treeview
+
+import (
+ "errors"
+ "fmt"
+ "image"
+ "io"
+ "log"
+ "os"
+ "sync"
+ "time"
+
+ "github.com/mum4k/termdash/cell"
+ "github.com/mum4k/termdash/keyboard"
+ "github.com/mum4k/termdash/mouse"
+ "github.com/mum4k/termdash/private/canvas"
+ "github.com/mum4k/termdash/private/runewidth"
+ "github.com/mum4k/termdash/terminal/terminalapi"
+ "github.com/mum4k/termdash/widgetapi"
+)
+
+// Number of nodes to scroll per mouse wheel event.
+const (
+ ScrollStep = 5
+)
+
+// TreeNode represents a node in the treeview.
+type TreeNode struct {
+ // ID is the unique identifier for the node.
+ ID string
+ // Label is the display text of the node.
+ Label string
+ // Level is the depth level of the node in the tree.
+ Level int
+ // Parent is the parent node of this node.
+ Parent *TreeNode
+ // Children are the child nodes of this node.
+ Children []*TreeNode
+ // Value holds any data associated with the node.
+ Value interface{}
+ // ShowSpinner indicates whether to display a spinner for this node.
+ ShowSpinner bool
+ // OnClick is the function to execute when the node is clicked.
+ OnClick func() error
+ // ExpandedState indicates whether the node is expanded to show its children.
+ ExpandedState bool
+ // SpinnerIndex is the current index for the spinner icons.
+ SpinnerIndex int
+ // mu protects access to the node's fields.
+ mu sync.Mutex
+}
+
+// SetShowSpinner safely sets the ShowSpinner flag.
+func (tn *TreeNode) SetShowSpinner(value bool) {
+ tn.mu.Lock()
+ defer tn.mu.Unlock()
+ tn.ShowSpinner = value
+ if !value {
+ tn.SpinnerIndex = 0 // Reset spinner index when spinner is turned off
+ }
+}
+
+// GetShowSpinner safely retrieves the ShowSpinner flag.
+func (tn *TreeNode) GetShowSpinner() bool {
+ tn.mu.Lock()
+ defer tn.mu.Unlock()
+ return tn.ShowSpinner
+}
+
+// IncrementSpinner safely increments the SpinnerIndex.
+func (tn *TreeNode) IncrementSpinner(totalIcons int) {
+ tn.mu.Lock()
+ defer tn.mu.Unlock()
+ tn.SpinnerIndex = (tn.SpinnerIndex + 1) % totalIcons
+}
+
+// IsRoot checks if the node is a root node.
+func (tn *TreeNode) IsRoot() bool {
+ return tn.Parent == nil
+}
+
+// SetExpandedState safely sets the ExpandedState flag.
+func (tn *TreeNode) SetExpandedState(value bool) {
+ tn.mu.Lock()
+ defer tn.mu.Unlock()
+ tn.ExpandedState = value
+}
+
+// GetExpandedState safely retrieves the ExpandedState flag.
+func (tn *TreeNode) GetExpandedState() bool {
+ tn.mu.Lock()
+ defer tn.mu.Unlock()
+ return tn.ExpandedState
+}
+
+// TreeView represents the treeview widget.
+type TreeView struct {
+ // mu protects access to the TreeView's fields.
+ mu sync.Mutex
+ // position stores the widget's top-left position.
+ position image.Point
+ // opts holds the configuration options for the TreeView.
+ opts *options
+ // selectedNode is the currently selected node.
+ selectedNode *TreeNode
+ // visibleNodes is the list of currently visible nodes.
+ visibleNodes []*TreeNode
+ // logger logs debugging information.
+ logger *log.Logger
+ // spinnerTicker updates spinner indices periodically.
+ spinnerTicker *time.Ticker
+ // stopSpinner signals the spinner goroutine to stop.
+ stopSpinner chan struct{}
+ // expandedIcon is the icon used for expanded nodes.
+ expandedIcon string
+ // collapsedIcon is the icon used for collapsed nodes.
+ collapsedIcon string
+ // leafIcon is the icon used for leaf nodes.
+ leafIcon string
+ // scrollOffset is the current vertical scroll offset.
+ scrollOffset int
+ // indentationPerLevel is the number of spaces to indent per tree level.
+ indentationPerLevel int
+ // canvasWidth is the width of the canvas.
+ canvasWidth int
+ // canvasHeight is the height of the canvas.
+ canvasHeight int
+ // totalContentHeight is the total height of the content.
+ totalContentHeight int
+ // waitingIcons are the icons used for the spinner.
+ waitingIcons []string
+ // lastClickTime is the timestamp of the last handled click.
+ lastClickTime time.Time
+ // lastKeyTime is the timestamp for debouncing the enter key.
+ lastKeyTime time.Time
+}
+
+// New creates a new TreeView instance.
+func New(opts ...Option) (*TreeView, error) {
+ options := newOptions()
+ for _, opt := range opts {
+ opt(options)
+ }
+
+ // Set default leaf icon if not provided
+ if options.leafIcon == "" {
+ options.leafIcon = "→"
+ }
+
+ // Set default indentation if not provided
+ if options.indentation == 0 {
+ options.indentation = 2
+ }
+
+ for _, node := range options.nodes {
+ setParentsAndAssignIDs(node, nil, 0, "")
+ }
+
+ // Create a logger to log debugging information to a file if logging is enabled
+ var logger *log.Logger
+ if options.enableLogging {
+ file, err := os.OpenFile("treeview_debug.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
+ if err != nil {
+ return nil, fmt.Errorf("failed to open log file: %v", err)
+ }
+ logger = log.New(file, "INFO: ", log.Ldate|log.Ltime|log.Lshortfile)
+ } else {
+ // Create a dummy logger that discards all logs
+ logger = log.New(io.Discard, "", 0)
+ }
+
+ tv := &TreeView{
+ opts: options,
+ logger: logger,
+ stopSpinner: make(chan struct{}),
+ expandedIcon: options.expandedIcon,
+ collapsedIcon: options.collapsedIcon,
+ leafIcon: options.leafIcon,
+ scrollOffset: 0,
+ indentationPerLevel: options.indentation,
+ waitingIcons: options.waitingIcons,
+ }
+
+ setInitialExpandedState(tv, true) // Expand root nodes by default
+
+ if len(options.waitingIcons) > 0 {
+ tv.spinnerTicker = time.NewTicker(200 * time.Millisecond)
+ go tv.runSpinner()
+ }
+ tv.updateTotalHeight()
+
+ // Set selectedNode to the first visible node
+ visibleNodes := tv.getVisibleNodesList()
+ if len(visibleNodes) > 0 {
+ tv.selectedNode = visibleNodes[0]
+ }
+ return tv, nil
+}
+
+// generateNodeID creates a consistent node ID.
+func generateNodeID(path string, label string) string {
+ if path == "" {
+ return label
+ }
+ return fmt.Sprintf("%s/%s", path, label)
+}
+
+// setParentsAndAssignIDs assigns parent references, levels, and IDs to nodes recursively.
+func setParentsAndAssignIDs(tn *TreeNode, parent *TreeNode, level int, path string) {
+ tn.Parent = parent
+ tn.Level = level
+
+ tn.ID = generateNodeID(path, tn.Label)
+
+ for _, child := range tn.Children {
+ setParentsAndAssignIDs(child, tn, level+1, tn.ID)
+ }
+}
+
+// runSpinner updates spinner indices periodically.
+func (tv *TreeView) runSpinner() {
+ for {
+ select {
+ case <-tv.spinnerTicker.C:
+ tv.mu.Lock()
+ visibleNodes := tv.getVisibleNodesList()
+ tv.mu.Unlock() // Release the TreeView lock before operating on individual nodes
+ for _, tn := range visibleNodes {
+ if tn.GetShowSpinner() && len(tv.waitingIcons) > 0 {
+ tn.IncrementSpinner(len(tv.waitingIcons))
+ tv.logger.Printf("Spinner updated for node: %s (SpinnerIndex: %d)", tn.Label, tn.SpinnerIndex)
+ }
+ }
+ case <-tv.stopSpinner:
+ return
+ }
+ }
+}
+
+// StopSpinnerTicker stops the spinner ticker.
+func (tv *TreeView) StopSpinnerTicker() {
+ if tv.spinnerTicker != nil {
+ tv.spinnerTicker.Stop()
+ close(tv.stopSpinner)
+ }
+}
+
+// setInitialExpandedState sets the initial expanded state for root nodes.
+func setInitialExpandedState(tv *TreeView, expandRoot bool) {
+ for _, tn := range tv.opts.nodes {
+ if tn.IsRoot() {
+ tn.SetExpandedState(expandRoot)
+ }
+ }
+ tv.updateTotalHeight()
+}
+
+// calculateHeight calculates the height of a node, including its children if expanded.
+func (tv *TreeView) calculateHeight(tn *TreeNode) int {
+ height := 1 // Start with the height of the current node
+ if tn.ExpandedState {
+ for _, child := range tn.Children {
+ height += tv.calculateHeight(child)
+ }
+ }
+ return height
+}
+
+// calculateTotalHeight calculates the total height of all visible nodes.
+func (tv *TreeView) calculateTotalHeight() int {
+ totalHeight := 0
+ for _, rootNode := range tv.opts.nodes {
+ totalHeight += tv.calculateHeight(rootNode)
+ }
+ return totalHeight
+}
+
+// updateTotalHeight updates the totalContentHeight based on visible nodes.
+func (tv *TreeView) updateTotalHeight() {
+ tv.totalContentHeight = tv.calculateTotalHeight()
+}
+
+// getVisibleNodesList retrieves a flat list of all currently visible nodes.
+func (tv *TreeView) getVisibleNodesList() []*TreeNode {
+ var list []*TreeNode
+ var traverse func(tn *TreeNode)
+ traverse = func(tn *TreeNode) {
+ list = append(list, tn)
+ tv.logger.Printf("Visible Node Added: '%s' at Level %d", tn.Label, tn.Level)
+ if tn.GetExpandedState() { // Use getter with mutex
+ for _, child := range tn.Children {
+ traverse(child)
+ }
+ }
+ }
+ for _, root := range tv.opts.nodes {
+ traverse(root)
+ }
+ return list
+}
+
+// getNodePrefix returns the appropriate prefix for a node based on its state.
+func (tv *TreeView) getNodePrefix(tn *TreeNode) string {
+ if tn.GetShowSpinner() && len(tv.waitingIcons) > 0 {
+ return tv.waitingIcons[tn.SpinnerIndex]
+ }
+
+ if len(tn.Children) > 0 {
+ if tn.ExpandedState {
+ return tv.expandedIcon
+ }
+ return tv.collapsedIcon
+ }
+
+ return tv.leafIcon
+}
+
+// drawNode draws nodes based on the nodesToDraw slice.
+func (tv *TreeView) drawNode(cvs *canvas.Canvas, nodesToDraw []*TreeNode) error {
+ for y, tn := range nodesToDraw {
+ // Determine if this node is selected
+ isSelected := (tn.ID == tv.selectedNode.ID)
+
+ // Get the prefix based on node state
+ prefix := tv.getNodePrefix(tn)
+ prefixWidth := runewidth.StringWidth(prefix)
+
+ // Construct the label
+ label := fmt.Sprintf("%s %s", prefix, tn.Label)
+ labelWidth := runewidth.StringWidth(label)
+ indentX := tn.Level * tv.indentationPerLevel
+ availableWidth := tv.canvasWidth - indentX
+
+ if tv.opts.truncate && labelWidth > availableWidth {
+ // Truncate the label to fit within the available space
+ truncatedLabel := truncateString(label, availableWidth)
+ if truncatedLabel != label {
+ label = truncatedLabel
+ }
+ labelWidth = runewidth.StringWidth(label)
+ }
+
+ // Log prefix width for debugging
+ tv.logger.Printf("Drawing node '%s' with prefix width %d", tn.Label, prefixWidth)
+
+ // Determine colors based on selection
+ var fgColor cell.Color = tv.opts.labelColor
+ var bgColor cell.Color = cell.ColorDefault
+ if isSelected {
+ fgColor = cell.ColorBlack
+ bgColor = cell.ColorWhite
+ }
+
+ // Draw the label at the correct position
+ if err := tv.drawLabel(cvs, label, indentX, y, fgColor, bgColor); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// findNodeByClick determines which node was clicked based on x and y coordinates.
+func (tv *TreeView) findNodeByClick(x, y int, visibleNodes []*TreeNode) *TreeNode {
+ clickedIndex := y + tv.scrollOffset // Adjust Y-coordinate based on scroll offset
+ if clickedIndex < 0 || clickedIndex >= len(visibleNodes) {
+ return nil
+ }
+
+ tn := visibleNodes[clickedIndex]
+
+ label := fmt.Sprintf("%s %s", tv.getNodePrefix(tn), tn.Label)
+ labelWidth := runewidth.StringWidth(label)
+ indentX := tn.Level * tv.indentationPerLevel
+ availableWidth := tv.canvasWidth - indentX
+
+ if tv.opts.truncate && labelWidth > availableWidth {
+ truncatedLabel := truncateString(label, availableWidth)
+ labelWidth = runewidth.StringWidth(truncatedLabel)
+ label = truncatedLabel
+ }
+
+ labelStartX := indentX
+ labelEndX := labelStartX + labelWidth
+
+ if x >= labelStartX && x < labelEndX {
+ tv.logger.Printf("Node '%s' (ID: %s) clicked at [X:%d Y:%d]", tn.Label, tn.ID, x, y)
+ return tn
+ }
+
+ return nil
+}
+
+// handleMouseClick processes mouse click at given x, y coordinates.
+func (tv *TreeView) handleMouseClick(x, y int) error {
+ tv.logger.Printf("Handling mouse click at (X:%d, Y:%d)", x, y)
+ visibleNodes := tv.visibleNodes
+ clickedNode := tv.findNodeByClick(x, y, visibleNodes)
+ if clickedNode != nil {
+ tv.logger.Printf("Node: %s (ID: %s) clicked, expanded: %v", clickedNode.Label, clickedNode.ID, clickedNode.ExpandedState)
+ // Update selectedNode to the clicked node
+ tv.selectedNode = clickedNode
+ if err := tv.handleNodeClick(clickedNode); err != nil {
+ tv.logger.Println("Error handling node click:", err)
+ }
+ } else {
+ tv.logger.Printf("No node found at position: (X:%d, Y:%d)", x, y)
+ }
+
+ return nil
+}
+
+// handleNodeClick toggles the expansion state of a node and manages the spinner.
+func (tv *TreeView) handleNodeClick(tn *TreeNode) error {
+ tv.logger.Printf("Handling node click for: %s (ID: %s)", tn.Label, tn.ID)
+ if len(tn.Children) > 0 {
+ // Toggle expansion state
+ tn.SetExpandedState(!tn.GetExpandedState())
+ tv.updateTotalHeight()
+ tv.logger.Printf("Toggled expansion for node: %s to %v", tn.Label, tn.ExpandedState)
+ return nil
+ }
+
+ // Handle leaf node click
+ if tn.OnClick != nil {
+ tn.SetShowSpinner(true)
+ tv.logger.Printf("Spinner started for node: %s", tn.Label)
+ go func(n *TreeNode) {
+ tv.logger.Printf("Executing OnClick for node: %s", n.Label)
+ if err := n.OnClick(); err != nil {
+ tv.logger.Printf("Error executing OnClick for node %s: %v", n.Label, err)
+ }
+ n.SetShowSpinner(false)
+ tv.logger.Printf("Spinner stopped for node: %s", n.Label)
+ }(tn)
+ }
+
+ return nil
+}
+
+// Mouse handles mouse events with debouncing for ButtonLeft clicks.
+// It processes mouse press events and mouse wheel events.
+func (tv *TreeView) Mouse(m *terminalapi.Mouse, meta *widgetapi.EventMeta) error {
+
+ // Ignore mouse release events to avoid handling multiple events per physical click
+ if m.Button == mouse.ButtonRelease {
+ return nil
+ }
+
+ // Adjust coordinates to be relative to the widget's position
+ x := m.Position.X - tv.position.X
+ y := m.Position.Y - tv.position.Y
+
+ switch m.Button {
+ case mouse.ButtonLeft:
+ now := time.Now()
+ if now.Sub(tv.lastClickTime) < 100*time.Millisecond {
+ // Ignore duplicate click within 100ms
+ tv.logger.Printf("Ignored duplicate ButtonLeft click at (X:%d, Y:%d)", x, y)
+ return nil
+ }
+ tv.lastClickTime = now
+ tv.logger.Printf("MouseDown event at position: (X:%d, Y:%d)", x, y)
+ return tv.handleMouseClick(x, y)
+ case mouse.ButtonWheelUp:
+ tv.logger.Println("Mouse wheel up")
+ if tv.scrollOffset >= ScrollStep {
+ tv.scrollOffset -= ScrollStep
+ } else {
+ tv.scrollOffset = 0
+ }
+ tv.updateVisibleNodes()
+ return nil
+ case mouse.ButtonWheelDown:
+ tv.logger.Println("Mouse wheel down")
+ maxOffset := tv.totalContentHeight - tv.canvasHeight
+ if maxOffset < 0 {
+ maxOffset = 0
+ }
+ if tv.scrollOffset+ScrollStep <= maxOffset {
+ tv.scrollOffset += ScrollStep
+ } else {
+ tv.scrollOffset = maxOffset
+ }
+ tv.updateVisibleNodes()
+ return nil
+ }
+ return nil
+}
+
+// Keyboard handles keyboard events.
+func (tv *TreeView) Keyboard(k *terminalapi.Keyboard, meta *widgetapi.EventMeta) error {
+ tv.mu.Lock()
+ visibleNodes := tv.visibleNodes
+ currentIndex := tv.getSelectedNodeIndex(visibleNodes)
+ tv.mu.Unlock()
+ if currentIndex == -1 {
+ if len(visibleNodes) > 0 {
+ tv.selectedNode = visibleNodes[0]
+ currentIndex = 0
+ } else {
+ // No visible nodes to select
+ return nil
+ }
+ }
+
+ // Debounce Enter key to avoid rapid toggling
+ now := time.Now()
+ if k.Key == keyboard.KeyEnter || k.Key == ' ' {
+ if now.Sub(tv.lastKeyTime) < 100*time.Millisecond {
+ tv.logger.Printf("Ignored rapid Enter key press")
+ return nil
+ }
+ tv.lastKeyTime = now
+ }
+
+ switch k.Key {
+ case keyboard.KeyArrowDown:
+ if currentIndex < len(visibleNodes)-1 {
+ currentIndex++
+ tv.selectedNode = visibleNodes[currentIndex]
+ // Adjust scrollOffset to keep selectedNode in view
+ if currentIndex >= tv.scrollOffset+tv.canvasHeight {
+ tv.scrollOffset = currentIndex - tv.canvasHeight + 1
+ }
+ }
+ case keyboard.KeyArrowUp:
+ if currentIndex > 0 {
+ currentIndex--
+ tv.selectedNode = visibleNodes[currentIndex]
+ // Adjust scrollOffset to keep selectedNode in view
+ if currentIndex < tv.scrollOffset {
+ tv.scrollOffset = currentIndex
+ }
+ }
+ case keyboard.KeyEnter, ' ':
+ if currentIndex >= 0 && currentIndex < len(visibleNodes) {
+ tn := visibleNodes[currentIndex]
+ tv.selectedNode = tn
+ if err := tv.handleNodeClick(tn); err != nil {
+ tv.logger.Println("Error handling node click:", err)
+ }
+ }
+ default:
+ // Handle other keys if needed
+ }
+
+ return nil
+}
+
+// getSelectedNodeIndex returns the index of the selected node in the visibleNodes list.
+func (tv *TreeView) getSelectedNodeIndex(visibleNodes []*TreeNode) int {
+ for idx, tn := range visibleNodes {
+ if tn.ID == tv.selectedNode.ID {
+ return idx
+ }
+ }
+ return -1
+}
+
+// drawScrollUp draws the scroll up indicator.
+func (tv *TreeView) drawScrollUp(cvs *canvas.Canvas) error {
+ if _, err := cvs.SetCell(image.Point{X: 0, Y: 0}, '↑', cell.FgColor(cell.ColorWhite)); err != nil {
+ return err
+ }
+ return nil
+}
+
+// drawScrollDown draws the scroll down indicator.
+func (tv *TreeView) drawScrollDown(cvs *canvas.Canvas) error {
+ if _, err := cvs.SetCell(image.Point{X: 0, Y: cvs.Area().Dy() - 1}, '↓', cell.FgColor(cell.ColorWhite)); err != nil {
+ return err
+ }
+ return nil
+}
+
+// drawLabel draws the label of a node at the specified position with given foreground and background colors.
+func (tv *TreeView) drawLabel(cvs *canvas.Canvas, label string, x, y int, fgColor, bgColor cell.Color) error {
+ tv.logger.Printf("Drawing label: '%s' at X: %d, Y: %d with FG: %v, BG: %v", label, x, y, fgColor, bgColor)
+ displayWidth := runewidth.StringWidth(label)
+ if x+displayWidth > cvs.Area().Dx() {
+ displayWidth = cvs.Area().Dx() - x
+ }
+
+ truncatedLabel := truncateString(label, displayWidth)
+
+ for i, r := range truncatedLabel {
+ if x+i >= cvs.Area().Dx() || y >= cvs.Area().Dy() {
+ // If the x or y position exceeds the canvas dimensions, stop drawing
+ break
+ }
+ if _, err := cvs.SetCell(image.Point{X: x + i, Y: y}, r, cell.FgColor(fgColor), cell.BgColor(bgColor)); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// Draw renders the treeview widget.
+func (tv *TreeView) Draw(cvs *canvas.Canvas, meta *widgetapi.Meta) error {
+ tv.mu.Lock()
+ tv.updateVisibleNodes()
+ visibleNodes := tv.visibleNodes
+ totalHeight := len(visibleNodes)
+ width := cvs.Area().Dx()
+ tv.canvasWidth = width // Set canvasWidth here
+ tv.canvasHeight = cvs.Area().Dy()
+ tv.mu.Unlock()
+
+ // Log canvas dimensions
+ tv.logger.Printf("Canvas Area: Dx=%d, Dy=%d", tv.canvasWidth, tv.canvasHeight)
+
+ if tv.canvasWidth <= 0 || tv.canvasHeight <= 0 {
+ return fmt.Errorf("canvas too small")
+ }
+
+ // Calculate the maximum valid scroll offset
+ maxScrollOffset := tv.totalContentHeight - tv.canvasHeight
+ if maxScrollOffset < 0 {
+ maxScrollOffset = 0
+ }
+
+ // Clamp scrollOffset to ensure it stays within valid bounds
+ if tv.scrollOffset > maxScrollOffset {
+ tv.scrollOffset = maxScrollOffset
+ tv.logger.Printf("Clamped scrollOffset to maxScrollOffset: %d", tv.scrollOffset)
+ }
+ if tv.scrollOffset < 0 {
+ tv.scrollOffset = 0
+ tv.logger.Printf("Clamped scrollOffset to 0")
+ }
+
+ tv.logger.Printf("Starting Draw with scrollOffset: %d, totalHeight: %d, canvasHeight: %d", tv.scrollOffset, totalHeight, tv.canvasHeight)
+
+ // Clear the canvas
+ if err := cvs.Clear(); err != nil {
+ return err
+ }
+
+ // Determine the range of nodes to draw
+ start := tv.scrollOffset
+ end := tv.scrollOffset + tv.canvasHeight
+ if end > len(visibleNodes) {
+ end = len(visibleNodes)
+ }
+
+ // Slice the visibleNodes to only the range to draw
+ nodesToDraw := visibleNodes[start:end]
+
+ // Draw nodes
+ if err := tv.drawNode(cvs, nodesToDraw); err != nil {
+ tv.logger.Printf("Error drawing nodes: %v", err)
+ return err
+ }
+
+ // Draw scroll indicators if needed
+ if tv.scrollOffset > 0 {
+ if err := tv.drawScrollUp(cvs); err != nil {
+ tv.logger.Printf("Error drawing scroll up indicator: %v", err)
+ return err
+ }
+ }
+ if tv.scrollOffset+tv.canvasHeight < totalHeight {
+ if err := tv.drawScrollDown(cvs); err != nil {
+ tv.logger.Printf("Error drawing scroll down indicator: %v", err)
+ return err
+ }
+ }
+
+ tv.logger.Printf("Finished Draw, final currentY: %d, scrollOffset: %d", end, tv.scrollOffset)
+ return nil
+}
+
+// Options returns the widget options to satisfy the widgetapi.Widget interface.
+func (tv *TreeView) Options() widgetapi.Options {
+ return widgetapi.Options{
+ MinimumSize: image.Point{10, 3},
+ WantKeyboard: widgetapi.KeyScopeFocused,
+ WantMouse: widgetapi.MouseScopeWidget,
+ ExclusiveKeyboardOnFocus: true,
+ }
+}
+
+// Select returns the label of the selected node.
+func (tv *TreeView) Select() (string, error) {
+ tv.mu.Lock()
+ defer tv.mu.Unlock()
+ if tv.selectedNode != nil {
+ return tv.selectedNode.Label, nil
+ }
+ return "", errors.New("no option selected")
+}
+
+// Next moves the selection down.
+func (tv *TreeView) Next() {
+ tv.mu.Lock()
+ defer tv.mu.Unlock()
+ visibleNodes := tv.visibleNodes
+ currentIndex := tv.getSelectedNodeIndex(visibleNodes)
+ if currentIndex >= 0 && currentIndex < len(visibleNodes)-1 {
+ currentIndex++
+ tv.selectedNode = visibleNodes[currentIndex]
+ // Adjust scrollOffset to keep selectedNode in view
+ if currentIndex >= tv.scrollOffset+tv.canvasHeight {
+ tv.scrollOffset = currentIndex - tv.canvasHeight + 1
+ }
+ }
+}
+
+// Previous moves the selection up.
+func (tv *TreeView) Previous() {
+ tv.mu.Lock()
+ defer tv.mu.Unlock()
+ visibleNodes := tv.visibleNodes
+ currentIndex := tv.getSelectedNodeIndex(visibleNodes)
+ if currentIndex > 0 {
+ currentIndex--
+ tv.selectedNode = visibleNodes[currentIndex]
+ // Adjust scrollOffset to keep selectedNode in view
+ if currentIndex < tv.scrollOffset {
+ tv.scrollOffset = currentIndex
+ }
+ }
+}
+
+// updateVisibleNodes updates the visibleNodes slice based on scrollOffset and node expansion.
+func (tv *TreeView) updateVisibleNodes() {
+ var allVisible []*TreeNode
+ var traverse func(tn *TreeNode)
+ traverse = func(tn *TreeNode) {
+ allVisible = append(allVisible, tn)
+ if tn.ExpandedState {
+ for _, child := range tn.Children {
+ traverse(child)
+ }
+ }
+ }
+ for _, root := range tv.opts.nodes {
+ traverse(root)
+ }
+
+ tv.totalContentHeight = len(allVisible)
+
+ // Clamp scrollOffset
+ if tv.scrollOffset > tv.totalContentHeight-tv.canvasHeight {
+ tv.scrollOffset = tv.totalContentHeight - tv.canvasHeight
+ if tv.scrollOffset < 0 {
+ tv.scrollOffset = 0
+ }
+ }
+
+ tv.visibleNodes = allVisible
+}
+
+// truncateString truncates a string to fit within a specified width, appending "..." if truncated.
+func truncateString(s string, maxWidth int) string {
+ if runewidth.StringWidth(s) <= maxWidth {
+ return s
+ }
+
+ ellipsis := "…"
+ ellipsisWidth := runewidth.StringWidth(ellipsis)
+
+ if maxWidth <= ellipsisWidth {
+ return ellipsis // Return ellipsis if space is too small
+ }
+
+ truncatedWidth := 0
+ truncatedString := ""
+
+ for _, r := range s {
+ charWidth := runewidth.RuneWidth(r)
+ if truncatedWidth+charWidth+ellipsisWidth > maxWidth {
+ break
+ }
+ truncatedString += string(r)
+ truncatedWidth += charWidth
+ }
+
+ return truncatedString + ellipsis
+}
diff --git a/widgets/treeview/treeview_test.go b/widgets/treeview/treeview_test.go
new file mode 100644
index 00000000..ecd93e6d
--- /dev/null
+++ b/widgets/treeview/treeview_test.go
@@ -0,0 +1,571 @@
+// treeview_test.go
+package treeview
+
+import (
+ "image"
+ "testing"
+ "time"
+
+ "github.com/mum4k/termdash/cell"
+ "github.com/mum4k/termdash/keyboard"
+ "github.com/mum4k/termdash/mouse"
+ "github.com/mum4k/termdash/terminal/terminalapi"
+ "github.com/mum4k/termdash/widgetapi"
+)
+
+// MockCanvas is a mock implementation of canvas.Canvas for testing purposes.
+type MockCanvas struct {
+ Cells map[image.Point]rune
+}
+
+func NewMockCanvas() *MockCanvas {
+ return &MockCanvas{
+ Cells: make(map[image.Point]rune),
+ }
+}
+
+// SetCell sets a rune at the specified point.
+func (mc *MockCanvas) SetCell(p image.Point, r rune, opts ...cell.Option) (bool, error) {
+ mc.Cells[p] = r
+ return true, nil
+}
+
+// Clear clears the canvas.
+func (mc *MockCanvas) Clear() error {
+ mc.Cells = make(map[image.Point]rune)
+ return nil
+}
+
+// Area returns the area of the canvas.
+func (mc *MockCanvas) Area() image.Rectangle {
+ return image.Rect(0, 0, 80, 24) // Default terminal size
+}
+
+// Write writes a string starting at the given point.
+func (mc *MockCanvas) Write(p image.Point, s string, opts ...cell.Option) error {
+ for i, char := range s {
+ mc.Cells[image.Point{X: p.X + i, Y: p.Y}] = char
+ }
+ return nil
+}
+
+// MockMeta is a mock implementation of widgetapi.Meta for testing purposes.
+type MockMeta struct {
+ area image.Rectangle
+}
+
+func NewMockMeta(area image.Rectangle) *MockMeta {
+ return &MockMeta{
+ area: area,
+ }
+}
+
+// Area returns the area of the widget.
+func (m *MockMeta) Area() image.Rectangle {
+ return m.area
+}
+
+// TestNew tests the initialization of the Treeview widget.
+func TestNew(t *testing.T) {
+ root := []*TreeNode{
+ {
+ Label: "Root",
+ Children: []*TreeNode{
+ {Label: "Child1"},
+ {Label: "Child2"},
+ },
+ },
+ }
+
+ tv, err := New(
+ Nodes(root...),
+ Indentation(4),
+ Icons("▼", "▶", "•"), // Corrected Icons order
+ LabelColor(cell.ColorRed),
+ WaitingIcons([]string{"|", "/", "-", "\\"}),
+ Truncate(true),
+ EnableLogging(false),
+ )
+ if err != nil {
+ t.Fatalf("Failed to create Treeview: %v", err)
+ }
+
+ // Verify selectedNode is initialized to "Root"
+ if tv.selectedNode == nil {
+ t.Errorf("Expected selectedNode to be initialized, got nil")
+ } else if tv.selectedNode.Label != "Root" {
+ t.Errorf("Expected selectedNode to be 'Root', got '%s'", tv.selectedNode.Label)
+ }
+
+ // Verify the number of root nodes
+ if len(tv.opts.nodes) != 1 {
+ t.Errorf("Expected 1 root node, got %d", len(tv.opts.nodes))
+ }
+
+ // Verify indentation
+ if tv.opts.indentation != 4 {
+ t.Errorf("Expected indentation to be 4, got %d", tv.opts.indentation)
+ }
+
+ // Verify Icons
+ if tv.opts.expandedIcon != "▼" || tv.opts.collapsedIcon != "▶" || tv.opts.leafIcon != "•" {
+ t.Errorf("Icons not set correctly: got expandedIcon=%s, collapsedIcon=%s, leafIcon=%s",
+ tv.opts.expandedIcon, tv.opts.collapsedIcon, tv.opts.leafIcon)
+ }
+
+ // Verify LabelColor
+ if tv.opts.labelColor != cell.ColorRed {
+ t.Errorf("Expected labelColor to be Red, got %v", tv.opts.labelColor)
+ }
+
+ // Verify WaitingIcons
+ if len(tv.opts.waitingIcons) != 4 {
+ t.Errorf("Expected 4 waitingIcons, got %d", len(tv.opts.waitingIcons))
+ }
+
+ // Verify Truncate
+ if !tv.opts.truncate {
+ t.Errorf("Expected truncate to be true")
+ }
+
+ // Verify EnableLogging
+ if tv.opts.enableLogging {
+ t.Errorf("Expected enableLogging to be false")
+ }
+}
+
+// TestNextPrevious tests navigating through the nodes using Next and Previous methods.
+func TestNextPrevious(t *testing.T) {
+ root := []*TreeNode{
+ {
+ Label: "Root",
+ Children: []*TreeNode{
+ {Label: "Child1"},
+ {Label: "Child2"},
+ {Label: "Child3"},
+ },
+ },
+ }
+
+ tv, err := New(
+ Nodes(root...),
+ Indentation(4),
+ Icons("▼", "▶", "•"),
+ )
+ if err != nil {
+ t.Fatalf("Failed to create Treeview: %v", err)
+ }
+
+ // Manually set Root to be expanded to make children visible
+ root[0].ExpandedState = true
+ tv.updateVisibleNodes()
+
+ // Initially selected node should be "Root"
+ if tv.selectedNode.Label != "Root" {
+ t.Errorf("Expected selectedNode to be 'Root', got '%s'", tv.selectedNode.Label)
+ }
+
+ // Navigate down to "Child1"
+ tv.Next()
+ if tv.selectedNode.Label != "Child1" {
+ t.Errorf("Expected selectedNode to be 'Child1', got '%s'", tv.selectedNode.Label)
+ }
+
+ // Navigate down to "Child2"
+ tv.Next()
+ if tv.selectedNode.Label != "Child2" {
+ t.Errorf("Expected selectedNode to be 'Child2', got '%s'", tv.selectedNode.Label)
+ }
+
+ // Navigate up to "Child1"
+ tv.Previous()
+ if tv.selectedNode.Label != "Child1" {
+ t.Errorf("Expected selectedNode to be 'Child1', got '%s'", tv.selectedNode.Label)
+ }
+
+ // Navigate up to "Root"
+ tv.Previous()
+ if tv.selectedNode.Label != "Root" {
+ t.Errorf("Expected selectedNode to be 'Root', got '%s'", tv.selectedNode.Label)
+ }
+
+ // Navigate up at top; should stay at "Root"
+ tv.Previous()
+ if tv.selectedNode.Label != "Root" {
+ t.Errorf("Expected selectedNode to remain 'Root', got '%s'", tv.selectedNode.Label)
+ }
+}
+
+// TestMouseScroll adjusted to align with actual behavior
+func TestMouseScroll(t *testing.T) {
+ root := []*TreeNode{
+ {
+ Label: "Root",
+ Children: []*TreeNode{
+ {Label: "Child1"},
+ {Label: "Child2"},
+ {Label: "Child3"},
+ {Label: "Child4"},
+ {Label: "Child5"},
+ },
+ },
+ }
+
+ tv, err := New(Nodes(root...), Indentation(2))
+ if err != nil {
+ t.Fatalf("Failed to create Treeview: %v", err)
+ }
+
+ // Mock a large canvas height
+ tv.canvasHeight = 3
+ tv.updateVisibleNodes()
+
+ // Initially, scrollOffset should be 0
+ if tv.scrollOffset != 0 {
+ t.Errorf("Expected initial scrollOffset to be 0, got %d", tv.scrollOffset)
+ }
+
+ // Simulate mouse wheel down
+ mouseEvent := &terminalapi.Mouse{
+ Button: mouse.ButtonWheelDown,
+ Position: image.Point{X: 0, Y: 0},
+ }
+
+ err = tv.Mouse(mouseEvent, &widgetapi.EventMeta{})
+ if err != nil {
+ t.Errorf("Mouse method returned an error: %v", err)
+ }
+
+ // After scrolling down, scrollOffset should be updated accordingly
+ maxOffset := len(tv.visibleNodes) - tv.canvasHeight
+ if tv.scrollOffset != maxOffset {
+ t.Errorf("Expected scrollOffset to be clamped to %d, got %d", maxOffset, tv.scrollOffset)
+ }
+
+ // Simulate mouse wheel up
+ mouseEvent = &terminalapi.Mouse{
+ Button: mouse.ButtonWheelUp,
+ Position: image.Point{X: 0, Y: 0},
+ }
+
+ err = tv.Mouse(mouseEvent, &widgetapi.EventMeta{})
+ if err != nil {
+ t.Errorf("Mouse method returned an error: %v", err)
+ }
+
+ // After scrolling up, scrollOffset should be 0
+ if tv.scrollOffset != 0 {
+ t.Errorf("Expected scrollOffset to be clamped to 0, got %d", tv.scrollOffset)
+ }
+}
+
+// TestKeyboardScroll tests keyboard navigation in the Treeview
+func TestKeyboardScroll(t *testing.T) {
+ root := []*TreeNode{
+ {
+ Label: "Root",
+ Children: []*TreeNode{
+ {Label: "Child1"},
+ {Label: "Child2"},
+ {Label: "Child3"},
+ {Label: "Child4"},
+ {Label: "Child5"},
+ },
+ },
+ }
+
+ tv, err := New(Nodes(root...), Indentation(2))
+ if err != nil {
+ t.Fatalf("Failed to create Treeview: %v", err)
+ }
+
+ // Mock a canvas height of 3
+ tv.canvasHeight = 3
+ tv.updateVisibleNodes()
+
+ // Navigate to Child1
+ tv.Keyboard(&terminalapi.Keyboard{Key: keyboard.KeyArrowDown}, &widgetapi.EventMeta{})
+ if tv.selectedNode.Label != "Child1" {
+ t.Errorf("Expected selectedNode to be 'Child1', got '%s'", tv.selectedNode.Label)
+ }
+
+ // Navigate to Child2
+ tv.Keyboard(&terminalapi.Keyboard{Key: keyboard.KeyArrowDown}, &widgetapi.EventMeta{})
+ if tv.selectedNode.Label != "Child2" {
+ t.Errorf("Expected selectedNode to be 'Child2', got '%s'", tv.selectedNode.Label)
+ }
+
+ // Ensure scrollOffset is updated correctly when navigating further
+ tv.Keyboard(&terminalapi.Keyboard{Key: keyboard.KeyArrowDown}, &widgetapi.EventMeta{})
+ if tv.selectedNode.Label != "Child3" {
+ t.Errorf("Expected selectedNode to be 'Child3', got '%s'", tv.selectedNode.Label)
+ }
+
+ if tv.scrollOffset != 1 {
+ t.Errorf("Expected scrollOffset to be 1, got %d", tv.scrollOffset)
+ }
+}
+
+// TestUpdateVisibleNodes tests the visibility of nodes based on expansion state.
+func TestUpdateVisibleNodes(t *testing.T) {
+ root := []*TreeNode{
+ {
+ Label: "Root",
+ Children: []*TreeNode{
+ {Label: "Child1"},
+ {Label: "Child2"},
+ {Label: "Child3"},
+ },
+ },
+ }
+
+ tv, err := New(Nodes(root...), Indentation(2))
+ if err != nil {
+ t.Fatalf("Failed to create Treeview: %v", err)
+ }
+
+ // Lock the Treeview before modifying node states
+ tv.mu.Lock()
+ root[0].SetExpandedState(true)
+ root[0].Children[0].SetExpandedState(false)
+ tv.mu.Unlock()
+
+ tv.updateVisibleNodes()
+
+ // Lock before accessing visibleNodes
+ tv.mu.Lock()
+ visibleNodes := make([]string, len(tv.visibleNodes))
+ for i, node := range tv.visibleNodes {
+ visibleNodes[i] = node.Label
+ }
+ tv.mu.Unlock()
+
+ expectedVisible := []string{"Root", "Child1", "Child2", "Child3"}
+
+ if len(visibleNodes) != len(expectedVisible) {
+ t.Errorf("Expected %d visible nodes, got %d", len(expectedVisible), len(visibleNodes))
+ }
+
+ for i, label := range expectedVisible {
+ if i >= len(visibleNodes) || visibleNodes[i] != label {
+ t.Errorf("Expected node at index %d to be '%s', got '%s'", i, label, visibleNodes[i])
+ }
+ }
+}
+
+// TestNodeExpansionAndCollapse adjusted for actual behavior
+func TestNodeExpansionAndCollapse(t *testing.T) {
+ root := []*TreeNode{
+ {
+ Label: "Root",
+ Children: []*TreeNode{
+ {Label: "Child1"},
+ {Label: "Child2"},
+ },
+ },
+ }
+
+ tv, err := New(Nodes(root...), Indentation(2))
+ if err != nil {
+ t.Fatalf("Failed to create Treeview: %v", err)
+ }
+
+ // Initially, all nodes should be visible
+ tv.updateVisibleNodes()
+ if len(tv.visibleNodes) != 3 { // Root + 2 children
+ t.Errorf("Expected 3 visible nodes, got %d", len(tv.visibleNodes))
+ }
+
+ // Collapse Root
+ root[0].SetExpandedState(false)
+ tv.updateVisibleNodes()
+ if len(tv.visibleNodes) != 1 { // Only Root
+ t.Errorf("Expected 1 visible node after collapsing Root, got %d", len(tv.visibleNodes))
+ }
+
+ // Expand Root again
+ root[0].SetExpandedState(true)
+ tv.updateVisibleNodes()
+ if len(tv.visibleNodes) != 3 { // Root + 2 children
+ t.Errorf("Expected 3 visible nodes after expanding Root, got %d", len(tv.visibleNodes))
+ }
+}
+
+// TestSelectNoVisibleNodes tests selecting a node when no nodes are visible.
+func TestSelectNoVisibleNodes(t *testing.T) {
+ root := []*TreeNode{
+ {
+ Label: "Root",
+ Children: []*TreeNode{}, // No children, making it a leaf node
+ },
+ }
+
+ tv, err := New(
+ Nodes(root...),
+ Indentation(2),
+ Icons("▼", "▶", "•"),
+ )
+ if err != nil {
+ t.Fatalf("Failed to create Treeview: %v", err)
+ }
+
+ // Manually set selectedNode to nil to simulate no visible nodes
+ tv.selectedNode = nil
+
+ label, err := tv.Select()
+ if err == nil {
+ t.Errorf("Expected Select to return an error when no node is selected")
+ }
+
+ if label != "" {
+ t.Errorf("Expected Select to return empty string when no node is selected, got '%s'", label)
+ }
+}
+
+// TestKeyboardNonArrowKeys tests that non-arrow keys do not affect navigation.
+func TestKeyboardNonArrowKeys(t *testing.T) {
+ root := []*TreeNode{
+ {
+ Label: "Root",
+ Children: []*TreeNode{
+ {Label: "Child1"},
+ },
+ },
+ }
+
+ tv, err := New(
+ Nodes(root...),
+ Indentation(2),
+ Icons("▼", "▶", "•"),
+ )
+ if err != nil {
+ t.Fatalf("Failed to create Treeview: %v", err)
+ }
+
+ // Manually expand Root to make children visible
+ root[0].ExpandedState = true
+ tv.updateVisibleNodes()
+
+ // Initial selection is "Root"
+ if tv.selectedNode.Label != "Root" {
+ t.Errorf("Expected selectedNode to be 'Root', got '%s'", tv.selectedNode.Label)
+ }
+
+ // Send a non-arrow key event (e.g., 'a')
+ tv.Keyboard(&terminalapi.Keyboard{Key: 'a'}, &widgetapi.EventMeta{})
+ if tv.selectedNode.Label != "Root" {
+ t.Errorf("Expected selectedNode to remain 'Root', got '%s'", tv.selectedNode.Label)
+ }
+}
+
+// TestRunSpinner tests the runSpinner method.
+func TestRunSpinner(t *testing.T) {
+ root := []*TreeNode{
+ {
+ Label: "Root",
+ Children: []*TreeNode{
+ {Label: "Child1"},
+ },
+ },
+ }
+
+ tv, err := New(
+ Nodes(root...),
+ WaitingIcons([]string{"|", "/", "-", "\\"}),
+ )
+ if err != nil {
+ t.Fatalf("Failed to create TreeView: %v", err)
+ }
+
+ // Start spinner on "Child1"
+ root[0].Children[0].SetShowSpinner(true)
+
+ // Wait to allow spinner to update
+ time.Sleep(500 * time.Millisecond)
+
+ // Check that SpinnerIndex has incremented
+ root[0].Children[0].mu.Lock()
+ spinnerIndex := root[0].Children[0].SpinnerIndex
+ root[0].Children[0].mu.Unlock()
+
+ if spinnerIndex == 0 {
+ t.Errorf("Expected SpinnerIndex to have incremented, got %d", spinnerIndex)
+ }
+
+ // Stop the spinner ticker
+ tv.StopSpinnerTicker()
+}
+
+// TestHandleNodeClick tests the handleNodeClick method.
+// TestHandleNodeClick tests the handleNodeClick method.
+func TestHandleNodeClick(t *testing.T) {
+ // Create a channel to signal when OnClick has been executed.
+ clickCh := make(chan struct{})
+
+ // Define the tree structure with an OnClick handler.
+ root := []*TreeNode{
+ {
+ Label: "Root",
+ Children: []*TreeNode{
+ {
+ Label: "Child1",
+ OnClick: func() error {
+ // Signal that OnClick has been called.
+ clickCh <- struct{}{}
+ return nil
+ },
+ },
+ },
+ },
+ }
+
+ // Initialize the TreeView.
+ tv, err := New(
+ Nodes(root...),
+ Indentation(2),
+ Icons("▼", "▶", "•"),
+ )
+ if err != nil {
+ t.Fatalf("Failed to create TreeView: %v", err)
+ }
+
+ // Expand Root to make its children visible.
+ root[0].SetExpandedState(true)
+ tv.updateVisibleNodes()
+
+ // Select "Child1".
+ tv.selectedNode = root[0].Children[0]
+ err = tv.handleNodeClick(tv.selectedNode)
+ if err != nil {
+ t.Errorf("handleNodeClick returned an error: %v", err)
+ }
+
+ // Wait for the OnClick handler to signal completion or timeout after 1 second.
+ select {
+ case <-clickCh:
+ // OnClick was called successfully.
+ case <-time.After(1 * time.Second):
+ t.Errorf("OnClick was not called within the expected time")
+ }
+
+ // Verify that the spinner has been reset.
+ if tv.selectedNode.GetShowSpinner() {
+ t.Errorf("Expected spinner to be false after OnClick execution")
+ }
+}
+
+// TestTruncateString tests the truncateString function.
+func TestTruncateString(t *testing.T) {
+ longString := "This is a very long string that should be truncated"
+ truncated := truncateString(longString, 10)
+
+ if truncated != "This is a…" {
+ t.Errorf("Expected 'This is a…', got '%s'", truncated)
+ }
+
+ // Test with a maxWidth smaller than ellipsis
+ truncated = truncateString(longString, 1)
+ if truncated != "…" {
+ t.Errorf("Expected '…', got '%s'", truncated)
+ }
+}
diff --git a/widgets/treeview/treeviewdemo/treeviewdemo.go b/widgets/treeview/treeviewdemo/treeviewdemo.go
new file mode 100644
index 00000000..5cc082fc
--- /dev/null
+++ b/widgets/treeview/treeviewdemo/treeviewdemo.go
@@ -0,0 +1,349 @@
+// treeviewdemo.go
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "math/rand"
+ "sync"
+ "time"
+
+ "github.com/mum4k/termdash"
+ "github.com/mum4k/termdash/cell"
+ "github.com/mum4k/termdash/container"
+ "github.com/mum4k/termdash/container/grid"
+ "github.com/mum4k/termdash/keyboard"
+ "github.com/mum4k/termdash/linestyle"
+ "github.com/mum4k/termdash/terminal/tcell"
+ "github.com/mum4k/termdash/terminal/terminalapi"
+ "github.com/mum4k/termdash/widgets/donut"
+ "github.com/mum4k/termdash/widgets/sparkline"
+ "github.com/mum4k/termdash/widgets/text"
+ "github.com/mum4k/termdash/widgets/treeview"
+)
+
+// NodeData holds arbitrary data associated with a tree node.
+type NodeData struct {
+ // Label is the label of the node.
+ Label string
+ // PID is the process ID associated with the node.
+ PID int
+ // CPUUsage is a slice of CPU usage percentages.
+ CPUUsage []int
+ // MemoryUsage is the current memory usage percentage.
+ MemoryUsage int
+ // LastCPUUsage is the last recorded CPU usage percentage.
+ LastCPUUsage int
+ // LastMemoryUsage is the last recorded memory usage percentage.
+ LastMemoryUsage int
+}
+
+// Helper function to generate smoother data.
+func generateNextValue(prev int) int {
+ delta := rand.Intn(5) - 2 // Random change between -2 and +2
+ next := prev + delta
+ if next < 0 {
+ next = 0
+ } else if next > 100 {
+ next = 100
+ }
+ return next
+}
+
+// fetchStaticData creates a static tree structure with associated data.
+func fetchStaticData() ([]*treeview.TreeNode, map[string]*NodeData, error) {
+ var nodeDataMap = make(map[string]*NodeData)
+
+ // Seed random number generator.
+ rand.Seed(time.Now().UnixNano())
+
+ // Create the root node.
+ root := &treeview.TreeNode{
+ Label: "Applications",
+ }
+
+ // Helper function to recursively build the tree and assign IDs.
+ var buildTree func(tn *treeview.TreeNode, path string)
+ buildTree = func(tn *treeview.TreeNode, path string) {
+ tn.ID = generateNodeID(path, tn.Label)
+
+ if len(tn.Children) == 0 {
+ // Leaf node: assign data.
+ pid := rand.Intn(9000) + 1000 // Random PID between 1000 and 9999.
+ initialCPUUsage := rand.Intn(100)
+ initialMemoryUsage := rand.Intn(100)
+ data := &NodeData{
+ Label: tn.Label,
+ PID: pid,
+ CPUUsage: []int{},
+ MemoryUsage: initialMemoryUsage,
+ LastCPUUsage: initialCPUUsage,
+ LastMemoryUsage: initialMemoryUsage,
+ }
+ // Initialize CPUUsage slice with initial values.
+ for i := 0; i < 20; i++ {
+ data.LastCPUUsage = generateNextValue(data.LastCPUUsage)
+ data.CPUUsage = append(data.CPUUsage, data.LastCPUUsage)
+ }
+ nodeDataMap[tn.ID] = data
+ } else {
+ // Recursively assign IDs to child nodes.
+ for _, child := range tn.Children {
+ buildTree(child, tn.ID)
+ }
+ }
+ }
+
+ // Build the tree structure.
+ for i := 1; i <= 10; i++ {
+ appNode := &treeview.TreeNode{
+ Label: fmt.Sprintf("Application %d", i),
+ }
+ for j := 1; j <= 5; j++ {
+ subAppNode := &treeview.TreeNode{
+ Label: fmt.Sprintf("SubApp %d.%d", i, j),
+ }
+ for k := 1; k <= 3; k++ {
+ featureNode := &treeview.TreeNode{
+ Label: fmt.Sprintf("Feature %d.%d.%d", i, j, k),
+ }
+ subAppNode.Children = append(subAppNode.Children, featureNode)
+ }
+ appNode.Children = append(appNode.Children, subAppNode)
+ }
+ root.Children = append(root.Children, appNode)
+ }
+
+ // Assign IDs and build nodeDataMap.
+ buildTree(root, "")
+
+ return []*treeview.TreeNode{root}, nodeDataMap, nil
+}
+
+// generateNodeID creates a consistent node ID.
+func generateNodeID(path string, label string) string {
+ if path == "" {
+ return label
+ }
+ return fmt.Sprintf("%s/%s", path, label)
+}
+
+// updateWidgets updates the widgets with data from the selected node.
+func updateWidgets(data *NodeData, memDonut *donut.Donut, spark *sparkline.SparkLine, detailText *text.Text) {
+ // Use data to update widgets.
+ spark.Add([]int{data.LastCPUUsage})
+ memDonut.Percent(data.MemoryUsage)
+
+ detailText.Reset()
+ detailText.Write(fmt.Sprintf(
+ "Selected Node: %s\n"+
+ "PID: %d\n"+
+ "CPU Usage: %d%%\n"+
+ "Memory Usage: %d%%\n",
+ data.Label,
+ data.PID,
+ data.LastCPUUsage,
+ data.MemoryUsage,
+ ))
+}
+
+func main() {
+ // Initialize terminal.
+ t, err := tcell.New()
+ if err != nil {
+ log.Fatalf("failed to create terminal: %v", err)
+ }
+ defer t.Close()
+
+ // Fetch static tree data.
+ processTree, nodeDataMap, err := fetchStaticData()
+ if err != nil {
+ log.Fatalf("failed to fetch static data: %v", err)
+ }
+
+ // Create widgets.
+ memDonut, err := donut.New(
+ donut.CellOpts(cell.FgColor(cell.ColorGreen)),
+ donut.Label("Memory Usage", cell.FgColor(cell.ColorYellow)),
+ )
+ if err != nil {
+ log.Fatalf("failed to create donut widget: %v", err)
+ }
+
+ spark, err := sparkline.New(
+ sparkline.Color(cell.ColorBlue),
+ sparkline.Label("CPU Usage"),
+ )
+ if err != nil {
+ log.Fatalf("failed to create sparkline widget: %v", err)
+ }
+
+ detailText, err := text.New(
+ text.WrapAtWords(),
+ )
+ if err != nil {
+ log.Fatalf("failed to create text widget: %v", err)
+ }
+
+ // Mutex to protect access to the selected node.
+ var mu sync.Mutex
+ var selectedNodeID string
+
+ // Create TreeView widget with logging enabled for debugging.
+ tv, err := treeview.New(
+ treeview.Label("Applications TreeView"),
+ treeview.Nodes(processTree...),
+ treeview.LabelColor(cell.ColorBlue),
+ treeview.CollapsedIcon("▶"),
+ treeview.ExpandedIcon("▼"),
+ treeview.WaitingIcons([]string{"◐", "◓", "◑", "◒"}),
+ treeview.LeafIcon(""),
+ treeview.Indentation(2),
+ treeview.Truncate(true),
+ treeview.EnableLogging(false),
+ )
+ if err != nil {
+ log.Fatalf("failed to create TreeView: %v", err)
+ }
+
+ // Assign OnClick handlers to leaf nodes only.
+ var assignOnClick func(tn *treeview.TreeNode)
+ assignOnClick = func(tn *treeview.TreeNode) {
+ // Assign OnClick only to leaf nodes.
+ if len(tn.Children) == 0 {
+ tn := tn // Capture range variable.
+ tn.OnClick = func() error {
+ mu.Lock()
+ selectedNodeID = tn.ID
+ mu.Unlock()
+
+ data := nodeDataMap[tn.ID]
+ if data != nil {
+ updateWidgets(data, memDonut, spark, detailText)
+ } else {
+ // Clear the widgets if no data is associated.
+ spark.Add([]int{0})
+ memDonut.Percent(0)
+ detailText.Reset()
+ detailText.Write(fmt.Sprintf("Selected Node: %s\n", tn.Label))
+ detailText.Write("No data available for this node.")
+ }
+
+ // Simulate a longer-running process to make the spinner visible.
+ time.Sleep(500 * time.Millisecond)
+
+ return nil
+ }
+ }
+ for _, child := range tn.Children {
+ assignOnClick(child)
+ }
+ }
+
+ for _, tn := range processTree {
+ assignOnClick(tn)
+ }
+
+ // Build grid layout.
+ builder := grid.New()
+ builder.Add(
+ grid.RowHeightPerc(70,
+ grid.ColWidthPerc(40,
+ grid.Widget(tv,
+ container.Border(linestyle.Light),
+ container.BorderTitle("Applications TreeView"),
+ container.Focused(), // Set initial focus to the TreeView
+ ),
+ ),
+ grid.ColWidthPerc(30,
+ grid.Widget(memDonut,
+ container.Border(linestyle.Light),
+ container.BorderTitle("Memory Usage"),
+ ),
+ ),
+ grid.ColWidthPerc(30,
+ grid.Widget(spark,
+ container.Border(linestyle.Light),
+ container.BorderTitle("CPU Sparkline"),
+ ),
+ ),
+ ),
+ grid.RowHeightPerc(30,
+ grid.Widget(detailText,
+ container.Border(linestyle.Light),
+ container.BorderTitle("Process Details"),
+ ),
+ ),
+ )
+
+ gridOpts, err := builder.Build()
+ if err != nil {
+ log.Fatalf("failed to build grid layout: %v", err)
+ }
+
+ // Create container.
+ c, err := container.New(
+ t,
+ gridOpts...,
+ )
+ if err != nil {
+ log.Fatalf("failed to create container: %v", err)
+ }
+
+ // Context for termdash.
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ // Start a goroutine to continuously update the widgets.
+ go func() {
+ ticker := time.NewTicker(1 * time.Second)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ticker.C:
+ mu.Lock()
+ id := selectedNodeID
+ mu.Unlock()
+
+ if id != "" {
+ data := nodeDataMap[id]
+ if data != nil {
+ // Update data with new values.
+ data.LastCPUUsage = generateNextValue(data.LastCPUUsage)
+ data.CPUUsage = append(data.CPUUsage, data.LastCPUUsage)
+ if len(data.CPUUsage) > 20 {
+ data.CPUUsage = data.CPUUsage[1:]
+ }
+ data.LastMemoryUsage = generateNextValue(data.LastMemoryUsage)
+ data.MemoryUsage = data.LastMemoryUsage
+
+ // Update widgets.
+ updateWidgets(data, memDonut, spark, detailText)
+ }
+ }
+ case <-ctx.Done():
+ return
+ }
+ }
+ }()
+
+ // Global key press handler to exit on 'q' or 'Esc'.
+ quitter := func(k *terminalapi.Keyboard) {
+ if k.Key == keyboard.KeyEsc || k.Key == 'q' {
+ cancel()
+ }
+ }
+
+ // Run termdash.
+ if err := termdash.Run(ctx, t, c,
+ termdash.KeyboardSubscriber(quitter),
+ termdash.RedrawInterval(500*time.Millisecond),
+ ); err != nil {
+ log.Fatalf("failed to run termdash: %v", err)
+ }
+
+ // Ensure spinner ticker is stopped.
+ tv.StopSpinnerTicker()
+}