ajalt / mordant

Multiplatform text styling for Kotlin command-line applications
https://ajalt.github.io/mordant/
Apache License 2.0
932 stars 33 forks source link

Feature request: Tree view widget #181

Open FelixZY opened 3 weeks ago

FelixZY commented 3 weeks ago

I would like to render a tree view, similar to the linux tree command:

$ tree hotel/
hotel/
├── floor 1
│   ├── room 100
│   ├── room 101
│   └── room 102
└── floor 2
    ├── room 200
    ├── room 201
    └── room 202
ajalt commented 2 weeks ago

I think that's a good fit for a built-in widget: it's not trivial to make with a grid, and it's a common enough CLI pattern.

Let's collect some more requirements before we start implementing: are there any other tools that display tree-like data that we could take inspiration from? For example, should the output be colored by default, do tree entries have descriptions etc.

FelixZY commented 2 weeks ago

I don't have good answers for most of the questions posed and am not experienced enough with mordant to know if coloured by default is a good fit. I'll try to bring a few things to the discussion though:

With regards to existing tools, tree is the obvious example. I believe exa has tree layout support as well.

Syntax wise, I'm thinking something like what Daisy UI does for their menu with submenu component would be simplest. Translated to DSL, perhaps something like

tree {
  leaf {
  }
  tree {
    leaf {
    }
  }
}

Personally I'd love multi-line support for "leaves". I think that's more uncommon when looking at other tree-like tools but it would be great for many use-cases.

Being able to show other widgets, such as tables, inside a leaf would be dreamy but then again, perhaps not as hard to implement as one would think if multi-line leaves are supported.

FelixZY commented 2 weeks ago

Also, terminology should probably be based on the tree data structure.

ajalt commented 2 weeks ago

I'm noticing a symmetry in the structure: nested nodes are drawn with exactly the same characters as the top level tree. So if each entry of the tree can be a widget, you wouldn't need to differentiate between leaf and middle nodes in a dsl, you could do something like

class Entry(title: String, content: Widget? = null)
class Tree(vararg entries: Entry): Widget

And the have the class definition like

    Tree(
        Entry("bin", Tree(
            Entry("parse-ansi-codes.rs"),
        )),
        Entry("Cargo.lock"),
        Entry("Cargo.toml"),
        Entry("README.md"),
        Entry("src", Tree(
            Entry("cursor.rs"),
            Entry("lib.rs"),
            Entry("style.rs"),
        )),
        Entry("target", Tree(
            Entry("debug"),
        )),
        Entry("test"),
    )

with a dsl like

    tree {
        entry("bin", tree {
            entry("parse-ansi-codes.rs")
        })
        entry("Cargo.lock")
        entry("Cargo.toml")
        entry("README.md")
        entry("src",
            tree {
                entry("cursor.rs")
                entry("lib.rs")
                entry("style.rs")
            }
        }
        entry("target", tree {
            entry("debug")
        })
        entry("test")
    }
FelixZY commented 2 weeks ago

I'm not sure I'm a fan of forcing a title: String on every entry. Also, I think it would be nice if tree could be used without wrapping it in an entry. Example:

data class Widget(val text: String)

sealed interface TreeNode

data class Leaf(val content: Widget) : TreeNode

data class Tree(val content: Widget? = null, val entries: List<TreeNode>) : TreeNode {
    constructor(content: Widget? = null, vararg entries: TreeNode) : this(content, entries.toList())

    data class Builder(val content: Widget? = null, var entries: MutableList<TreeNode> = mutableListOf<TreeNode>()) {
        fun tree(content: Widget? = null, init: Tree.Builder.() -> Unit) {
            entries.add(
                Tree.Builder(content).apply { init() }.build()
            )
        }

        fun leaf(init: () -> Widget) {
            entries.add(Leaf(init()))
        }

        fun build(): Tree = Tree(content, entries)
    }
}

fun tree(content: Widget? = null, init: Tree.Builder.() -> Unit): Tree =
    Tree.Builder(content).apply { init() }.build()

fun main() {
    val normalTree = Tree(
        content = Widget("Hotel"),
        Tree(
            content = Widget("Floor 1"),
            Leaf(Widget("Room 100")),
            Leaf(Widget("Room 101")),
            Leaf(Widget("Room 102"))
        ),
        Tree(
            content = Widget("Floor 2"),
            Leaf(Widget("Room 200")),
            Leaf(Widget("Room 201")),
            Leaf(Widget("Room 202"))
        )
    )

    val dslTree = tree(Widget("Hotel")) {
        tree(Widget("Floor 1")) {
            leaf {
                Widget("Room 100")
            }
            leaf {
                Widget("Room 101")
            }
            leaf {
                Widget("Room 102")
            }
        }
        tree(Widget("Floor 2")) {
            leaf {
                Widget("Room 200")
            }
            leaf {
                Widget("Room 201")
            }
            leaf {
                Widget("Room 202")
            }
        }
    }

    println(normalTree)
    println(dslTree)
    println(normalTree == dslTree)
}

Playground link: https://pl.kotl.in/o-95F5FZ1


Note that data class Widget(val text: String) is a standin for mordant's Widget class, allowing the above code to compile.

ajalt commented 2 weeks ago

Thanks for all the feedback! I'll definitely have overloads for specifying entries as either a string or a widget, like I do for all the other widget builders.

The reason I think that a list of entries is preferable than a split content/entires, is because a tree might have only leaf nodes (or more than one root) like this:

├─ Cargo.lock
├─ Cargo.toml
└─ test

And a single separate content doesn't fit well in that case.

FelixZY commented 2 weeks ago

Not sure I see the problem - wouldn't that just be

tree {
  leaf { "Cargo.lock" }
  leaf { "Cargo.toml" }
  leaf { "test" }
}

?

EDIT

I.e.

[content]
├─ [entries[0]]
├─ [entries[1]]
└─ [entries[2]]

or

[root.content]
├── [root.entries[0].content]
│   ├── [root.entries[0].entries[0]]
│   ├── [root.entries[0].entries[1]]
│   └── [root.entries[0].entries[2]]
└── [root.entries[1].content]
    ├── [root.entries[1].entries[0]]
    ├── [root.entries[1].entries[1]]
    └── [root.entries[1].entries[2]]

I guess index 0 could be made "special" to mean the current node's content but I think it makes sense to use explicit syntax.

EDIT2

Note that I imagine Tree.content as optional. If there is no content, I'd imagine the "root" of the relevant subtree (or root tree) to render as an empty space or possibly or ./similar.

FelixZY commented 2 weeks ago

How do you mean a tree could have more than one root?