1
0
mirror of https://github.com/mum4k/termdash.git synced 2025-04-25 13:48:50 +08:00

Added missing comments to types, added more tests, changed node to tv, changed Treeview to TreeView

This commit is contained in:
Keith Knott 2024-09-23 19:37:34 -04:00
parent cd6f571a1b
commit d9ab04a88b
4 changed files with 345 additions and 264 deletions

View File

@ -2,30 +2,31 @@ package treeview
import "github.com/mum4k/termdash/cell"
// Option represents a configuration option for the Treeview.
// Option represents a configuration option for the TreeView.
type Option func(*options)
// options holds the configuration for the Treeview.
// options holds the configuration for the TreeView.
type options struct {
nodes []*TreeNode
labelColor cell.Color
expandedIcon string
// 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 string
indentation int
waitingIcons []string
truncate bool
// 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.
// Sample spinners:
// []string{'←','↖','↑','↗','→','↘','↓','↙'}
// []string{'◰','◳','◲','◱'}
// []string{'◴','◷','◶','◵'}
// []string{'◐','◓','◑','◒'}
// []string{'x','+'}
// []string{'⣾','⣽','⣻','⢿','⡿','⣟','⣯','⣷'}
// newOptions initializes default options.
func newOptions() *options {
return &options{
@ -40,7 +41,7 @@ func newOptions() *options {
}
}
// Nodes sets the root nodes of the Treeview.
// Nodes sets the root nodes of the TreeView.
func Nodes(nodes ...*TreeNode) Option {
return func(o *options) {
o.nodes = nodes
@ -95,7 +96,7 @@ func EnableLogging(enable bool) Option {
// 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.
// No action needed; label is set in container's BorderTitle.
}
}

View File

@ -19,78 +19,124 @@ import (
"github.com/mum4k/termdash/widgetapi"
)
// Number of nodes to scroll per mouse wheel event
// Number of nodes to scroll per mouse wheel event.
const (
ScrollStep = 5
)
// TreeNode represents a node in the treeview.
type TreeNode struct {
ID string
Label string
Level int
Parent *TreeNode
Children []*TreeNode
Value interface{} // Can hold any data type
ShowSpinner bool
OnClick func() error
ExpandedState bool // Unique expanded state for each node
SpinnerIndex int // Current index for spinner icons
mu sync.Mutex
// 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 (node *TreeNode) SetShowSpinner(value bool) {
node.mu.Lock()
node.ShowSpinner = value
func (tn *TreeNode) SetShowSpinner(value bool) {
tn.mu.Lock()
defer tn.mu.Unlock()
tn.ShowSpinner = value
if !value {
node.SpinnerIndex = 0 // Reset spinner index when spinner is turned off
tn.SpinnerIndex = 0 // Reset spinner index when spinner is turned off
}
node.mu.Unlock()
}
// GetShowSpinner safely retrieves the ShowSpinner flag.
func (node *TreeNode) GetShowSpinner() bool {
return node.ShowSpinner
func (tn *TreeNode) GetShowSpinner() bool {
tn.mu.Lock()
defer tn.mu.Unlock()
return tn.ShowSpinner
}
// IncrementSpinner safely increments the SpinnerIndex.
func (node *TreeNode) IncrementSpinner(totalIcons int) {
node.mu.Lock()
node.SpinnerIndex = (node.SpinnerIndex + 1) % totalIcons
node.mu.Unlock()
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 (node *TreeNode) IsRoot() bool {
return node.Parent == nil
func (tn *TreeNode) IsRoot() bool {
return tn.Parent == nil
}
// Treeview represents the treeview widget.
type Treeview struct {
mu sync.Mutex
position image.Point // Stores the widget's top-left position
opts *options
selectedNode *TreeNode
visibleNodes []*TreeNode
logger *log.Logger
spinnerTicker *time.Ticker
stopSpinner chan struct{}
expandedIcon string
collapsedIcon string
leafIcon string
scrollOffset int
// 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 int
canvasHeight int
totalContentHeight int
waitingIcons []string
lastClickTime time.Time // Timestamp of the last handled click
lastKeyTime time.Time // Timestamp for debouncing the enter key
// 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) {
// New creates a new TreeView instance.
func New(opts ...Option) (*TreeView, error) {
options := newOptions()
for _, opt := range opts {
opt(options)
@ -123,7 +169,7 @@ func New(opts ...Option) (*Treeview, error) {
logger = log.New(io.Discard, "", 0)
}
tv := &Treeview{
tv := &TreeView{
opts: options,
logger: logger,
stopSpinner: make(chan struct{}),
@ -160,32 +206,30 @@ func generateNodeID(path string, label string) string {
}
// setParentsAndAssignIDs assigns parent references, levels, and IDs to nodes recursively.
func setParentsAndAssignIDs(node *TreeNode, parent *TreeNode, level int, path string) {
node.Parent = parent
node.Level = level
func setParentsAndAssignIDs(tn *TreeNode, parent *TreeNode, level int, path string) {
tn.Parent = parent
tn.Level = level
node.ID = generateNodeID(path, node.Label)
tn.ID = generateNodeID(path, tn.Label)
for _, child := range node.Children {
setParentsAndAssignIDs(child, node, level+1, node.ID)
for _, child := range tn.Children {
setParentsAndAssignIDs(child, tn, level+1, tn.ID)
}
}
// runSpinner updates spinner indices periodically.
func (tv *Treeview) runSpinner() {
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 _, node := range visibleNodes {
//node.mu.Lock()
if node.GetShowSpinner() && len(tv.waitingIcons) > 0 {
node.IncrementSpinner(len(tv.waitingIcons))
tv.logger.Printf("Spinner updated for node: %s (SpinnerIndex: %d)", node.Label, node.SpinnerIndex)
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)
}
//node.mu.Unlock()
}
case <-tv.stopSpinner:
return
@ -194,7 +238,7 @@ func (tv *Treeview) runSpinner() {
}
// StopSpinnerTicker stops the spinner ticker.
func (tv *Treeview) StopSpinnerTicker() {
func (tv *TreeView) StopSpinnerTicker() {
if tv.spinnerTicker != nil {
tv.spinnerTicker.Stop()
close(tv.stopSpinner)
@ -202,20 +246,20 @@ func (tv *Treeview) StopSpinnerTicker() {
}
// setInitialExpandedState sets the initial expanded state for root nodes.
func setInitialExpandedState(tv *Treeview, expandRoot bool) {
for _, node := range tv.opts.nodes {
if node.IsRoot() {
node.SetExpandedState(expandRoot)
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(node *TreeNode) int {
func (tv *TreeView) calculateHeight(tn *TreeNode) int {
height := 1 // Start with the height of the current node
if node.ExpandedState {
for _, child := range node.Children {
if tn.ExpandedState {
for _, child := range tn.Children {
height += tv.calculateHeight(child)
}
}
@ -223,7 +267,7 @@ func (tv *Treeview) calculateHeight(node *TreeNode) int {
}
// calculateTotalHeight calculates the total height of all visible nodes.
func (tv *Treeview) calculateTotalHeight() int {
func (tv *TreeView) calculateTotalHeight() int {
totalHeight := 0
for _, rootNode := range tv.opts.nodes {
totalHeight += tv.calculateHeight(rootNode)
@ -232,19 +276,19 @@ func (tv *Treeview) calculateTotalHeight() int {
}
// updateTotalHeight updates the totalContentHeight based on visible nodes.
func (tv *Treeview) updateTotalHeight() {
func (tv *TreeView) updateTotalHeight() {
tv.totalContentHeight = tv.calculateTotalHeight()
}
// getVisibleNodesList retrieves a flat list of all currently visible nodes.
func (tv *Treeview) getVisibleNodesList() []*TreeNode {
func (tv *TreeView) getVisibleNodesList() []*TreeNode {
var list []*TreeNode
var traverse func(node *TreeNode)
traverse = func(node *TreeNode) {
list = append(list, node)
tv.logger.Printf("Visible Node Added: '%s' at Level %d", node.Label, node.Level)
if node.GetExpandedState() { // Use getter with mutex
for _, child := range node.Children {
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)
}
}
@ -256,13 +300,13 @@ func (tv *Treeview) getVisibleNodesList() []*TreeNode {
}
// getNodePrefix returns the appropriate prefix for a node based on its state.
func (tv *Treeview) getNodePrefix(node *TreeNode) string {
if node.GetShowSpinner() && len(tv.waitingIcons) > 0 {
return tv.waitingIcons[node.SpinnerIndex]
func (tv *TreeView) getNodePrefix(tn *TreeNode) string {
if tn.GetShowSpinner() && len(tv.waitingIcons) > 0 {
return tv.waitingIcons[tn.SpinnerIndex]
}
if len(node.Children) > 0 {
if node.ExpandedState {
if len(tn.Children) > 0 {
if tn.ExpandedState {
return tv.expandedIcon
}
return tv.collapsedIcon
@ -272,19 +316,19 @@ func (tv *Treeview) getNodePrefix(node *TreeNode) string {
}
// drawNode draws nodes based on the nodesToDraw slice.
func (tv *Treeview) drawNode(cvs *canvas.Canvas, nodesToDraw []*TreeNode) error {
for y, node := range nodesToDraw {
func (tv *TreeView) drawNode(cvs *canvas.Canvas, nodesToDraw []*TreeNode) error {
for y, tn := range nodesToDraw {
// Determine if this node is selected
isSelected := (node.ID == tv.selectedNode.ID)
isSelected := (tn.ID == tv.selectedNode.ID)
// Get the prefix based on node state
prefix := tv.getNodePrefix(node)
prefix := tv.getNodePrefix(tn)
prefixWidth := runewidth.StringWidth(prefix)
// Construct the label
label := fmt.Sprintf("%s %s", prefix, node.Label)
label := fmt.Sprintf("%s %s", prefix, tn.Label)
labelWidth := runewidth.StringWidth(label)
indentX := node.Level * tv.indentationPerLevel
indentX := tn.Level * tv.indentationPerLevel
availableWidth := tv.canvasWidth - indentX
if tv.opts.truncate && labelWidth > availableWidth {
@ -297,7 +341,7 @@ func (tv *Treeview) drawNode(cvs *canvas.Canvas, nodesToDraw []*TreeNode) error
}
// Log prefix width for debugging
tv.logger.Printf("Drawing node '%s' with prefix width %d", node.Label, prefixWidth)
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
@ -316,17 +360,17 @@ func (tv *Treeview) drawNode(cvs *canvas.Canvas, nodesToDraw []*TreeNode) error
}
// findNodeByClick determines which node was clicked based on x and y coordinates.
func (tv *Treeview) findNodeByClick(x, y int, visibleNodes []*TreeNode) *TreeNode {
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
}
node := visibleNodes[clickedIndex]
tn := visibleNodes[clickedIndex]
label := fmt.Sprintf("%s %s", tv.getNodePrefix(node), node.Label)
label := fmt.Sprintf("%s %s", tv.getNodePrefix(tn), tn.Label)
labelWidth := runewidth.StringWidth(label)
indentX := node.Level * tv.indentationPerLevel
indentX := tn.Level * tv.indentationPerLevel
availableWidth := tv.canvasWidth - indentX
if tv.opts.truncate && labelWidth > availableWidth {
@ -339,15 +383,15 @@ func (tv *Treeview) findNodeByClick(x, y int, visibleNodes []*TreeNode) *TreeNod
labelEndX := labelStartX + labelWidth
if x >= labelStartX && x < labelEndX {
tv.logger.Printf("Node '%s' (ID: %s) clicked at [X:%d Y:%d]", node.Label, node.ID, x, y)
return node
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 {
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)
@ -366,20 +410,20 @@ func (tv *Treeview) handleMouseClick(x, y int) error {
}
// handleNodeClick toggles the expansion state of a node and manages the spinner.
func (tv *Treeview) handleNodeClick(node *TreeNode) error {
tv.logger.Printf("Handling node click for: %s (ID: %s)", node.Label, node.ID)
if len(node.Children) > 0 {
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
node.SetExpandedState(!node.GetExpandedState())
tn.SetExpandedState(!tn.GetExpandedState())
tv.updateTotalHeight()
tv.logger.Printf("Toggled expansion for node: %s to %v", node.Label, node.ExpandedState)
tv.logger.Printf("Toggled expansion for node: %s to %v", tn.Label, tn.ExpandedState)
return nil
}
// Handle leaf node click
if node.OnClick != nil {
node.SetShowSpinner(true)
tv.logger.Printf("Spinner started for node: %s", node.Label)
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 {
@ -387,7 +431,7 @@ func (tv *Treeview) handleNodeClick(node *TreeNode) error {
}
n.SetShowSpinner(false)
tv.logger.Printf("Spinner stopped for node: %s", n.Label)
}(node)
}(tn)
}
return nil
@ -395,7 +439,8 @@ func (tv *Treeview) handleNodeClick(node *TreeNode) error {
// 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 {
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
@ -443,7 +488,7 @@ func (tv *Treeview) Mouse(m *terminalapi.Mouse, meta *widgetapi.EventMeta) error
}
// Keyboard handles keyboard events.
func (tv *Treeview) Keyboard(k *terminalapi.Keyboard, meta *widgetapi.EventMeta) error {
func (tv *TreeView) Keyboard(k *terminalapi.Keyboard, meta *widgetapi.EventMeta) error {
tv.mu.Lock()
visibleNodes := tv.visibleNodes
currentIndex := tv.getSelectedNodeIndex(visibleNodes)
@ -489,9 +534,9 @@ func (tv *Treeview) Keyboard(k *terminalapi.Keyboard, meta *widgetapi.EventMeta)
}
case keyboard.KeyEnter, ' ':
if currentIndex >= 0 && currentIndex < len(visibleNodes) {
node := visibleNodes[currentIndex]
tv.selectedNode = node
if err := tv.handleNodeClick(node); err != nil {
tn := visibleNodes[currentIndex]
tv.selectedNode = tn
if err := tv.handleNodeClick(tn); err != nil {
tv.logger.Println("Error handling node click:", err)
}
}
@ -502,24 +547,10 @@ func (tv *Treeview) Keyboard(k *terminalapi.Keyboard, meta *widgetapi.EventMeta)
return nil
}
// SetExpandedState safely sets the ExpandedState flag.
func (node *TreeNode) SetExpandedState(value bool) {
node.mu.Lock()
node.ExpandedState = value
node.mu.Unlock()
}
// GetExpandedState safely retrieves the ExpandedState flag.
func (node *TreeNode) GetExpandedState() bool {
node.mu.Lock()
defer node.mu.Unlock()
return node.ExpandedState
}
// getSelectedNodeIndex returns the index of the selected node in the visibleNodes list.
func (tv *Treeview) getSelectedNodeIndex(visibleNodes []*TreeNode) int {
for idx, node := range visibleNodes {
if node.ID == tv.selectedNode.ID {
func (tv *TreeView) getSelectedNodeIndex(visibleNodes []*TreeNode) int {
for idx, tn := range visibleNodes {
if tn.ID == tv.selectedNode.ID {
return idx
}
}
@ -527,7 +558,7 @@ func (tv *Treeview) getSelectedNodeIndex(visibleNodes []*TreeNode) int {
}
// drawScrollUp draws the scroll up indicator.
func (tv *Treeview) drawScrollUp(cvs *canvas.Canvas) error {
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
}
@ -535,7 +566,7 @@ func (tv *Treeview) drawScrollUp(cvs *canvas.Canvas) error {
}
// drawScrollDown draws the scroll down indicator.
func (tv *Treeview) drawScrollDown(cvs *canvas.Canvas) error {
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
}
@ -543,7 +574,7 @@ func (tv *Treeview) drawScrollDown(cvs *canvas.Canvas) error {
}
// 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 {
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() {
@ -565,7 +596,7 @@ func (tv *Treeview) drawLabel(cvs *canvas.Canvas, label string, x, y int, fgColo
}
// Draw renders the treeview widget.
func (tv *Treeview) Draw(cvs *canvas.Canvas, meta *widgetapi.Meta) error {
func (tv *TreeView) Draw(cvs *canvas.Canvas, meta *widgetapi.Meta) error {
tv.mu.Lock()
tv.updateVisibleNodes()
visibleNodes := tv.visibleNodes
@ -640,7 +671,7 @@ func (tv *Treeview) Draw(cvs *canvas.Canvas, meta *widgetapi.Meta) error {
}
// Options returns the widget options to satisfy the widgetapi.Widget interface.
func (tv *Treeview) Options() widgetapi.Options {
func (tv *TreeView) Options() widgetapi.Options {
return widgetapi.Options{
MinimumSize: image.Point{10, 3},
WantKeyboard: widgetapi.KeyScopeFocused,
@ -650,21 +681,21 @@ func (tv *Treeview) Options() widgetapi.Options {
}
// Select returns the label of the selected node.
func (tv *Treeview) Select() (string, error) {
func (tv *TreeView) Select() (string, error) {
tv.mu.Lock()
defer tv.mu.Unlock()
if tv.selectedNode != nil {
return tv.selectedNode.Label, nil
}
tv.mu.Unlock()
return "", errors.New("no option selected")
}
// Next moves the selection down.
func (tv *Treeview) Next() {
func (tv *TreeView) Next() {
tv.mu.Lock()
defer tv.mu.Unlock()
visibleNodes := tv.visibleNodes
currentIndex := tv.getSelectedNodeIndex(visibleNodes)
tv.mu.Unlock()
if currentIndex >= 0 && currentIndex < len(visibleNodes)-1 {
currentIndex++
tv.selectedNode = visibleNodes[currentIndex]
@ -676,11 +707,11 @@ func (tv *Treeview) Next() {
}
// Previous moves the selection up.
func (tv *Treeview) Previous() {
func (tv *TreeView) Previous() {
tv.mu.Lock()
defer tv.mu.Unlock()
visibleNodes := tv.visibleNodes
currentIndex := tv.getSelectedNodeIndex(visibleNodes)
tv.mu.Unlock()
if currentIndex > 0 {
currentIndex--
tv.selectedNode = visibleNodes[currentIndex]
@ -691,83 +722,14 @@ func (tv *Treeview) Previous() {
}
}
// findNearestVisibleNode finds the nearest visible node in the tree
func (tv *Treeview) findNearestVisibleNode(currentNode *TreeNode, visibleNodes []*TreeNode) *TreeNode {
if currentNode == nil {
return nil
}
if currentNode.Parent != nil {
parentNode := currentNode.Parent
for _, node := range visibleNodes {
if node.ID == parentNode.ID {
return parentNode
}
}
// If the parent is not visible, recursively search upwards
return tv.findNearestVisibleNode(parentNode, visibleNodes)
}
// If at the root and it's not visible, return the first visible node
if len(visibleNodes) > 0 {
return visibleNodes[0]
}
return nil // No visible nodes found
}
// findPreviousVisibleNode finds the previous visible node in the tree
func (tv *Treeview) findPreviousVisibleNode(currentNode *TreeNode) *TreeNode {
if currentNode == nil {
return nil
}
if currentNode.Parent == nil {
// If at the root, there's no previous node
return nil
}
parent := currentNode.Parent
siblings := parent.Children
currentIndex := -1
for i, sibling := range siblings {
if sibling.ID == currentNode.ID {
currentIndex = i
break
}
}
if currentIndex == -1 {
// Node not found among siblings, something is wrong
return nil
}
if currentIndex == 0 {
// If the current node is the first child, return the parent
return parent
}
previousSibling := siblings[currentIndex-1]
return tv.findLastVisibleDescendant(previousSibling)
}
// findLastVisibleDescendant finds the last visible descendant of a node
func (tv *Treeview) findLastVisibleDescendant(node *TreeNode) *TreeNode {
if !node.ExpandedState || len(node.Children) == 0 {
return node
}
// Since node is expanded and has children, go to the last child
lastChild := node.Children[len(node.Children)-1]
return tv.findLastVisibleDescendant(lastChild)
}
// updateVisibleNodes updates the visibleNodes slice based on scrollOffset and node expansion.
func (tv *Treeview) updateVisibleNodes() {
func (tv *TreeView) updateVisibleNodes() {
var allVisible []*TreeNode
var traverse func(node *TreeNode)
traverse = func(node *TreeNode) {
allVisible = append(allVisible, node)
if node.ExpandedState {
for _, child := range node.Children {
var traverse func(tn *TreeNode)
traverse = func(tn *TreeNode) {
allVisible = append(allVisible, tn)
if tn.ExpandedState {
for _, child := range tn.Children {
traverse(child)
}
}

View File

@ -4,6 +4,7 @@ package treeview
import (
"image"
"testing"
"time"
"github.com/mum4k/termdash/cell"
"github.com/mum4k/termdash/keyboard"
@ -456,3 +457,115 @@ func TestKeyboardNonArrowKeys(t *testing.T) {
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)
}
}

View File

@ -25,11 +25,17 @@ import (
// NodeData holds arbitrary data associated with a tree node.
type NodeData struct {
Label string
PID int
CPUUsage []int
MemoryUsage int
LastCPUUsage int
// 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
}
@ -58,17 +64,17 @@ func fetchStaticData() ([]*treeview.TreeNode, map[string]*NodeData, error) {
}
// Helper function to recursively build the tree and assign IDs.
var buildTree func(node *treeview.TreeNode, path string)
buildTree = func(node *treeview.TreeNode, path string) {
node.ID = generateNodeID(path, node.Label)
var buildTree func(tn *treeview.TreeNode, path string)
buildTree = func(tn *treeview.TreeNode, path string) {
tn.ID = generateNodeID(path, tn.Label)
if len(node.Children) == 0 {
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: node.Label,
Label: tn.Label,
PID: pid,
CPUUsage: []int{},
MemoryUsage: initialMemoryUsage,
@ -80,11 +86,11 @@ func fetchStaticData() ([]*treeview.TreeNode, map[string]*NodeData, error) {
data.LastCPUUsage = generateNextValue(data.LastCPUUsage)
data.CPUUsage = append(data.CPUUsage, data.LastCPUUsage)
}
nodeDataMap[node.ID] = data
nodeDataMap[tn.ID] = data
} else {
// Recursively assign IDs to child nodes.
for _, child := range node.Children {
buildTree(child, node.ID)
for _, child := range tn.Children {
buildTree(child, tn.ID)
}
}
}
@ -168,7 +174,6 @@ func main() {
spark, err := sparkline.New(
sparkline.Color(cell.ColorBlue),
sparkline.Label("CPU Usage"),
// Removed sparkline.Max(100) as it's not available
)
if err != nil {
log.Fatalf("failed to create sparkline widget: %v", err)
@ -185,9 +190,9 @@ func main() {
var mu sync.Mutex
var selectedNodeID string
// Create Treeview widget with logging enabled for debugging.
// Create TreeView widget with logging enabled for debugging.
tv, err := treeview.New(
treeview.Label("Applications Treeview"),
treeview.Label("Applications TreeView"),
treeview.Nodes(processTree...),
treeview.LabelColor(cell.ColorBlue),
treeview.CollapsedIcon("▶"),
@ -199,21 +204,21 @@ func main() {
treeview.EnableLogging(false),
)
if err != nil {
log.Fatalf("failed to create treeview: %v", err)
log.Fatalf("failed to create TreeView: %v", err)
}
// Assign OnClick handlers to leaf nodes only.
var assignOnClick func(node *treeview.TreeNode)
assignOnClick = func(node *treeview.TreeNode) {
var assignOnClick func(tn *treeview.TreeNode)
assignOnClick = func(tn *treeview.TreeNode) {
// Assign OnClick only to leaf nodes.
if len(node.Children) == 0 {
node := node // Capture range variable.
node.OnClick = func() error {
if len(tn.Children) == 0 {
tn := tn // Capture range variable.
tn.OnClick = func() error {
mu.Lock()
selectedNodeID = node.ID
selectedNodeID = tn.ID
mu.Unlock()
data := nodeDataMap[node.ID]
data := nodeDataMap[tn.ID]
if data != nil {
updateWidgets(data, memDonut, spark, detailText)
} else {
@ -221,7 +226,7 @@ func main() {
spark.Add([]int{0})
memDonut.Percent(0)
detailText.Reset()
detailText.Write(fmt.Sprintf("Selected Node: %s\n", node.Label))
detailText.Write(fmt.Sprintf("Selected Node: %s\n", tn.Label))
detailText.Write("No data available for this node.")
}
@ -231,13 +236,13 @@ func main() {
return nil
}
}
for _, child := range node.Children {
for _, child := range tn.Children {
assignOnClick(child)
}
}
for _, node := range processTree {
assignOnClick(node)
for _, tn := range processTree {
assignOnClick(tn)
}
// Build grid layout.
@ -247,8 +252,8 @@ func main() {
grid.ColWidthPerc(40,
grid.Widget(tv,
container.Border(linestyle.Light),
container.BorderTitle("Applications Treeview"),
container.Focused(), // Set initial focus to the treeview
container.BorderTitle("Applications TreeView"),
container.Focused(), // Set initial focus to the TreeView
),
),
grid.ColWidthPerc(30,