337 lines
7.9 KiB
Go
337 lines
7.9 KiB
Go
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
|
|
} |