diff --git a/README.md b/README.md index 4f214341..e8b39325 100644 --- a/README.md +++ b/README.md @@ -562,6 +562,100 @@ for i := 0; i < repeat; i++ { } ``` +## Rendering Trees + +Lip Gloss ships with a tree rendering sub-package. + +```go +import "github.com/charmbracelet/lipgloss/tree" +``` + +Define a new tree. + +```go +t := tree.Root("."). + Child("A", "B", "C") +``` + +Print the tree. + +```go +fmt.Println(t) + +// . +// ├── A +// ├── B +// └── C +``` + +Trees have the ability to nest. + +```go +t := tree.Root("."). + Child("Item 1"). + Child( + tree.Root("Item 2"). + Child("Item 2.1"). + Child("Item 2.2"). + Child("Item 2.3"), + ). + Child( + tree.Root("Item 3"). + Child("Item 3.1"). + Child("Item 3.2"), + ) +``` + +Print the tree. + +```go +fmt.Println(t) +``` + +

+Tree Example (simple) +

+ +Trees can be customized via their enumeration function as well as using +`lipgloss.Style`s. + +```go +enumeratorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("99")).MarginRight(1) +itemStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("212")).MarginRight(1) + +t := tree. + Root("Makeup"). + Child( + "Glossier", + "Claire’s Boutique", + "Nyx", + "Mac", + "Milk", + ). + Enumerator(tree.RoundedEnumerator). + EnumeratorStyle(enumeratorStyle). + ItemStyle(itemStyle) + +``` + +Print the tree. + +

+Tree Example (makeup) +

