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!).

4 Likes