package tui import "github.com/lixenwraith/terminal" // ExpandIcon chars const ( IconExpanded = '▼' IconCollapsed = '▶' IconBullet = '•' ) // ExpandIconRune returns appropriate expand/collapse indicator func ExpandIconRune(expanded bool) rune { if expanded { return IconExpanded } return IconCollapsed } // TreeLineMode specifies connector line rendering type TreeLineMode uint8 const ( TreeLinesNone TreeLineMode = iota // Indent only, no lines TreeLinesSimple // │ continuation, └ for last ) // TreeNode represents a node in a hierarchical tree type TreeNode struct { Key string // Unique identifier for expansion state Label string // Display text Icon rune // Custom icon, 0 = auto (▶/▼ for expandable, • for leaf) IconFg terminal.RGB Expandable bool // Has children Expanded bool // Currently expanded Depth int // Nesting level (0 = root) Check CheckState // Optional checkbox, CheckNone to skip CheckFg terminal.RGB Style Style // Text styling IsLast bool // Last sibling at this depth (for tree lines) Data any // Application payload Suffix string // Secondary text after label (e.g., "(5 files)") SuffixStyle Style // Styling for suffix, zero = dimmed version of Style Badge rune // Icon between checkbox and label (e.g., '★'), 0 = none BadgeFg terminal.RGB // Badge color } // ancestorHasMoreSiblings checks if there are more nodes at given depth after idx func (r Region) ancestorHasMoreSiblings(nodes []TreeNode, idx, depth int) bool { targetDepth := depth for i := idx + 1; i < len(nodes); i++ { if nodes[i].Depth <= targetDepth { return nodes[i].Depth == targetDepth } } return false } // TreeOpts configures tree rendering type TreeOpts struct { CursorBg terminal.RGB DefaultBg terminal.RGB IndentWidth int // Cells per depth, default 2 IconWidth int // Width for icon column, default 2 LineMode TreeLineMode LineFg terminal.RGB } // DefaultTreeOpts returns sensible defaults func DefaultTreeOpts() TreeOpts { return TreeOpts{ IndentWidth: 2, IconWidth: 2, LineMode: TreeLinesNone, LineFg: terminal.RGB{R: 80, G: 80, B: 100}, } } // Tree renders hierarchical tree nodes within region, returns number of rows rendered // Nodes must be pre-flattened (only visible/expanded nodes included) func (r Region) Tree(nodes []TreeNode, cursor, scroll int, opts TreeOpts) int { if r.H < 1 || len(nodes) == 0 { return 0 } indentW := opts.IndentWidth if indentW < 1 { indentW = 2 } iconW := opts.IconWidth if iconW < 1 { iconW = 2 } lineFg := opts.LineFg if lineFg == (terminal.RGB{}) { lineFg = DefaultTheme.Border } rendered := 0 for y := 0; y < r.H; y++ { idx := scroll + y if idx >= len(nodes) { break } node := nodes[idx] isCursor := idx == cursor bg := opts.DefaultBg if isCursor { bg = opts.CursorBg } for x := 0; x < r.W; x++ { r.Cell(x, y, ' ', terminal.RGB{}, bg, terminal.AttrNone) } x := 0 if opts.LineMode == TreeLinesSimple && node.Depth > 0 { x = r.renderTreeLines(y, x, node, nodes, idx, scroll, indentW, lineFg, bg) } else { x = node.Depth * indentW } // Icon (expand indicator or bullet) icon := node.Icon iconFg := node.IconFg if icon == 0 { if node.Expandable { if node.Expanded { icon = IconExpanded } else { icon = IconCollapsed } } else { icon = IconBullet } } if iconFg == (terminal.RGB{}) { iconFg = lineFg } if x < r.W { r.Cell(x, y, icon, iconFg, bg, terminal.AttrNone) } x += iconW // Checkbox if node.Check != CheckNone || node.CheckFg != (terminal.RGB{}) { if x+3 <= r.W { checkFg := node.CheckFg if checkFg == (terminal.RGB{}) { checkFg = node.Style.Fg } var ch rune switch node.Check { case CheckNone: ch = ' ' case CheckPartial: ch = 'o' case CheckFull: ch = 'x' case CheckPlus: ch = '+' } r.Cell(x, y, '[', checkFg, bg, terminal.AttrNone) r.Cell(x+1, y, ch, checkFg, bg, terminal.AttrNone) r.Cell(x+2, y, ']', checkFg, bg, terminal.AttrNone) } x += 4 } // Badge (optional icon between checkbox and label) if node.Badge != 0 { if x < r.W { badgeFg := node.BadgeFg if badgeFg == (terminal.RGB{}) { badgeFg = node.Style.Fg } r.Cell(x, y, node.Badge, badgeFg, bg, terminal.AttrNone) } x += 2 } // Label style := node.Style if style.Bg == (terminal.RGB{}) { style.Bg = bg } // Calculate available width for label + suffix availW := r.W - x - 1 labelLen := RuneLen(node.Label) suffixLen := RuneLen(node.Suffix) label := node.Label suffix := node.Suffix // Truncate if needed, prioritizing label over suffix if labelLen+suffixLen > availW { if labelLen > availW { label = Truncate(label, availW) suffix = "" } else { suffix = Truncate(suffix, availW-labelLen) } } r.TextStyled(x, y, label, style) x += RuneLen(label) // Suffix if suffix != "" { suffixStyle := node.SuffixStyle if suffixStyle.Fg == (terminal.RGB{}) { // Default: dimmed version of label style suffixStyle.Fg = terminal.RGB{ R: node.Style.Fg.R / 2, G: node.Style.Fg.G / 2, B: node.Style.Fg.B / 2, } } if suffixStyle.Bg == (terminal.RGB{}) { suffixStyle.Bg = bg } r.TextStyled(x, y, suffix, suffixStyle) } rendered++ } return rendered } // renderTreeLines draws connector lines for tree structure, returns x position after lines func (r Region) renderTreeLines(y, startX int, node TreeNode, nodes []TreeNode, idx, scroll, indentW int, fg terminal.RGB, bg terminal.RGB) int { x := startX for d := 0; d < node.Depth; d++ { // Determine if ancestor at this depth has more siblings below hasMore := r.ancestorHasMoreSiblings(nodes, idx, d) if d == node.Depth-1 { // Direct parent level - show branch if node.IsLast { r.Cell(x, y, '└', fg, bg, terminal.AttrNone) } else { r.Cell(x, y, '├', fg, bg, terminal.AttrNone) } // Horizontal connector for i := 1; i < indentW; i++ { if x+i < r.W { r.Cell(x+i, y, '─', fg, bg, terminal.AttrNone) } } } else { // Ancestor level - show continuation or space if hasMore { r.Cell(x, y, '│', fg, bg, terminal.AttrNone) } // Fill rest with spaces (already cleared) } x += indentW } return x } // --- Navigation helpers --- // FindParentIndex returns index of parent node for node at idx, or -1 if root func FindParentIndex(nodes []TreeNode, idx int) int { if idx <= 0 || idx >= len(nodes) { return -1 } targetDepth := nodes[idx].Depth - 1 if targetDepth < 0 { return -1 } for i := idx - 1; i >= 0; i-- { if nodes[i].Depth == targetDepth { return i } } return -1 } // FindFirstChildIndex returns index of first child for node at idx, or -1 if none func FindFirstChildIndex(nodes []TreeNode, idx int) int { if idx < 0 || idx >= len(nodes)-1 { return -1 } if !nodes[idx].Expandable || !nodes[idx].Expanded { return -1 } childDepth := nodes[idx].Depth + 1 if idx+1 < len(nodes) && nodes[idx+1].Depth == childDepth { return idx + 1 } return -1 } // FindNextSiblingIndex returns index of next sibling at same depth, or -1 func FindNextSiblingIndex(nodes []TreeNode, idx int) int { if idx < 0 || idx >= len(nodes) { return -1 } depth := nodes[idx].Depth for i := idx + 1; i < len(nodes); i++ { if nodes[i].Depth < depth { return -1 // Went up, no more siblings } if nodes[i].Depth == depth { return i } } return -1 } // FindPrevSiblingIndex returns index of previous sibling at same depth, or -1 func FindPrevSiblingIndex(nodes []TreeNode, idx int) int { if idx <= 0 || idx >= len(nodes) { return -1 } depth := nodes[idx].Depth for i := idx - 1; i >= 0; i-- { if nodes[i].Depth < depth { return -1 // Went up, no more siblings } if nodes[i].Depth == depth { return i } } return -1 }