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 [segmentdisplaydemo](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 +``` + +[treeviewdemo](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() +}