Repository: dankamongmen/notcurses (language: python)

Issue:
add nctree_add()/nctree_del()
We currently have no way to dynamically change up an `nctree`. We ought be able to add an element at a path, and delete an element specified by some path.
add nctree_add()/nctree_del()
We currently have no way to dynamically change up an `nctree`. We ought be able to add an element at a path, and delete an element specified by some path.
add nctree_add()/nctree_del()
We currently have no way to dynamically change up an `nctree`. We ought be able to add an element at a path, and delete an element specified by some path.
add nctree_add()/nctree_del()
We currently have no way to dynamically change up an `nctree`. We ought be able to add an element at a path, and delete an element specified by some path.
add nctree_add()/nctree_del()
We currently have no way to dynamically change up an `nctree`. We ought be able to add an element at a path, and delete an element specified by some path.


Discussion / hints:

Fix patch:
diff --git a/NEWS.md b/NEWS.md
index af1b772307..fd34fab8da 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -10,6 +10,7 @@ rearrangements of Notcurses.
     or bottom (scrolling enabled) boundaries.
   * Added `notcurses_default_background()` and `notcurses_default_foreground()`.
   * Added `nccell_load_ucs32()`.
+  * Added `nctree_add()` and `nctree_del()`. `nctree` is now dynamic.
 
 * 3.0.0 (2021-12-01) **"In the A"**
   * Made the ABI/API changes that have been planned/collected during 2.x
diff --git a/README.md b/README.md
index 38fcf79748..19bb540552 100644
--- a/README.md
+++ b/README.md
@@ -543,6 +543,16 @@ If things break or seem otherwise lackluster, **please** consult the
   terminal. No known terminal emulators exhibit this behavior.
 </details>
 
+<details>
+  <summary>How can I draw a large plane, and only make a portion of it visible?</summary>
+  The simplest way is probably to create a plane of the same dimensions immediately above
+  the plane, and keep a region of it transparent, and the rest opaque. If you want the visible
+  area to stay in the same place on the display, but the portion being seen to change, try
+  making a plane twice as large in each dimension as the original plane. Make the desired area
+  transparent, and the rest opaque. Now move the original plane behind this plane so that the
+  desired area lines up with the &ldquo;hole&rdquo;.
+</details>
+
 <details>
   <summary>Why no <code>NCSTYLE_REVERSE</code>?</summary>
   It would consume a precious bit. You can use <code>ncchannels_reverse()</code>
diff --git a/doc/man/man1/tfman.1.md b/doc/man/man1/tfman.1.md
index ea32cdebca..cdd569e747 100644
--- a/doc/man/man1/tfman.1.md
+++ b/doc/man/man1/tfman.1.md
@@ -36,6 +36,9 @@ The following keypresses are recognized:
 
 # BUGS
 
+**tfman** does not currently (and is unlikely to ever) support the full
+**groff** macro language.
+
 # SEE ALSO
 
 **man(1)**,