+ +The predefined enumerators for trees are `DefaultEnumerator` and `RoundedEnumerator`. + +If you need, you can also build trees incrementally: + +```go +t := tree.New() + +for i := 0; i < repeat; i++ { + t.Child("Lip Gloss") +} +``` + --- ## FAQ diff --git a/examples/go.mod b/examples/go.mod index 64150bcf..15b2609c 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -22,7 +22,7 @@ require ( github.com/charmbracelet/bubbletea v0.25.0 // indirect github.com/charmbracelet/keygen v0.5.0 // indirect github.com/charmbracelet/log v0.4.0 // indirect - github.com/charmbracelet/x/ansi v0.1.3 // indirect + github.com/charmbracelet/x/ansi v0.1.4 // indirect github.com/charmbracelet/x/errors v0.0.0-20240117030013-d31dba354651 // indirect github.com/charmbracelet/x/exp/term v0.0.0-20240328150354-ab9afc214dfd // indirect github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect diff --git a/examples/go.sum b/examples/go.sum index 9a640cb8..72eaeba8 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -13,8 +13,8 @@ github.com/charmbracelet/ssh v0.0.0-20240401141849-854cddfa2917 h1:NZKjJ7d/pzk/A github.com/charmbracelet/ssh v0.0.0-20240401141849-854cddfa2917/go.mod h1:8/Ve8iGRRIGFM1kepYfRF2pEOF5Y3TEZYoJaA54228U= github.com/charmbracelet/wish v1.4.0 h1:pL1uVP/YuYgJheHEj98teZ/n6pMYnmlZq/fcHvomrfc= github.com/charmbracelet/wish v1.4.0/go.mod h1:ew4/MjJVfW/akEO9KmrQHQv1F7bQRGscRMrA+KtovTk= -github.com/charmbracelet/x/ansi v0.1.3 h1:RBh/eleNWML5R524mjUF0yVRePTwqN9tPtV+DPgO5Lw= -github.com/charmbracelet/x/ansi v0.1.3/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/ansi v0.1.4 h1:IEU3D6+dWwPSgZ6HBH+v6oUuZ/nVawMiWj5831KfiLM= +github.com/charmbracelet/x/ansi v0.1.4/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= github.com/charmbracelet/x/errors v0.0.0-20240117030013-d31dba354651 h1:3RXpZWGWTOeVXCTv0Dnzxdv/MhNUkBfEcbaTY0zrTQI= github.com/charmbracelet/x/errors v0.0.0-20240117030013-d31dba354651/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= github.com/charmbracelet/x/exp/term v0.0.0-20240328150354-ab9afc214dfd h1:HqBjkSFXXfW4IgX3TMKipWoPEN08T3Pi4SA/3DLss/U= diff --git a/examples/tree/background/main.go b/examples/tree/background/main.go new file mode 100644 index 00000000..a38ca626 --- /dev/null +++ b/examples/tree/background/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/tree" +) + +func main() { + enumeratorStyle := lipgloss.NewStyle(). + Background(lipgloss.Color("0")). + Padding(0, 1) + + headerItemStyle := lipgloss.NewStyle(). + Background(lipgloss.Color("#ee6ff8")). + Foreground(lipgloss.Color("#ecfe65")). + Bold(true). + Padding(0, 1) + + itemStyle := headerItemStyle.Background(lipgloss.Color("0")) + + t := tree.Root("# Table of Contents"). + RootStyle(itemStyle). + ItemStyle(itemStyle). + EnumeratorStyle(enumeratorStyle). + Child( + tree.Root("## Chapter 1"). + Child("Chapter 1.1"). + Child("Chapter 1.2"), + ). + Child( + tree.Root("## Chapter 2"). + Child("Chapter 2.1"). + Child("Chapter 2.2"), + ) + + fmt.Println(t) +} diff --git a/examples/tree/files/main.go b/examples/tree/files/main.go new file mode 100644 index 00000000..9e6250b8 --- /dev/null +++ b/examples/tree/files/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/tree" +) + +func main() { + enumeratorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240")).PaddingRight(1) + itemStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("99")).Bold(true).PaddingRight(1) + + t := tree.Root(".").EnumeratorStyle(enumeratorStyle).ItemStyle(itemStyle) + _ = filepath.Walk(".", func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + t.Child(tree.Root(path)) + } + return nil + }) + + fmt.Println(t) +} diff --git a/examples/tree/makeup/main.go b/examples/tree/makeup/main.go new file mode 100644 index 00000000..2619cf81 --- /dev/null +++ b/examples/tree/makeup/main.go @@ -0,0 +1,29 @@ +package main + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/tree" +) + +func main() { + enumeratorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("99")).MarginRight(1) + itemStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("212")).MarginRight(1) + + t := tree. + Root("Makeup"). + Child( + "Glossier", + "Claire’s Boutique", + "Nyx", + "Mac", + "Milk", + ). + Enumerator(tree.RoundedEnumerator). + EnumeratorStyle(enumeratorStyle). + ItemStyle(itemStyle). + RootStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#04B575"))) + + fmt.Println(t) +} diff --git a/examples/tree/rounded/main.go b/examples/tree/rounded/main.go new file mode 100644 index 00000000..c32dae3a --- /dev/null +++ b/examples/tree/rounded/main.go @@ -0,0 +1,37 @@ +package main + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/tree" +) + +func main() { + itemStyle := lipgloss.NewStyle().MarginRight(1) + enumeratorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).MarginRight(1) + + t := tree.Root("Groceries"). + Child( + tree.Root("Fruits"). + Child( + "Blood Orange", + "Papaya", + "Dragonfruit", + "Yuzu", + ), + tree.Root("Items"). + Child( + "Cat Food", + "Nutella", + "Powdered Sugar", + ), + tree.Root("Veggies"). + Child( + "Leek", + "Artichoke", + ), + ).ItemStyle(itemStyle).EnumeratorStyle(enumeratorStyle).Enumerator(tree.RoundedEnumerator) + + fmt.Println(t) +} diff --git a/examples/tree/simple/main.go b/examples/tree/simple/main.go new file mode 100644 index 00000000..718022ed --- /dev/null +++ b/examples/tree/simple/main.go @@ -0,0 +1,27 @@ +package main + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss/tree" +) + +func main() { + t := tree.Root("."). + Child("Item 1"). + Child( + tree.New(). + Root("Item 2"). + Child("Item 2.1"). + Child("Item 2.2"). + Child("Item 2.3"), + ). + Child( + tree.New(). + Root("Item 3"). + Child("Item 3.1"). + Child("Item 3.2"), + ) + + fmt.Println(t) +} diff --git a/examples/tree/styles/main.go b/examples/tree/styles/main.go new file mode 100644 index 00000000..9950b1f4 --- /dev/null +++ b/examples/tree/styles/main.go @@ -0,0 +1,26 @@ +package main + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/tree" +) + +func main() { + purple := lipgloss.NewStyle().Foreground(lipgloss.Color("99")).MarginRight(1) + pink := lipgloss.NewStyle().Foreground(lipgloss.Color("212")).MarginRight(1) + + t := tree.New(). + Child( + "Glossier", + "Claire’s Boutique", + tree.Root("Nyx"). + Child("Lip Gloss", "Foundation"). + EnumeratorStyle(pink), + "Mac", + "Milk", + ). + EnumeratorStyle(purple) + fmt.Println(t) +} diff --git a/examples/tree/toggle/main.go b/examples/tree/toggle/main.go new file mode 100644 index 00000000..b914c2f5 --- /dev/null +++ b/examples/tree/toggle/main.go @@ -0,0 +1,92 @@ +package main + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/tree" +) + +type styles struct { + base, + block, + enumerator, + dir, + toggle, + file lipgloss.Style +} + +func defaultStyles() styles { + var s styles + s.base = lipgloss.NewStyle(). + Background(lipgloss.Color("57")). + Foreground(lipgloss.Color("225")) + s.block = s.base. + Padding(1, 3). + Margin(1, 3). + Width(40) + s.enumerator = s.base. + Foreground(lipgloss.Color("212")). + PaddingRight(1) + s.dir = s.base. + Inline(true) + s.toggle = s.base. + Foreground(lipgloss.Color("207")). + PaddingRight(1) + s.file = s.base + return s +} + +type dir struct { + name string + open bool + styles styles +} + +func (d dir) String() string { + t := d.styles.toggle.Render + n := d.styles.dir.Render + if d.open { + return t("▼") + n(d.name) + } + return t("▶") + n(d.name) +} + +type file struct { + name string + styles styles +} + +func (s file) String() string { + return s.styles.file.Render(s.name) +} + +func main() { + s := defaultStyles() + + t := tree.Root(dir{"~", true, s}). + Enumerator(tree.RoundedEnumerator). + EnumeratorStyle(s.enumerator). + Child( + dir{"ayman", false, s}, + tree.Root(dir{"bash", false, s}). + Child( + tree.Root(dir{"tools", true, s}). + Child( + file{"zsh", s}, + file{"doom-emacs", s}, + ), + ), + tree.Root(dir{"carlos", true, s}). + Child( + tree.Root(dir{"emotes", true, s}). + Child( + file{"chefkiss.png", s}, + file{"kekw.png", s}, + ), + ), + dir{"maas", false, s}, + ) + + fmt.Println(s.block.Render(t.String())) +} diff --git a/go.mod b/go.mod index 0911a674..9ca256cf 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/aymanbagabas/go-udiff v0.2.0 github.com/charmbracelet/x/ansi v0.1.4 github.com/lucasb-eyer/go-colorful v1.2.0 + github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a github.com/muesli/termenv v0.15.2 github.com/rivo/uniseg v0.4.7 golang.org/x/sys v0.19.0 diff --git a/go.sum b/go.sum index fee272a6..2a55532a 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/charmbracelet/x/ansi v0.1.4 h1:IEU3D6+dWwPSgZ6HBH+v6oUuZ/nVawMiWj5831KfiLM= github.com/charmbracelet/x/ansi v0.1.4/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= diff --git a/list/enumerator.go b/list/enumerator.go index ce55b1e7..1fe327b8 100644 --- a/list/enumerator.go +++ b/list/enumerator.go @@ -120,33 +120,3 @@ func Asterisk(Items, int) string { func Dash(Items, int) string { return "-" } - -// Tree enumerates a tree. -// -// ├── Foo -// ├── Bar -// ├── Baz -// └── Qux. -func Tree(items Items, index int) string { - if items.Length()-1 == index { - return "└──" - } - return "├──" -} - -// DefaultIndenter indents a tree for nested trees and multiline content. -// -// ├── Foo -// ├── Bar -// │ ├── Qux -// │ ├── Quux -// │ │ ├── Foo -// │ │ └── Bar -// │ └── Quuux -// └── Baz. -func TreeIndenter(items Items, index int) string { - if items.Length()-1 == index { - return " " - } - return "│ " -} diff --git a/list/list.go b/list/list.go index 30aa6ed8..dc38e3bc 100644 --- a/list/list.go +++ b/list/list.go @@ -24,7 +24,7 @@ package list import ( "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/lipgloss/internal/tree" + "github.com/charmbracelet/lipgloss/tree" ) // List represents a list of items that can be displayed. Lists can contain diff --git a/list/list_test.go b/list/list_test.go index d745f480..b5a0a947 100644 --- a/list/list_test.go +++ b/list/list_test.go @@ -7,8 +7,8 @@ import ( "github.com/aymanbagabas/go-udiff" "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/lipgloss/internal/tree" "github.com/charmbracelet/lipgloss/list" + "github.com/charmbracelet/lipgloss/tree" ) // XXX: can't write multi-line examples if the underlying string uses diff --git a/style.go b/style.go index a9c8294f..4ef419c1 100644 --- a/style.go +++ b/style.go @@ -287,9 +287,7 @@ func (s Style) Render(strs ...string) string { te = te.Underline() } if reverse { - if reverse { - teWhitespace = teWhitespace.Reverse() - } + teWhitespace = teWhitespace.Reverse() te = te.Reverse() } if blink { diff --git a/internal/tree/children.go b/tree/children.go similarity index 91% rename from internal/tree/children.go rename to tree/children.go index 9cd4db8e..6727092a 100644 --- a/internal/tree/children.go +++ b/tree/children.go @@ -1,11 +1,11 @@ package tree -// Children is the interface that wraps the basic methods of a list model. +// Children is the interface that wraps the basic methods of a tree model. type Children interface { // At returns the content item of the given index. At(index int) Node - // Length returns the number of items in the list. + // Length returns the number of children in the tree. Length() int } @@ -86,7 +86,7 @@ func (m *Filter) Filter(f func(index int) bool) *Filter { return m } -// Length returns the number of items in the list. +// Length returns the number of children in the tree. func (m *Filter) Length() int { j := 0 for i := 0; i < m.data.Length(); i++ { diff --git a/internal/tree/enumerator.go b/tree/enumerator.go similarity index 100% rename from internal/tree/enumerator.go rename to tree/enumerator.go diff --git a/internal/tree/renderer.go b/tree/renderer.go similarity index 92% rename from internal/tree/renderer.go rename to tree/renderer.go index 3cca00bb..8fd86930 100644 --- a/internal/tree/renderer.go +++ b/tree/renderer.go @@ -6,13 +6,14 @@ import ( "github.com/charmbracelet/lipgloss" ) -// StyleFunc allows the list to be styled per item. +// StyleFunc allows the tree to be styled per item. type StyleFunc func(children Children, i int) lipgloss.Style -// Style is the styling applied to the list. +// Style is the styling applied to the tree. type Style struct { enumeratorFunc StyleFunc itemFunc StyleFunc + root lipgloss.Style } // newRenderer returns the renderer used to render a tree. @@ -50,7 +51,7 @@ func (r *renderer) render(node Node, root bool, prefix string) string { // print the root node name if its not empty. if name := node.Value(); name != "" && root { - strs = append(strs, r.style.itemFunc(children, -1).Render(name)) + strs = append(strs, r.style.root.Render(name)) } for i := 0; i < children.Length(); i++ { @@ -82,14 +83,14 @@ func (r *renderer) render(node Node, root bool, prefix string) string { // the current node's prefix have the same height. for lipgloss.Height(item) > lipgloss.Height(nodePrefix) { nodePrefix = lipgloss.JoinVertical( - lipgloss.Top, + lipgloss.Left, nodePrefix, enumStyle.Render(indent), ) } for lipgloss.Height(nodePrefix) > lipgloss.Height(multineLinePrefix) { multineLinePrefix = lipgloss.JoinVertical( - lipgloss.Top, + lipgloss.Left, multineLinePrefix, prefix, ) @@ -98,7 +99,7 @@ func (r *renderer) render(node Node, root bool, prefix string) string { strs = append( strs, lipgloss.JoinHorizontal( - lipgloss.Left, + lipgloss.Top, multineLinePrefix, nodePrefix, item, @@ -126,7 +127,7 @@ func (r *renderer) render(node Node, root bool, prefix string) string { } } } - return lipgloss.JoinVertical(lipgloss.Top, strs...) + return strings.Join(strs, "\n") } func max(a, b int) int { diff --git a/tree/testdata/TestRootStyle.golden b/tree/testdata/TestRootStyle.golden new file mode 100644 index 00000000..feacf4cd --- /dev/null +++ b/tree/testdata/TestRootStyle.golden @@ -0,0 +1,3 @@ +Root +├── Foo +└── Baz \ No newline at end of file diff --git a/internal/tree/tree.go b/tree/tree.go similarity index 90% rename from internal/tree/tree.go rename to tree/tree.go index 5c629af3..94f5dd08 100644 --- a/internal/tree/tree.go +++ b/tree/tree.go @@ -223,6 +223,12 @@ func (t *Tree) EnumeratorStyleFunc(fn StyleFunc) *Tree { return t } +// RootStyle sets a style for the root element. +func (t *Tree) RootStyle(style lipgloss.Style) *Tree { + t.ensureRenderer().style.root = style + return t +} + // ItemStyle sets a static style for all items. // // Use ItemStyleFunc to conditionally set styles based on the tree node. @@ -249,12 +255,12 @@ func (t *Tree) ItemStyleFunc(fn StyleFunc) *Tree { return t } -// Enumerator sets the enumerator implementation. This can be used to change the way the branches indicators look. -// Lipgloss includes predefined enumerators including bullets, roman numerals, and more. For -// example, you can have a numbered list: +// Enumerator sets the enumerator implementation. This can be used to change the +// way the branches indicators look. Lipgloss includes predefined enumerators +// for a classic or rounded tree. For example, you can have a rounded tree: // // tree.New(). -// Enumerator(Arabic) +// Enumerator(RoundedEnumerator) func (t *Tree) Enumerator(enum Enumerator) *Tree { t.ensureRenderer().enumerator = enum return t @@ -302,13 +308,25 @@ func (t *Tree) Children() Children { // It is a shorthand for: // // tree.New().Root(root) -func Root(root string) *Tree { - return New().Root(root) +func Root(root any) *Tree { + t := New() + return t.Root(root) } // Root sets the root value of this tree. -func (t *Tree) Root(root string) *Tree { - t.value = root +func (t *Tree) Root(root any) *Tree { + // root is a tree or string + switch item := root.(type) { + case *Tree: + t.value = item.value + t = t.Child(item.children) + case Node, fmt.Stringer: + t.value = item.(fmt.Stringer).String() + case string, nil: + t.value = item.(string) + default: + t.value = fmt.Sprintf("%v", item) + } return t } diff --git a/internal/tree/tree_test.go b/tree/tree_test.go similarity index 96% rename from internal/tree/tree_test.go rename to tree/tree_test.go index 654b5b4f..3ee1fd34 100644 --- a/internal/tree/tree_test.go +++ b/tree/tree_test.go @@ -7,9 +7,11 @@ import ( "github.com/aymanbagabas/go-udiff" "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/lipgloss/internal/tree" "github.com/charmbracelet/lipgloss/list" "github.com/charmbracelet/lipgloss/table" + "github.com/charmbracelet/lipgloss/tree" + "github.com/charmbracelet/x/exp/golden" + "github.com/muesli/termenv" ) func TestTree(t *testing.T) { @@ -410,6 +412,20 @@ Root assertEqual(t, want, tree.String()) } +func TestRootStyle(t *testing.T) { + lipgloss.SetColorProfile(termenv.TrueColor) + tree := tree.New(). + Root("Root"). + Child( + "Foo", + "Baz", + ). + RootStyle(lipgloss.NewStyle().Background(lipgloss.Color("#5A56E0"))). + ItemStyle(lipgloss.NewStyle().Background(lipgloss.Color("#04B575"))) + + golden.RequireEqual(t, []byte(tree.String())) +} + func TestAt(t *testing.T) { data := tree.NewStringData("Foo", "Bar") @@ -607,7 +623,7 @@ func TestMultilinePrefixSubtree(t *testing.T) { ├── Foo ├── Bar ├── Baz -│ Foo Document +│ Foo Document │ The Foo Files │ │ │ Bar Document