v0.1.0 initial commit
This commit is contained in:
+337
@@ -0,0 +1,337 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user