Roll-your-own expanding/contracting outlines

Part 1: individual outline entries

Introduction
This is intended to be the first part of a series on building your own outliner. Don’t be put off by the length. Most of the text here actually concerns the design decisions. Once those decisions are made, implementation is rather easy. If you prefer, you can just skip to the code.

I intend to expand on this, bit by bit, adding features incrementally as time permits. Comments are welcome.

Inspiration
If you’ve ever used an outliner (CherryTree, NoteCase Pro, ToDoList, TreeLine, TreePad, Leo, …), it’s probably because you have a lot of information to keep. You want an expandable structure, so that there’s always a good place to put something new, but you also want to collapse (hide) the parts of it that you’re not reading right this minute.

Also, the higher-level parts of the outline serve to give context to their lower-level parts, so you don’t have to keep repeating yourself.

An outline can be just as handy in Anvil.

Starting Points
As of this writing, Anvil’s Form builder does not provide an outline/tree component. But it does provide a component that can be used as a foundation: the GridPanel.

Like a Form, a GridPanel is a container for other components. This container is physically subdivided into an open-ended set of rows, each with a dozen equal-width columns. Imagine a chessboard 12 squares wide, and H squares high (your choice of H), and you have a pretty good idea of where the contained components can end up.

How does this help us build an outline? Think of how an outline is arranged in space. An outline is a vertical list of entries, or nodes, each node being indented some fixed amount. In our chessboard model, each node starts in one row, in some square, and occupies all remaining squares to its right. Its first “child” node, if any, will occupy the immediately following row, starting one level deeper (one square further right).

Implementation
Now, how should a Node be composed?

First, we need something for the user to click on, that can let us know when it’s been clicked. That’s either a Button or a Link. Because this part could be quite wordy, I’ve chosen a Link.

For a static, unchanging outline, one that never needs to expand or contract, that’s probably enough. But ours will expand and contract, so we need a way to trigger that. Preferably something that doesn’t look like a Link, and is easy to hit with a mouse or a finger. E.g., a Button.

And, of course, we’ll need a way to manufacture nodes at will. A suitable class will do that. Code for that is as follows:

# anvil_tree_node.py -- an expandable, collapsible tree node (button + label)

from anvil import *
from types import IntType


# constants
# The following is used as the text of the node's pushbutton.
LEAF_NODE = "."      # a node that has no children; cannot expand or collapse
EXPANDED_NODE = "-"      # a node with children; has been expanded
COLLAPSED_NODE = "+"      # a node with children; has been collapsed


# 1 node = 1 row in the grid component.
# This grid component has 12 columns per row.
# The [+/-] button component goes first, and occupies 1 column (indented).
# The link component occupies the remaining columns.
NUM_GRID_COLUMNS = 12
BUTTON_WIDTH_COLUMNS = 1
AVAILABLE_LABEL_COLUMNS = NUM_GRID_COLUMNS - BUTTON_WIDTH_COLUMNS


# anvil_tree_node represents one node of the tree.
# To capture inter-node relationships (parent, child, sibling, etc.), 
# nodes will be collected into an array (list).
class anvil_tree_node :
    "Represents one node of a GUI tree."
    
    def __init__(self, level = 0, label = "") :
        "Constructor."
        assert type(level) == IntType, "node level must be an integer"
        assert 0 <= level < AVAILABLE_LABEL_COLUMNS, "node level is out of range"
        self.level = level
        self.link = Link(
            text = label, 
            spacing_above = "none", 
            spacing_below = "none",
            border = "none")
        self.button = Button(
            text = LEAF_NODE, 
            spacing_above = "none", 
            spacing_below = "none",
            border = "none")
        # We set spacing to none, because when an object is hidden, its spacing isn't.
        # All that leftover spacing accumulates to look like missing row(s).

But we also need a way to attach that node to some GridPanel:

def append_to(self, array, grid, prefix) :
    "Append self to tree-supplied array and grid."
    row_name = prefix + repr(len(array))
    array.append(self)
    grid.add_component(self.button,
        row = row_name,
        col_xs = self.level,
        width_xs = BUTTON_WIDTH_COLUMNS)
    next_start_column = self.level + BUTTON_WIDTH_COLUMNS
    grid.add_component(self.link,
        row = row_name,
        col_xs = next_start_column,
        width_xs = AVAILABLE_LABEL_COLUMNS - next_start_column)
    # a subclass with more components will want to override this function.
    self.button.visible = False