diff --git a/doc/man/man3/notcurses_tree.3.md b/doc/man/man3/notcurses_tree.3.md
index 654610af9f..75448d57ab 100644
--- a/doc/man/man3/notcurses_tree.3.md
+++ b/doc/man/man3/notcurses_tree.3.md
@@ -44,16 +44,19 @@ typedef struct nctree_options {
 
 **void* nctree_prev(struct nctree* ***n***);**
 
-**void* nctree_goto(struct nctree* ***n***, const int* ***spec***, size_t ***specdepth***, int* ***failspec***);**
+**void* nctree_goto(struct nctree* ***n***, const int* ***path***, int* ***failpath***);**
+
+**int nctree_add(struct nctree* ***n***, const unsigned* ***path***, const nctree_item* ***add***);**
+
+**int nctree_del(struct nctree* ***n***, const unsigned* ***path***);**
 
 **void nctree_destroy(struct nctree* ***n***);**
 
 # DESCRIPTION
 
-**nctree**s organize static hierarchical items, and allow them to be browsed.
-Each item can have arbitrary subitems. Items can be collapsed and expanded.
-The display supports scrolling and searching. Items cannot be added or removed,
-however; they must be provided in their entirety at creation time.
+**nctree**s organize hierarchical items, and allow them to be browsed. Each
+item can have arbitrary subitems. Items can be collapsed and expanded.
+The display supports scrolling and searching.
 
 An **nctree** cannot be empty. **count** must be non-zero, and **items** must
 not be **NULL**. The callback function **nctreecb** must also be non-**NULL**.
@@ -72,6 +75,12 @@ is expanded in the callback, it will be shrunk back down by the widget. The
 is negative, the item is before the focused item; a positive parameter implies
 that the item follows the focused item; the focused item itself is passed zero.
 
+**nctree_goto**, **nctree_add**, and **nctree_del** all use the concept of a
+***path***. A path is an array of **unsigned** values, terminated by
+**UINT_MAX**, with each successive value indexing into the hierarchy thus far.
+The root node of the **nctree** thus always has a 2-element path of
+[0, **UINT_MAX**].
+
 # RETURN VALUES
 
 **nctree_create** returns **NULL** for invalid options. This includes a **NULL**
@@ -81,11 +90,23 @@ that the item follows the focused item; the focused item itself is passed zero.
 newly-focused item. **nctree_focused** returns the **curry** pointer from the
 already-focused item.
 
+**nctree_goto** returns the **curry** pointer from the newly-focused item. If
+***path*** is invalid, the first invalid hierarchy level is written to
+***failpath***, and **NULL** is returned. If ***path*** is valid, the value -1
+is written to ***failpath***. Since it is possible for a ***curry***
+pointer to be **NULL**, one should check ***failpath*** before assuming the
+operation failed.
+
+**nctree_add** and **nctree_del** both return -1 for an invalid ***path***, and
+0 otherwise.
+
 # NOTES
 
 **nctree** shares many properties with **notcurses_reel**. Unlike the latter,
-**nctree**s support arbitrary hierarchical levels, but they do not allow
-elements to come and go across the lifetime of the widget.
+**nctree**s support arbitrary hierarchical levels.
+
+**nctree** used to only handle static data, but **nctree_add** and
+**nctree_del** were added in Notcurses 3.0.1.
 
 # SEE ALSO
 
diff --git a/include/notcurses/notcurses.h b/include/notcurses/notcurses.h
index b1bdff2fec..0e44b8e763 100644
--- a/include/notcurses/notcurses.h
+++ b/include/notcurses/notcurses.h
@@ -3829,14 +3829,14 @@ API void ncmultiselector_destroy(struct ncmultiselector* n);
 // of the callback.
 
 // each item has a curry, and zero or more subitems.
-typedef struct nctree_item {
+struct nctree_item {
   void* curry;
   struct nctree_item* subs;
   unsigned subcount;
-} nctree_item;
+};
 
 typedef struct nctree_options {
-  const nctree_item* items; // top-level nctree_item array
+  const struct nctree_item* items; // top-level nctree_item array
   unsigned count;           // size of |items|
   int (*nctreecb)(struct ncplane*, void*, int); // item callback function
   int indentcols;           // columns to indent per level of hierarchy
@@ -3877,13 +3877,24 @@ API void* nctree_next(struct nctree* n) __attribute__ ((nonnull (1)));
 // Change focus to the previous item.
 API void* nctree_prev(struct nctree* n) __attribute__ ((nonnull (1)));
 
-// Go to the item specified by the array |spec|, terminated by UINT_MAX. If
-// the spec is invalid, NULL is returned, and the depth of the first invalid
-// spec is written to *|failspec|. Otherwise, the true depth is written to
-// *|failspec|, and the curry is returned (|failspec| is necessary because the
-// curry could itself be NULL).
+// Go to the item specified by the array |spec| (a spec is a series of unsigned
+// values, each identifying a subelement in the hierarchy thus far, terminated
+// by UINT_MAX). If the spec is invalid, NULL is returned, and the depth of the
+// first invalid spec is written to *|failspec|. Otherwise, the true depth is
+// written to *|failspec|, and the curry is returned (|failspec| is necessary
+// because the curry could itself be NULL).
 API void* nctree_goto(struct nctree* n, const unsigned* spec, int* failspec);
 
+// Insert |add| into the nctree |n| at |spec|. The path up to the last element
+// must already exist. If an item already exists at the path, it will be moved
+// to make room for |add|.
+API int nctree_add(struct nctree* n, const unsigned* spec, const struct nctree_item* add)
+  __attribute__ ((nonnull (1, 2, 3)));
+
+// Delete the item at |spec|, including any subitems.
+API int nctree_del(struct nctree* n, const unsigned* spec)
+  __attribute__ ((nonnull (1, 2)));
+
 // Destroy the nctree.
 API void nctree_destroy(struct nctree* n);
 
diff --git a/src/lib/tree.c b/src/lib/tree.c
index 6e09f9990d..7d211f05c7 100644
--- a/src/lib/tree.c
+++ b/src/lib/tree.c
@@ -14,7 +14,7 @@ typedef struct nctree {
   nctree_int_item* curitem; // item addressed by the path
   unsigned maxdepth;        // maximum hierarchy level
   unsigned* currentpath;    // array of |maxdepth|+1 elements, ended by UINT_MAX
-  int activerow;            // active row 0 <= activerow < dimy
+  int activerow;            // active row -1 <= activerow < dimy
   int indentcols;           // cols to indent per level
   uint64_t bchannels;       // border glyph channels
 } nctree;
@@ -33,8 +33,8 @@ free_tree_items(nctree_int_item* iarray){
 // allocates a |count|-sized array of nctree_int_items, and fills |fill| in,
 // using |items|. updates |*maxdepth| when appropriate.
 static int
-dup_tree_items(nctree_int_item* fill, const nctree_item* items, unsigned count, unsigned depth,
-               unsigned* maxdepth){
+dup_tree_items(nctree_int_item* fill, const struct nctree_item* items,
+               unsigned count, unsigned depth, unsigned* maxdepth){
   fill->subcount = count;
   // FIXME perhaps better to alloc a page at a time and take all items from
   // there, for better TLB performance?
@@ -81,10 +81,16 @@ goto_last_item(nctree* n){
 
 static void
 goto_first_item(nctree* n){
-  n->currentpath[0] = 0;
-  n->currentpath[1] = UINT_MAX;
-  n->curitem = &n->items.subs[0];
-  n->activerow = 0;
+  if(n->maxdepth == 0){
+    n->currentpath[0] = UINT_MAX;
+    n->curitem = NULL;
+    n->activerow = -1;
+  }else{
+    n->currentpath[0] = 0;
+    n->currentpath[1] = UINT_MAX;
+    n->curitem = &n->items.subs[0];
+    n->activerow = 0;
+  }
 }
 
 // the initial path ought point to the first item.
@@ -122,12 +128,97 @@ nctree_inner_create(ncplane* n, const nctree_options* opts){
   return ret;
 }
 
+int nctree_add(nctree* n, const unsigned* spec, const struct nctree_item* add){
+  // it's illegal to pass an empty path for addition; one must pass { 0, UINT_MAX }
+  if(spec[0] == UINT_MAX){
+    logerror("invalid empty path\n");
+    return -1;
+  }
+  struct nctree_int_item* nii = &n->items;
+  const unsigned* p = spec;
+  unsigned depth = 0;
+  while(p[1] != UINT_MAX){ // we know p[1] isn't UINT_MAX
+    if(*p >= nii->subcount){
+      logerror("invalid path element (%u >= %u)\n", *p, nii->subcount);
+      return -1;
+    }
+    nii = &nii->subs[*p];
+    ++p;
+    ++depth;
+  }
+  // this last one can be equal to subcount; we're placing it at the end
+  if(*p > nii->subcount){
+    logerror("invalid path element (%u >= %u)\n", *p, nii->subcount);
+    return -1;
+  }
+  struct nctree_int_item* tmparr = realloc(nii->subs, sizeof(*nii->subs) * (nii->subcount + 1));
+  if(tmparr == NULL){
+    return -1;
+  }
+  nii->subs = tmparr;
+  if(*p != nii->subcount++){
+    memmove(&nii->subs[*p], &nii->subs[*p + 1], sizeof(*nii->subs) * (nii->subcount - *p));
+  }
+  if(p - spec >= n->maxdepth){
+    unsigned max = p - spec + 1;
+    unsigned* tmp = realloc(n->currentpath, sizeof(*n->currentpath) * (max + 1));
+    if(tmp == NULL){
+      return -1;
+    }
+    n->currentpath = tmp;
+    n->currentpath[max] = UINT_MAX;
+    n->maxdepth = max;
+  }
+  nii->subs[*p].subs = NULL;
+  nii->subs[*p].subcount = 0;
+  nii->subs[*p].curry = NULL;
+  nii->subs[*p].ncp = NULL;
+  if(dup_tree_items(&nii->subs[*p], add->subs, add->subcount, depth, &n->maxdepth)){
+    return -1;
+  }
+  if(n->activerow == -1){
+    n->activerow = 0;
+    n->curitem = &n->items;
+    n->currentpath[0] = 0;
+  }
+  return 0;
+}
+
+int nctree_del(nctree* n, const unsigned* spec){
+  nctree_int_item* parent = NULL;
+  nctree_int_item* nii = &n->items;
+  const unsigned* p = spec;
+  while(*p != UINT_MAX){
+    if(*p >= nii->subcount){
+      logerror("invalid path element (%u >= %u)\n", *p, nii->subcount);
+      return -1;
+    }
+    parent = nii;
+    nii = &nii->subs[*p];
+    ++p;
+  }
+  free_tree_items(nii);
+  if(parent){
+    // parent can only be defined if we had at least one path element
+    unsigned lastelem = spec[-1];
+    if(lastelem != --parent->subcount){
+      memmove(&parent->subs[lastelem], &parent->subs[lastelem + 1],
+              sizeof(*parent->subs) * (parent->subcount - lastelem));
+    }
+  }
+  if(n->items.subcount == 0){
+    n->activerow = -1;
+    n->curitem = NULL;
+  }
+  return 0;
+}
+
 nctree* nctree_create(ncplane* n, const nctree_options* opts){
   if(opts->flags){
     logwarn("Passed invalid flags 0x%016" PRIx64 "\n", opts->flags);
   }
-  if(opts->count == 0 || opts->items == NULL){
-    logerror("Can't create empty tree\n");
+  if(n == notcurses_stdplane(ncplane_notcurses(n))){
+    logerror("can't use the standard plane\n");
     goto error;
   }
   if(opts->nctreecb == NULL){
@@ -283,7 +374,6 @@ tree_path_length(const unsigned* path){
 static int
 draw_tree_item(nctree* n, nctree_int_item* nii, const unsigned* path,
                int* frontiert, int* frontierb, int distance){
-//fprintf(stderr, "drawing item ft: %d fb: %d %p\n", *frontiert, *frontierb, nii->ncp);
   if(!nii->ncp){
     const int startx = (tree_path_length(path) - 1) * n->indentcols;
     int ymin, ymax;
@@ -375,6 +465,9 @@ destroy_below(nctree* n, nctree_int_item* nii, unsigned* path, int distance){
 // to hold n->maxdepth + 1 unsigneds.
 static int
 nctree_inner_redraw(nctree* n, unsigned* tmppath){
+  if(n->activerow < 0){
+    return 0;
+  }
   ncplane* ncp = n->items.ncp;
   if(ncplane_cursor_move_yx(ncp, n->activerow, 0)){
     return -1;
diff --git a/src/man/main.c b/src/man/main.c
index db7209c8ea..d9065fc4e2 100644
--- a/src/man/main.c
+++ b/src/man/main.c
@@ -9,6 +9,7 @@
 #include <sys/stat.h>
 #include <sys/types.h>
 #include <notcurses/notcurses.h>
+#include "structure.h"
 #include "builddef.h"
 
 static void
@@ -172,6 +173,7 @@ typedef enum {
   LINE_TP, LINE_TQ,
   LINE_ME, LINE_MT, LINE_UE, LINE_UR,
   LINE_OP, LINE_SY, LINE_YS,
+  LINE_NF, LINE_FI,
 } ltypes;
 
 typedef enum {
@@ -181,7 +183,8 @@ typedef enum {
   TROFF_STRUCTURE,
   TROFF_PARAGRAPH,
   TROFF_HYPERLINK,
-  TROFF_SYNOPSIS
+  TROFF_SYNOPSIS,
+  TROFF_PREFORMATTED,
 } ttypes;
 
 typedef struct {
@@ -222,6 +225,8 @@ static const trofftype trofftypes[] = {
   { .ltype = LINE_UNKNOWN, .symbol = "hy", .ttype = TROFF_UNKNOWN, .channel = 0, },
   { .ltype = LINE_UNKNOWN, .symbol = "br", .ttype = TROFF_UNKNOWN, .channel = 0, },
   { .ltype = LINE_COMMENT, .symbol = "IX", .ttype = TROFF_COMMENT, .channel = 0, },
+  { .ltype = LINE_NF, .symbol = "nf", .ttype = TROFF_FONT, .channel = 0, },
+  { .ltype = LINE_FI, .symbol = "fi", .ttype = TROFF_FONT, .channel = 0, },
 };
 
 // the troff trie is only defined on the 128 ascii values.
@@ -324,6 +329,7 @@ typedef struct pagedom {
   char* version;
   char* footer;
   char* header;
+  struct docstructure* ds;
 } pagedom;
 
 static const char*
@@ -456,6 +462,7 @@ troff_parse(const unsigned char* map, size_t mlen, pagedom* dom){
   pagenode* current_section = NULL;
   pagenode* current_subsection = NULL;
   pagenode* current_para = NULL;
+  bool preformatted = false;
   for(size_t off = 0 ; off < mlen ; ++off){
     const unsigned char* ws = line;
     size_t left = mlen - off;
@@ -467,84 +474,14 @@ troff_parse(const unsigned char* map, size_t mlen, pagedom* dom){
       ++eol;
       --left;
     }
-    // functional end of line--doesn't include possible newline
     const unsigned char* feol = eol;
-    if(left && *eol == '\n'){
-      --feol;
-    }
-    if(node){
-      if(node->ltype == LINE_TH){
-        if(dom_get_title(dom)){
-          fprintf(stderr, "found a second title (was %s)\n", dom_get_title(dom));
-          return -1;
-        }
-        char* et = extract_text(ws, feol);
-        if(et == NULL){
-          return -1;
-        }
-        if((dom->root = malloc(sizeof(*dom->root))) == NULL){
-          free(et);
-          return -1;
-        }
-        memset(dom->root, 0, sizeof(*dom->root));
-        dom->root->ttype = node;
-        dom->root->text = et;
-        if(lex_title(dom)){
-          return -1;
-        }
-        current_para = dom->root;
-      }else if(node->ltype == LINE_SH){
-        if(dom->root == NULL){
-          fprintf(stderr, "section transcends structure\n");
-          return -1;
-        }
-        char* et = extract_text(ws, feol);
-        if(et == NULL){
-          return -1;
-        }
-        if((current_section = add_node(dom->root, et)) == NULL){
-          free(et);
-          return -1;
-        }
-        current_section->ttype = node;
-        current_subsection = NULL;
-        current_para = current_section;
-      }else if(node->ltype == LINE_SS){
-        char* et = extract_text(ws, feol);
-        if(et == NULL){
-          return -1;
-        }
-        if(current_section == NULL){
-          fprintf(stderr, "subsection %s without section\n", et);
-          free(et);
-          return -1;
-        }
-        if((current_subsection = add_node(current_section, et)) == NULL){
-          free(et);
-          return -1;
-        }
-        current_subsection->ttype = node;
-        current_para = current_subsection;
-      }else if(node->ltype == LINE_PP){
-        if(dom->root == NULL){
-          fprintf(stderr, "paragraph transcends structure\n");
-          return -1;
-        }
-        if((current_para = add_node(current_para, NULL)) == NULL){
-          return -1;
-        }
-        current_para->ttype = node;
-      }else if(node->ltype == LINE_TP){
-        if(dom->root == NULL){
-          fprintf(stderr, "tagged paragraph transcends structure\n");
-          return -1;
-        }
-        if((current_para = add_node(current_para, NULL)) == NULL){
-          return -1;
-        }
-        current_para->ttype = node;
+    // functional end of line--doesn't include possible newline
+    if(!preformatted){
+      if(left && *eol == '\n'){
+        --feol;
       }
-    }else{
+    }
+    if(node == NULL){
       if(current_para == NULL){
         //fprintf(stderr, "free-floating text transcends para\n");
         //fprintf(stderr, "[%s]\n", line);
@@ -554,6 +491,80 @@ troff_parse(const unsigned char* map, size_t mlen, pagedom* dom){
           return -1;
         }
       }
+    }else if(node->ltype == LINE_NF){
+      preformatted = true;
+    }else if(node->ltype == LINE_FI){
+      preformatted = false;
+    }else if(node->ltype == LINE_TH){
+      if(dom_get_title(dom)){
+        fprintf(stderr, "found a second title (was %s)\n", dom_get_title(dom));
+        return -1;
+      }
+      char* et = extract_text(ws, feol);
+      if(et == NULL){
+        return -1;
+      }
+      if((dom->root = malloc(sizeof(*dom->root))) == NULL){
+        free(et);
+        return -1;
+      }
+      memset(dom->root, 0, sizeof(*dom->root));
+      dom->root->ttype = node;
+      dom->root->text = et;
+      if(lex_title(dom)){
+        return -1;
+      }
+      current_para = dom->root;
+    }else if(node->ltype == LINE_SH){
+      if(dom->root == NULL){
+        fprintf(stderr, "section transcends structure\n");
+        return -1;
+      }
+      char* et = extract_text(ws, feol);
+      if(et == NULL){
+        return -1;
+      }
+      if((current_section = add_node(dom->root, et)) == NULL){
+        free(et);
+        return -1;
+      }
+      current_section->ttype = node;
+      current_subsection = NULL;
+      current_para = current_section;
+    }else if(node->ltype == LINE_SS){
+      char* et = extract_text(ws, feol);
+      if(et == NULL){
+        return -1;
+      }
+      if(current_section == NULL){
+        fprintf(stderr, "subsection %s without section\n", et);
+        free(et);
+        return -1;
+      }
+      if((current_subsection = add_node(current_section, et)) == NULL){
+        free(et);
+        return -1;
+      }
+      current_subsection->ttype = node;
+      current_para = current_subsection;
+    }else if(node->ltype == LINE_PP){
+      if(dom->root == NULL){
+        fprintf(stderr, "paragraph transcends structure\n");
+        return -1;
+      }
+      if((current_para = add_node(current_para, NULL)) == NULL){
+        return -1;
+      }
+      current_para->ttype = node;
+    }else if(node->ltype == LINE_TP){
+      if(dom->root == NULL){
+        fprintf(stderr, "tagged paragraph transcends structure\n");
+        return -1;
+      }
+      if((current_para = add_node(current_para, NULL)) == NULL){
+        return -1;
+      }
+      current_para->ttype = node;
     }
     off += eol - line;
     line = eol + 1;
@@ -617,6 +628,7 @@ putpara(struct ncplane* p, const char* text){
               case 'R': style = 0; break; // roman, default
               case 'I': style |= NCSTYLE_ITALIC; break;
               case 'B': style |= NCSTYLE_BOLD; break;
+              case 'C': break; // unsure! seems to be used with .nf/.fi
               default:
                 fprintf(stderr, "illegal font macro %s\n", curend);
                 return -1;
@@ -636,8 +648,13 @@ putpara(struct ncplane* p, const char* text){
           } macros[] = {
             { "aq]", "'", },
             { "dq]", "\"", },
+            { "lq]", u8"\u201c", }, // left double quote
+            { "rq]", u8"\u201d", }, // right double quote
+            { "em]", u8"\u2014", }, // em dash
+            { "en]", u8"\u2013", }, // en dash
             { "rg]", "®", },
             { "rs]", "\\", },
+            { "ti]", "~", },
             { NULL, NULL, }
           };
           ++curend;
@@ -684,13 +701,20 @@ draw_domnode(struct ncplane* p, const pagedom* dom, const pagenode* n,
   ncplane_set_fchannel(p, n->ttype->channel);
   size_t b = 0;
   switch(n->ttype->ltype){
-    case LINE_TH: /*
+    case LINE_TH:
+      if(docstructure_add(dom->ds, dom->title, ncplane_y(p), DOCSTRUCTURE_TITLE)){
+        return -1;
+      }
+      /*
       ncplane_set_styles(p, NCSTYLE_UNDERLINE);
       ncplane_printf_aligned(p, 0, NCALIGN_LEFT, "%s(%s)", dom->title, dom->section);
       ncplane_printf_aligned(p, 0, NCALIGN_RIGHT, "%s(%s)", dom->title, dom->section);
       ncplane_set_styles(p, NCSTYLE_NONE);
       */break;
     case LINE_SH: // section heading
+      if(docstructure_add(dom->ds, dom->title, ncplane_y(p), DOCSTRUCTURE_SECTION)){
+        return -1;
+      }
       if(strcmp(n->text, "NAME")){
         ncplane_puttext(p, -1, NCALIGN_LEFT, "\n\n", &b);
         ncplane_set_styles(p, NCSTYLE_BOLD | NCSTYLE_UNDERLINE);
@@ -701,6 +725,9 @@ draw_domnode(struct ncplane* p, const pagedom* dom, const pagenode* n,
       }
       break;
     case LINE_SS: // subsection heading
+      if(docstructure_add(dom->ds, dom->title, ncplane_y(p), DOCSTRUCTURE_SUBSECTION)){
+        return -1;
+      }
       ncplane_puttext(p, -1, NCALIGN_LEFT, "\n\n", &b);
       ncplane_set_styles(p, NCSTYLE_ITALIC | NCSTYLE_UNDERLINE);
       ncplane_putstr_aligned(p, -1, NCALIGN_CENTER, n->text);
@@ -740,9 +767,13 @@ draw_domnode(struct ncplane* p, const pagedom* dom, const pagenode* n,
 // scroll the entire plane. higher memory cost, longer initial latency,
 // very fast moves.
 static int
-draw_content(struct ncplane* p){
-  const pagedom* dom = ncplane_userptr(p);
+draw_content(struct ncplane* stdn, struct ncplane* p){
+  pagedom* dom = ncplane_userptr(p);
   unsigned wrotetext = 0; // unused by us
+  dom->ds = docstructure_create(stdn);
+  if(dom->ds == NULL){
+    return -1;
+  }
   return draw_domnode(p, dom, dom->root, &wrotetext);
 }
 
@@ -751,7 +782,8 @@ resize_pman(struct ncplane* pman){
   unsigned dimy, dimx;
   ncplane_dim_yx(ncplane_parent_const(pman), &dimy, &dimx);
   ncplane_resize_simple(pman, dimy - 1, dimx);
-  int r = draw_content(pman);
+  struct ncplane* stdn = notcurses_stdplane(ncplane_notcurses(pman));
+  int r = draw_content(stdn, pman);
   ncplane_move_yx(pman, 0, 0);
   return r;
 }
@@ -779,7 +811,7 @@ render_troff(struct notcurses* nc, const unsigned char* map, size_t mlen,
   if(pman == NULL){
     return NULL;
   }
-  if(draw_content(pman)){
+  if(draw_content(stdn, pman)){
     ncplane_destroy(pman);
     return NULL;
   }
@@ -838,6 +870,7 @@ pagedom_destroy(pagedom* dom){
   free(dom->title);
   free(dom->version);
   free(dom->section);
+  docstructure_free(dom->ds);
 }
 
 static struct ncplane*
diff --git a/src/man/structure.c b/src/man/structure.c
new file mode 100644
index 0000000000..fce3e12976
--- /dev/null
+++ b/src/man/structure.c
@@ -0,0 +1,125 @@
+#include <limits.h>
+#include <notcurses/notcurses.h>
+#include "structure.h"
+
+#define HIERARCHY_MAX 3
+
+typedef struct docnode {
+  char* title;
+  int line;
+  docstruct_e level;
+} docnode;
+
+typedef struct docstructure {
+  struct nctree* nct;
+  // one entry for each hierarchy level + terminator
+  unsigned curpath[HIERARCHY_MAX + 1];
+} docstructure;
+
+static int
+docstruct_callback(struct ncplane* n, void* curry, int i){
+  (void)n; // FIXME
+  (void)curry;
+  (void)i;
+  return 0;
+}
+
+docstructure* docstructure_create(struct ncplane* n){
+  docstructure* ds = malloc(sizeof(*ds));
+  if(ds == NULL){
+    return NULL;
+  }
+  const int ROWS = 7;
+  const int COLDIV = 4;
+  ncplane_options nopts = {
+    .rows = ROWS,
+    .cols = ncplane_dim_x(n) / COLDIV,
+    .y = ncplane_dim_y(n) - ROWS,
+    .x = ncplane_dim_x(n) - (ncplane_dim_x(n) / COLDIV) - 1,
+    .flags = NCPLANE_OPTION_AUTOGROW, // autogrow to right
+  };
+  struct ncplane* p = ncplane_create(n, &nopts);
+  if(p == NULL){
+    return NULL;
+  }
+  uint64_t channels = NCCHANNELS_INITIALIZER(0, 0, 0, 0x80, 0x80, 0x80);
+  ncplane_set_base(p, "", 0, channels);
+  nctree_options topts = {
+    .nctreecb = docstruct_callback,
+  };
+  if((ds->nct = nctree_create(p, &topts)) == NULL){
+    free(ds);
+    return NULL;
+  }
+  for(unsigned z = 0 ; z < sizeof(ds->curpath) / sizeof(*ds->curpath) ; ++z){
+    ds->curpath[z] = UINT_MAX;
+  }
+  return ds;
+}
+
+void docstructure_free(docstructure* ds){
+  if(ds){
+    nctree_destroy(ds->nct);
+    free(ds);
+  }
+}
+
+static void
+docnode_free(docnode* dn){
+  if(dn){
+    free(dn->title);
+    free(dn);
+  }
+}
+
+static docnode*
+docnode_create(const char* title, int line, docstruct_e level){
+  docnode* dn = malloc(sizeof(*dn));
+  if(dn == NULL){
+    return NULL;
+  }
+  if((dn->title = strdup(title)) == NULL){
+    free(dn);
+    return NULL;
+  }
+  dn->level = level;
+  dn->line = line;
+  return dn;
+}
+
+// add the specified [sub]section to the document strucure. |line| refers to
+// the row on the display plane, *not* the line in the original content.
+int docstructure_add(docstructure* ds, const char* title, int line,
+                     docstruct_e level){
+  unsigned addpath[sizeof(ds->curpath) / sizeof(*ds->curpath)];
+  if(level < 0 || (unsigned)level >= sizeof(addpath) / sizeof(*addpath) - 1){
+    fprintf(stderr, "invalid level %d\n", level);
+    return -1;
+  }
+  docnode* dn = docnode_create(title, line, level);
+  if(dn == NULL){
+    return -1;
+  }
+  unsigned z = 0;
+  while(z < level){
+    if((addpath[z] = ds->curpath[z]) == UINT_MAX){
+      ds->curpath[z] = 0;
+      addpath[z] = 0;
+    }
+    ++z;
+  }
+  addpath[z] = ds->curpath[z] + 1;
+  addpath[z + 1] = UINT_MAX;
+  struct nctree_item nitem = {
+    .curry = dn,
+  };
+  if(nctree_add(ds->nct, addpath, &nitem)){
+    docnode_free(dn);
+    return -1;
+  }
+  ds->curpath[z] = addpath[z];
+  if(nctree_redraw(ds->nct)){
+    return -1;
+  }
+  return 0;
+}
diff --git a/src/man/structure.h b/src/man/structure.h
new file mode 100644
index 0000000000..83a15eb80d
--- /dev/null
+++ b/src/man/structure.h
@@ -0,0 +1,29 @@
+#ifndef ncplane_MAN_STRUCTURE
+#define ncplane_MAN_STRUCTURE
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+struct ncplane;
+struct docstructure;
+
+typedef enum {
+  DOCSTRUCTURE_TITLE,
+  DOCSTRUCTURE_SECTION,
+  DOCSTRUCTURE_SUBSECTION,
+} docstruct_e;
+
+struct docstructure* docstructure_create(struct ncplane* n);
+void docstructure_free(struct docstructure* ds);
+
+// add the specified [sub]section to the document strucure. |line| refers to
+// the row on the display plane, *not* the line in the original content.
+int docstructure_add(struct docstructure* ds, const char* title, int line,
+                     docstruct_e level);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
diff --git a/src/poc/tree.c b/src/poc/tree.c
index e450229841..2495a024f3 100644
--- a/src/poc/tree.c
+++ b/src/poc/tree.c
@@ -1,6 +1,6 @@
 #include "notcurses/notcurses.h"
 
-static nctree_item alphaUs[] = {
+static struct nctree_item alphaUs[] = {
   {
     .subs = NULL,
     .subcount = 0,
@@ -80,13 +80,13 @@ static nctree_item alphaUs[] = {
   }
 };
 
-static nctree_item alphaU = {
+static struct nctree_item alphaU = {
   .subs = alphaUs,
   .subcount = sizeof(alphaUs) / sizeof(*alphaUs),
   .curry = "ɑ-emitting U",
 };
 
-static nctree_item doubleUs[] = {
+static struct nctree_item doubleUs[] = {
   {
     .subs = NULL,
     .subcount = 0,
@@ -94,13 +94,13 @@ static nctree_item doubleUs[] = {
   }
 };
 
-static nctree_item doubleU = {
+static struct nctree_item doubleU = {
   .subs = doubleUs,
   .subcount = sizeof(doubleUs) / sizeof(*doubleUs),
   .curry = "ββ-emitting U",
 };
 
-static nctree_item doubleminusUs[] = {
+static struct nctree_item doubleminusUs[] = {
   {
     .subs = NULL,
     .subcount = 0,
@@ -108,13 +108,13 @@ static nctree_item doubleminusUs[] = {
   }
 };
 
-static nctree_item doubleminusU = {
+static struct nctree_item doubleminusU = {
   .subs = doubleminusUs,
   .subcount = sizeof(doubleminusUs) / sizeof(*doubleminusUs),
   .curry = "β−β−-emitting U",
 };
 
-static nctree_item betaminusUs[] = {
+static struct nctree_item betaminusUs[] = {
   {
     .subs = NULL,
     .subcount = 0,
@@ -130,7 +130,7 @@ static nctree_item betaminusUs[] = {
   }
 };
 
-static nctree_item betaminusPus[] = {
+static struct nctree_item betaminusPus[] = {
   {
     .subs = NULL,
     .subcount = 0,
@@ -154,7 +154,7 @@ static nctree_item betaminusPus[] = {
   }
 };
 
-static nctree_item betaminus[] = {
+static struct nctree_item betaminus[] = {
   {
     .subs = betaminusUs,
     .subcount = sizeof(betaminusUs) / sizeof(*betaminusUs),
@@ -166,7 +166,7 @@ static nctree_item betaminus[] = {
   },
 };
 
-static nctree_item betaplusUs[] = {
+static struct nctree_item betaplusUs[] = {
   {
     .subs = NULL,
     .subcount = 0,
@@ -182,13 +182,13 @@ static nctree_item betaplusUs[] = {
   },
 };
 
-static nctree_item betaplus = {
+static struct nctree_item betaplus = {
   .subs = betaplusUs,
   .subcount = sizeof(betaplusUs) / sizeof(*betaplusUs),
   .curry = "β-emitting U",
 };
 
-static nctree_item gammaUs[] = {
+static struct nctree_item gammaUs[] = {
   {
     .subs = NULL,
     .subcount = 0,
@@ -224,13 +224,13 @@ static nctree_item gammaUs[] = {
   },
 };
 
-static nctree_item gammas = {
+static struct nctree_item gammas = {
   .subs = gammaUs,
   .subcount = sizeof(gammaUs) / sizeof(*gammaUs),
   .curry = "γ-emitting U",
 };
 
-static nctree_item sfissionUs[] = {
+static struct nctree_item sfissionUs[] = {
   {
     .subs = NULL,
     .subcount = 0,
@@ -262,13 +262,13 @@ static nctree_item sfissionUs[] = {
   },
 };
 
-static nctree_item sfissions = {
+static struct nctree_item sfissions = {
   .subs = sfissionUs,
   .subcount = sizeof(sfissionUs) / sizeof(*sfissionUs),
   .curry = "spontaneously fissioning U",
 };
 
-static nctree_item ecaptureUs[] = {
+static struct nctree_item ecaptureUs[] = {
   {
     .subs = NULL,
     .subcount = 0,
@@ -280,13 +280,13 @@ static nctree_item ecaptureUs[] = {
   },
 };
 
-static nctree_item ecaptures = {
+static struct nctree_item ecaptures = {
   .subs = ecaptureUs,
   .subcount = sizeof(ecaptureUs) / sizeof(*ecaptureUs),
   .curry = "electron capturing U",
 };
 
-static nctree_item cdecayUs[] = {
+static struct nctree_item cdecayUs[] = {
   {
     .subs = NULL,
     .subcount = 0,
@@ -306,13 +306,13 @@ static nctree_item cdecayUs[] = {
   },
 };
 
-static nctree_item cdecays = {
+static struct nctree_item cdecays = {
   .subs = cdecayUs,
   .subcount = sizeof(cdecayUs) / sizeof(*cdecayUs),
   .curry = "cluster decaying U",
 };
 
-static nctree_item radUs[] = {
+static struct nctree_item radUs[] = {
   {
     .subs = &alphaU,
     .subcount = 1,
@@ -352,7 +352,7 @@ static nctree_item radUs[] = {
   },
 };
 
-static nctree_item rads = {
+static struct nctree_item rads = {
   .subs = radUs,
   .subcount = sizeof(radUs) / sizeof(*radUs),
   .curry = "radiating isotopes",


Test patch:
diff --git a/src/tests/tree.cpp b/src/tests/tree.cpp
index c7a7151eed..cb30f8d863 100644
--- a/src/tests/tree.cpp
+++ b/src/tests/tree.cpp
@@ -16,30 +16,27 @@ TEST_CASE("Tree") {
   REQUIRE(n_);
   REQUIRE(0 == ncplane_cursor_move_yx(n_, 0, 0));
 
-  // should be refused with a null items
-  SUBCASE("BadTreeNoItems") {
-    struct nctree_options opts = {
-      .items = nullptr,
-      .count = 2,
-      .nctreecb = treecb,
-      .indentcols = 0,
-      .flags = 0,
-    };
+  SUBCASE("RefuseStandardPlane") {
+    struct nctree_options opts{};
+    opts.nctreecb = treecb;
     auto treen = nctree_create(n_, &opts);
     REQUIRE(nullptr == treen);
+    CHECK(0 == notcurses_render(nc_));
   }
 
-  // should be refused with a zero count
-  SUBCASE("BadTreeNoCount") {
-    struct nctree_options opts = {
-      .items = {},
-      .count = 0,
-      .nctreecb = treecb,
-      .indentcols = 1,
-      .flags = 0,
-    };
-    auto treen = nctree_create(n_, &opts);
-    REQUIRE(nullptr == treen);
+  // trivial tree with null items
+  SUBCASE("TreeNoItems") {
+    struct ncplane_options nopts{};
+    nopts.rows = 7;
+    nopts.cols = 20;
+    auto p = ncplane_create(n_, &nopts);
+    REQUIRE(p);
+    struct nctree_options opts{};
+    opts.nctreecb = treecb;
+    auto treen = nctree_create(p, &opts);
+    REQUIRE(treen);
+    CHECK(0 == notcurses_render(nc_));
+    nctree_destroy(treen);
   }
 
   nctree_item subs[] = {
@@ -531,6 +528,11 @@ TEST_CASE("Tree") {
   };
 
   SUBCASE("TraverseLongList") {
+    struct ncplane_options nopts{};
+    nopts.rows = 7;
+    nopts.cols = 20;
+    auto p = ncplane_create(n_, &nopts);
+    REQUIRE(p);
     struct nctree_options topts = {
       .items = &rads,
       .count = 1,
@@ -538,7 +540,7 @@ TEST_CASE("Tree") {
       .indentcols = 2,
       .flags = 0,
     };
-    struct nctree* tree = nctree_create(notcurses_stdplane(nc_), &topts);
+    struct nctree* tree = nctree_create(p, &topts);
     REQUIRE(nullptr != tree);
     CHECK(0 == notcurses_render(nc_));
     void* curry = nctree_focused(tree);