This is peaceful coexistence. That is, the new node will be placed on a new row, immediately below all of your GridPanel’s existing components (if any).

About append_to()
Does the above seem a bit complex? Probably. The code was written to address some of GridPanel’s specific design features.

First, GridPanel rows are named, not numbered. To add a row, we needed to generate a unique new row name. While numbers make somewhat good names – at least, you can’t run out of them – by themselves, they’re hardly unique. That’s a potential problem. If our node’s row name was already in use, then our new node would likely land on top of some other component, triggering an error condition.

To prevent that, we allowed row names to incorporate a caller-supplied prefix. Presumably, you’ve already built your GridPanel, so you should know how your other rows are named. You can then choose a suitable prefix, to prevent any conflicts.

Second, for some operations (expand/collapse), we’ll need to iterate over all the affected nodes.

That really isn’t easy with the GridPanel alone. It doesn’t see a “node”; it sees only a long list of individual parts, with no concrete relationship between them. For a more coherent (and iterable) view, therefore, we also attach our node to a caller-supplied array (a Python list). This gives the caller a quick shortcut to every node in the outline.

Other design considerations

  1. Multiple Outlines in the same GridPanel

Technically, with this setup, you could have any number of distinct outlines in your GridPanel, each with its own list and prefix. Just make sure that you finish adding one outline, before starting the next. GridPanel is effectively append-only. If you mix rows from different outlines together, GridPanel won’t unmix them for you!

  1. “Leaf” vs. “Nonleaf” nodes

Why are we hiding the button? Well, at this point, we don’t know whether this node has any child nodes. In practice, most outline entries lie at the tips of their branches: they are “leaves”. And a leaf has no use for a button; it has no children to hide. So, by default, we hide the buttons. But we will find (and unhide) the usable buttons later.

In a different implementation, we might skip creating the buttons, until we know which rows actually need one. That may or may not be more efficient. A lot depends on whether the outline can change shape – adding or removing children – after initial construction. I’m erring on the side of caution, here.

  1. Fancier rows

Likewise, another implementation might have additional entries per row. For example, if your outline is a to-do list, then you might add a “% done” column. This would change the node (and its code) slightly.

I think this is enough for now. More infrastructure to come (I hope!).

5 Likes

Hi @p.colbert!

I’m going through old Show and Tell posts and found this one – which I like very much.

Did you every post any follow-ups?

As it turns out, no. Work time took over, and I had to scrap that development. The replacement ended up much more complex, due to incremental upgrades instead of a total redesign.

It’s now pretty clear that there are 3rd-party Javascript outlines that can do a much better job, with much less overhead. The effort is better spent wrapping them in Python than reinventing the wheel.

Edit: But also see

1 Like

Thank you for the link to Stefano’s Show and Tell post, which I had also found interesting yesterday.

If you’ve had any particularly good (or particularly bad :wink:) experience with specific Javascript outliner codebases, I’d be grateful if you shared that.

If not, then no worries. And, in any event, I very much appreciate your taking the time to reply to my inquiry regarding a very old post.

No particular experience so far. I can say that such libraries can be hard to find.

It’s good to develop a set of criteria in advance, e.g.,

  • can expand and collapse
  • nodes can be hidden, disabled, formatted (fg/bg color, font, italic/bold, …)
  • node can have icon(s)
  • icon can change depending on state of node (expanded/collapsed)
  • node can have checkbox
  • tree can have additional columns
  • tree can be constructed from JSON, your favorite outliner, or nested dicts/lists
  • tree structure can be modified after creation
  • tree can run horizontally (expanding/collapsing columns instead of rows)
  • attach popup hints or menus to nodes
  • attach your own data to nodes
  • iterate over the tree (or a branch) in one of several orders
  • visually distinct node types or styles
  • etc.

Not every library supports every option, and your app may not need every option. Your Python wrapper might be used to fill in gaps.

But a good set of criteria can quickly eliminate some libraries from consideration.

1 Like

Thank you for the good advice!

There are a couple of features in your ‘shopping list’ that I might not have thought about until it was too late. :woozy_face: