shithub: orca

Download patch

ref: ed44a599a31562d5369a721cc8392cb6caa52e9d
parent: 7531dbe191cee7e6eb4a85b50da142cd61e85ff3
parent: a7090efcacd09f569300fd0ab1d893c3ebb2864f
author: Sigrid Solveig Haflínudóttir <ftrvxmtrx@gmail.com>
date: Thu Jun 24 11:10:27 EDT 2021

Merge remote-tracking branch 'github/master'

--- a/README.md
+++ b/README.md
@@ -1,15 +1,17 @@
 # ORCΛ
 
-Orca is an [esoteric programming language](https://en.wikipedia.org/wiki/Esoteric_programming_language) designed to quickly create procedural sequencers, in which every letter of the alphabet is an operation, where lowercase letters operate on bang, uppercase letters operate each frame.
+Orca is an [esoteric programming language](https://en.wikipedia.org/wiki/Esoteric_programming_language) and live editor designed to quickly create procedural sequencers. Every letter of the alphabet is an operation, lowercase letters execute on `*bang*`, and uppercase letters execute each frame.
 
-This application **is not a synthesizer, but a flexible livecoding environment** capable of sending MIDI, OSC & UDP to your audio/visual interfaces, like Ableton, Renoise, VCV Rack or SuperCollider.
+This is the C implementation of the [ORCΛ](https://wiki.xxiivv.com/site/orca.html) language and terminal livecoding environment. It's designed to be power efficient. It can handle large files, even if your terminal is small.
 
-If you need <strong>help</strong>, visit the <a href="https://talk.lurk.org/channel/orca" target="_blank" rel="noreferrer" class="external ">chatroom</a> or the <a href="https://llllllll.co/t/orca-live-coding-tool/17689" target="_blank" rel="noreferrer" class="external ">forum</a>.
+Orca is not a synthesizer, but a flexible livecoding environment capable of sending MIDI, OSC, and UDP to your audio/visual interfaces like Ableton, Renoise, VCV Rack, or SuperCollider.
 
-This is the **C** implementation of the [ORCΛ](https://github.com/hundredrabbits/Orca) language and tools. The livecoding environment for this C version runs in a terminal. It's designed to be power efficient. It can handle large files, even if your terminal is small.
-
 <img src='https://raw.githubusercontent.com/wiki/hundredrabbits/Orca-c/PREVIEW.jpg' width='600'/>
 
+| Main git repo | GitHub mirror |
+| ------------- | ------------- |
+| [git.sr.ht/~rabbits/orca](https://git.sr.ht/~rabbits/orca) | [github.com/hundredrabbits/Orca-c](https://github.com/hundredrabbits/Orca-c) |
+
 ## Quick Start for Debian/Raspbian (Raspberry Pi)
 
 ```sh
@@ -37,14 +39,7 @@
 │ > MIDI Output...    │
 │                     │
 │   Clock & Timing... │
-│   Appearance...     │
-│                     │
-│   Controls...       │
-│   Operators...      │
-│   About ORCA...     │
-│                     │
-│   Quit              │
-└─────────────────────┘
+│.....................│
 ```
 
 ## Prerequisites
@@ -57,7 +52,7 @@
 
 ## Build
 
-The build script, called simply `tool`, is written in `bash`. It should work with `gcc` (including the `musl-gcc` wrapper), `tcc`, and `clang`, and will automatically detect your compiler. You can manually specify a compiler with the `-c` option.
+The build script, called simply `tool`, is written in POSIX `sh`. It should work with `gcc` (including the `musl-gcc` wrapper), `tcc`, and `clang`, and will automatically detect your compiler. You can manually specify a compiler with the `-c` option.
 
 Currently known to build on macOS (`gcc`, `clang`, `tcc`) and Linux (`gcc`, `musl-gcc`, `tcc`, and `clang`, optionally with `LLD`), and Windows via cygwin or WSL (`gcc` or `clang`, `tcc` untested).
 
@@ -129,7 +124,7 @@
         Example: /OSC_MIDI_0/MIDI
 ```
 
-### Example: build and run `orca` liveocding environment with MIDI output
+### Example: build and run `orca` livecoding environment with MIDI output
 
 ```sh
 $ ./tool build --portmidi orca           # compile orca using build script
@@ -180,6 +175,6 @@
 
 ## Extras
 
+- Discuss and get help in the [forum thread](https://llllllll.co/t/orca-live-coding-tool/17689).
 - Support this project through [Patreon](https://patreon.com/100).
-- See the [License](LICENSE.md) file for license rights and limitations (MIT).
-- Pull Requests are welcome!
+- See the [License](LICENSE.md) (MIT) file for license rights and limitations.
--- a/base.h
+++ b/base.h
@@ -15,7 +15,7 @@
 #if defined(__clang__)
 #define ORCA_OPT_MINSIZE __attribute__((minsize))
 #elif defined(__GNUC__)
-#define ORCA_OPT_MINSIZE __attribute__(("Os"))
+#define ORCA_OPT_MINSIZE __attribute__((optimize("Os")))
 #else
 #define ORCA_OPT_MINSIZE
 #endif
--- a/examples/basics/_osc.orca
+++ b/examples/basics/_osc.orca
@@ -1,16 +1,16 @@
 .........................................
 .#.OSC.#.................................
+..............#########################..
+.#.3.VALUES.#.#.......................#..
+..............#.First.char.is.path....#..
+.D8...........#.......................#..
+..=a3123......#.Second.char.is.length.#..
+..............#.......................#..
+.#.2.VALUES.#.#.Remaining.chars.are...#..
+..............#.integer.values........#..
+.D6...........#.......................#..
+.*=b212.......#########################..
 .........................................
-.#.VALUES.#..............................
 .........................................
-.D8......................................
-..=a123..................................
 .........................................
-.#.EMPTY.#...............................
 .........................................
-.D6......................................
-..=b.....................................
-.........................................
-.........................................
-.........................................
-.........................................
\ No newline at end of file
--- a/sim.c
+++ b/sim.c
@@ -248,6 +248,7 @@
   Usz channel = index_of(channel_g);
   if (channel > 15)
     return;
+  PORT(0, 0, OUT, "");
   Oevent_midi_cc *oe =
       (Oevent_midi_cc *)oevent_list_alloc_item(extra_params->oevent_list);
   oe->oevent_type = Oevent_type_midi_cc;
@@ -313,6 +314,7 @@
     if (vel_num > 127)
       vel_num = 127;
   }
+  PORT(0, 0, OUT, "");
   Oevent_midi_note *oe =
       (Oevent_midi_note *)oevent_list_alloc_item(extra_params->oevent_list);
   oe->oevent_type = (U8)Oevent_type_midi_note;
@@ -343,6 +345,7 @@
   }
   n = i;
   STOP_IF_NOT_BANGED;
+  PORT(0, 0, OUT, "");
   Oevent_udp_string *oe =
       (Oevent_udp_string *)oevent_list_alloc_item(extra_params->oevent_list);
   oe->oevent_type = (U8)Oevent_type_udp_string;
@@ -364,6 +367,7 @@
   STOP_IF_NOT_BANGED;
   Glyph g = PEEK(0, 1);
   if (g != '.') {
+    PORT(0, 0, OUT, "");
     U8 buff[Oevent_osc_int_count];
     for (Usz i = 0; i < len; ++i) {
       buff[i] = (U8)index_of(PEEK(0, (Isz)i + 3));
@@ -392,6 +396,7 @@
   Usz channel = index_of(channel_g);
   if (channel > 15)
     return;
+  PORT(0, 0, OUT, "");
   Oevent_midi_pb *oe =
       (Oevent_midi_pb *)oevent_list_alloc_item(extra_params->oevent_list);
   oe->oevent_type = Oevent_type_midi_pb;
@@ -511,7 +516,7 @@
 
 BEGIN_OPERATOR(halt)
   LOWERCASE_REQUIRES_BANG;
-  PORT(1, 0, OUT, "output");
+  PORT(1, 0, IN | PARAM, "locked");
 END_OPERATOR
 
 BEGIN_OPERATOR(increment)
@@ -535,9 +540,20 @@
 
 BEGIN_OPERATOR(jump)
   LOWERCASE_REQUIRES_BANG;
+  Glyph g = PEEK(-1, 0);
+  if (g == This_oper_char)
+    return;
   PORT(-1, 0, IN, "val");
-  PORT(1, 0, OUT, "output");
-  POKE(1, 0, PEEK(-1, 0));
+  for (Isz i = 1; i <= 256; ++i) {
+    if (PEEK(i, 0) != This_oper_char) {
+      char t[6];
+      sprintf(t, "out%ld", i);
+      PORT(i, 0, OUT, t);
+      POKE(i, 0, g);
+      break;
+    }
+    STUN(i, 0);
+  }
 END_OPERATOR
 
 // Note: this is merged from a pull request without being fully tested or
@@ -742,9 +758,20 @@
 
 BEGIN_OPERATOR(yump)
   LOWERCASE_REQUIRES_BANG;
+  Glyph g = PEEK(0, -1);
+  if (g == This_oper_char)
+    return;
   PORT(0, -1, IN, "val");
-  PORT(0, 1, OUT, "output");
-  POKE(0, 1, PEEK(0, -1));
+  for (Isz i = 1; i <= 256; ++i) {
+    if (PEEK(0, i) != This_oper_char) {
+      char t[6];
+      sprintf(t, "out%ld", i);
+      PORT(0, i, OUT, t);
+      POKE(0, i, g);
+      break;
+    }
+    STUN(0, i);
+  }
 END_OPERATOR
 
 BEGIN_OPERATOR(lerp)
--- a/term_util.c
+++ b/term_util.c
@@ -2,7 +2,6 @@
 #include "oso.h"
 #include <ctype.h>
 #include <form.h>
-#include <menu.h>
 
 void term_util_init_colors() {
   if (has_colors()) {
@@ -38,20 +37,18 @@
   Qmsg_dismiss_mode dismiss_mode;
 };
 
-struct Qmenu_item_extra {
-  int user_id;
+typedef struct Qmenu_item {
+  char const *text;
+  int id;
   U8 owns_string : 1, is_spacer : 1;
-};
+} Qmenu_item;
 
 struct Qmenu {
   Qblock qblock;
-  MENU *ncurses_menu;
-  ITEM **ncurses_items;
+  Qmenu_item *items;
   Usz items_count, items_cap;
-  ITEM *initial_item;
-  int id;
-  // Flag for right-padding hack. Temp until we do our own menus
-  U8 has_submenu_item : 1;
+  int current_item, id;
+  U8 needs_reprint : 1, is_frontmost : 1;
 };
 
 struct Qform {
@@ -62,67 +59,81 @@
   int id;
 };
 
+static void qmenu_free(Qmenu *qm);
+static void qform_free(Qform *qf);
+ORCA_NOINLINE static void qmenu_reprint(Qmenu *qm);
+
 Qnav_stack qnav_stack;
 
-void qnav_init() { qnav_stack = (Qnav_stack){.blocks = {0}}; }
+void qnav_init() { qnav_stack = (Qnav_stack){0}; }
 void qnav_deinit() {
-  while (qnav_stack.count != 0)
+  while (qnav_stack.top)
     qnav_stack_pop();
 }
+// Set new y and x coordinates for the top and left of a Qblock based on the
+// position of the Qblock "below" it in the stack. (Below meaning its order in
+// the stack, not vertical position on a Y axis.) The target Qblock should
+// already be inserted into the stack somewhere, so don't call this before
+// you've finished doing the rest of the setup on the Qblock. The y and x
+// fields can be junk, though, since this function writes to them without
+// reading them.
+static ORCA_NOINLINE void qnav_reposition_block(Qblock *qb) {
+  int top = 0, left = 0;
+  Qblock *prev = qb->down;
+  if (!prev)
+    goto done;
+  int total_h, total_w;
+  getmaxyx(qb->outer_window, total_h, total_w);
+  WINDOW *w = prev->outer_window;
+  int prev_y = prev->y, prev_x = prev->x, prev_h, prev_w;
+  getmaxyx(w, prev_h, prev_w);
+  // Start by trying to position the item to the right of the previous item.
+  left = prev_x + prev_w + 0;
+  int term_h, term_w;
+  getmaxyx(stdscr, term_h, term_w);
+  // Check if we'll run out of room if we position the new item to the right
+  // of the existing item (with the same Y position.)
+  if (left + total_w > term_w) {
+    // If we have enough room if we position just below the previous item in
+    // the stack, do that instead of positioning to the right of it.
+    if (prev_x + total_w <= term_w && total_h < term_h - (prev_y + prev_h)) {
+      top = prev_y + prev_h;
+      left = prev_x;
+    }
+    // If the item doesn't fit there, but it's less wide than the terminal,
+    // right-align it to the edge of the terminal.
+    else if (total_w < term_w) {
+      left = term_w - total_w;
+    }
+    // Otherwise, just start the layout over at Y=0,X=0
+    else {
+      left = 0;
+    }
+  }
+done:
+  qb->y = top;
+  qb->x = left;
+}
 static ORCA_NOINLINE void qnav_stack_push(Qblock *qb, int height, int width) {
 #ifndef NDEBUG
-  for (Usz i = 0; i < qnav_stack.count; ++i) {
-    assert(qnav_stack.blocks[i] != qb);
+  for (Qblock *i = qnav_stack.top; i; i = i->down) {
+    assert(i != qb);
   }
 #endif
-  int top = 0, left = 0;
   int total_h = height + 2, total_w = width + 2;
-  if (qnav_stack.count > 0) {
-    WINDOW *w = qnav_stack.blocks[qnav_stack.count - 1]->outer_window;
-    int prev_y, prev_x, prev_h, prev_w;
-    getbegyx(w, prev_y, prev_x);
-    getmaxyx(w, prev_h, prev_w);
-    // Start by trying to position the item to the right of the previous item.
-    left = prev_x + prev_w + 0;
-    int term_h, term_w;
-    getmaxyx(stdscr, term_h, term_w);
-    // Check if we'll run out of room if we position the new item to the right
-    // of the existing item (with the same Y position.)
-    if (left + total_w > term_w) {
-      // If we have enough room if we position just below the previous item in
-      // the stack, do that instead of positioning to the right of it.
-      if (prev_x + total_w <= term_w && total_h < term_h - (prev_y + prev_h)) {
-        top = prev_y + prev_h;
-        left = prev_x;
-      }
-      // If the item doesn't fit there, but it's less wide than the terminal,
-      // right-align it to the edge of the terminal.
-      else if (total_w < term_w) {
-        left = term_w - total_w;
-      }
-      // Otherwise, just start the layout over at Y=0,X=0
-      else {
-        left = 0;
-      }
-    }
-  }
-  qnav_stack.blocks[qnav_stack.count] = qb;
-  ++qnav_stack.count;
+  if (qnav_stack.top)
+    qnav_stack.top->up = qb;
+  else
+    qnav_stack.bottom = qb;
+  qb->down = qnav_stack.top;
+  qnav_stack.top = qb;
   qb->outer_window = newpad(total_h, total_w);
-  // This used to be derwin when when used newwin instead of newpad -- not sure
-  // if we should use derwin or subpad now. subpad is probably more compatible.
-  // ncurses docs state that it handles it correctly, unlike some others?
   qb->content_window = subpad(qb->outer_window, height, width, 1, 1);
-  qb->y = top;
-  qb->x = left;
+  qnav_reposition_block(qb);
   qnav_stack.occlusion_dirty = true;
 }
 
-Qblock *qnav_top_block() {
-  if (qnav_stack.count == 0)
-    return NULL;
-  return qnav_stack.blocks[qnav_stack.count - 1];
-}
+Qblock *qnav_top_block() { return qnav_stack.top; }
 
 void qblock_init(Qblock *qb, Qblock_type_tag tag) {
   *qb = (Qblock){0};
@@ -129,9 +140,6 @@
   qb->tag = tag;
 }
 
-void qmenu_free(Qmenu *qm);
-void qform_free(Qform *qf);
-
 void qnav_free_block(Qblock *qb) {
   switch (qb->tag) {
   case Qblock_type_qmsg: {
@@ -149,10 +157,16 @@
 }
 
 void qnav_stack_pop(void) {
-  assert(qnav_stack.count > 0);
-  if (qnav_stack.count == 0)
+  assert(qnav_stack.top);
+  if (!qnav_stack.top)
     return;
-  Qblock *qb = qnav_stack.blocks[qnav_stack.count - 1];
+  Qblock *qb = qnav_stack.top;
+  qnav_stack.top = qb->down;
+  if (qnav_stack.top)
+    qnav_stack.top->up = NULL;
+  else
+    qnav_stack.bottom = NULL;
+  qnav_stack.occlusion_dirty = true;
   WINDOW *content_window = qb->content_window;
   WINDOW *outer_window = qb->outer_window;
   // erase any stuff underneath where this window is, in case it's outside of
@@ -162,32 +176,36 @@
   qnav_free_block(qb);
   delwin(content_window);
   delwin(outer_window);
-  --qnav_stack.count;
-  qnav_stack.blocks[qnav_stack.count] = NULL;
-  qnav_stack.occlusion_dirty = true;
 }
 
 bool qnav_draw(void) {
   bool drew_any = false;
-  if (qnav_stack.count < 1)
+  if (!qnav_stack.bottom)
     goto done;
   int term_h, term_w;
   getmaxyx(stdscr, term_h, term_w);
-  for (Usz i = 0; i < qnav_stack.count; ++i) {
-    Qblock *qb = qnav_stack.blocks[i];
-    if (qnav_stack.occlusion_dirty) {
-      bool is_frontmost = i == qnav_stack.count - 1;
+  for (Qblock *qb = qnav_stack.bottom; qb; qb = qb->up) {
+    bool is_frontmost = qb == qnav_stack.top;
+    if (qnav_stack.occlusion_dirty)
       qblock_print_frame(qb, is_frontmost);
-      switch (qb->tag) {
-      case Qblock_type_qmsg:
-        break;
-      case Qblock_type_qmenu:
-        qmenu_set_displayed_active(qmenu_of(qb), is_frontmost);
-        break;
-      case Qblock_type_qform:
-        break;
+    switch (qb->tag) {
+    case Qblock_type_qmsg:
+      break;
+    case Qblock_type_qmenu: {
+      Qmenu *qm = qmenu_of(qb);
+      if (qm->is_frontmost != is_frontmost) {
+        qm->is_frontmost = is_frontmost;
+        qm->needs_reprint = 1;
       }
+      if (qm->needs_reprint) {
+        qmenu_reprint(qm);
+        qm->needs_reprint = 0;
+      }
+      break;
     }
+    case Qblock_type_qform:
+      break;
+    }
     touchwin(qb->outer_window); // here? or after continue?
     if (term_h < 1 || term_w < 1)
       continue;
@@ -209,6 +227,14 @@
   return drew_any;
 }
 
+void qnav_adjust_term_size(void) {
+  if (!qnav_stack.bottom)
+    return;
+  for (Qblock *qb = qnav_stack.bottom; qb; qb = qb->up)
+    qnav_reposition_block(qb);
+  qnav_stack.occlusion_dirty = true;
+}
+
 void qblock_print_border(Qblock *qb, unsigned int attr) {
   wborder(qb->outer_window, ACS_VLINE | attr, ACS_VLINE | attr,
           ACS_HLINE | attr, ACS_HLINE | attr, ACS_ULCORNER | attr,
@@ -222,7 +248,7 @@
   wattr_get(qb->outer_window, &attrs, &pair, NULL);
   wattrset(qb->outer_window, attr);
   waddch(qb->outer_window, ' ');
-  wprintw(qb->outer_window, title);
+  waddstr(qb->outer_window, title);
   waddch(qb->outer_window, ' ');
   wattr_set(qb->outer_window, attrs, pair, NULL);
 }
@@ -343,92 +369,66 @@
 Qmenu *qmenu_create(int id) {
   Qmenu *qm = (Qmenu *)malloc(sizeof(Qmenu));
   qblock_init(&qm->qblock, Qblock_type_qmenu);
-  qm->ncurses_menu = NULL;
-  qm->ncurses_items = NULL;
+  qm->items = NULL;
   qm->items_count = 0;
   qm->items_cap = 0;
-  qm->initial_item = NULL;
+  qm->current_item = 0;
   qm->id = id;
-  qm->has_submenu_item = 0;
+  qm->needs_reprint = 1;
+  qm->is_frontmost = 0;
   return qm;
 }
 void qmenu_destroy(Qmenu *qm) { qmenu_free(qm); }
 int qmenu_id(Qmenu const *qm) { return qm->id; }
-static ORCA_NOINLINE void
-qmenu_allocitems(Qmenu *qm, Usz count, Usz *out_idx, ITEM ***out_items,
-                 struct Qmenu_item_extra **out_extras) {
+static ORCA_NOINLINE Qmenu_item *qmenu_allocitems(Qmenu *qm, Usz count) {
   Usz old_count = qm->items_count;
-  // Add 1 for the extra null terminator guy
-  Usz new_count = old_count + count + 1;
+  if (old_count > SIZE_MAX - count) // overflow
+    exit(1);
+  Usz new_count = old_count + count;
   Usz items_cap = qm->items_cap;
-  ITEM **items = qm->ncurses_items;
+  Qmenu_item *items = qm->items;
   if (new_count > items_cap) {
     // todo overflow check, realloc fail check
-    Usz old_cap = items_cap;
     Usz new_cap = new_count < 32 ? 32 : orca_round_up_power2(new_count);
-    Usz new_size = new_cap * (sizeof(ITEM *) + sizeof(struct Qmenu_item_extra));
-    ITEM **new_items = (ITEM **)realloc(items, new_size);
+    Usz new_size = new_cap * sizeof(Qmenu_item);
+    Qmenu_item *new_items = (Qmenu_item *)realloc(items, new_size);
     if (!new_items)
       exit(1);
     items = new_items;
     items_cap = new_cap;
-    // Move old extras data to new position
-    Usz old_extras_offset = sizeof(ITEM *) * old_cap;
-    Usz new_extras_offset = sizeof(ITEM *) * new_cap;
-    Usz old_extras_size = sizeof(struct Qmenu_item_extra) * old_count;
-    memmove((char *)items + new_extras_offset,
-            (char *)items + old_extras_offset, old_extras_size);
-    qm->ncurses_items = new_items;
+    qm->items = new_items;
     qm->items_cap = new_cap;
   }
-  // Not using new_count here in order to leave an extra 1 for the null
-  // terminator as required by ncurses.
-  qm->items_count = old_count + count;
-  Usz extras_offset = sizeof(ITEM *) * items_cap;
-  *out_idx = old_count;
-  *out_items = items + old_count;
-  *out_extras =
-      (struct Qmenu_item_extra *)((char *)items + extras_offset) + old_count;
+  qm->items_count = new_count;
+  return items + old_count;
 }
-ORCA_FORCEINLINE static struct Qmenu_item_extra *
-qmenu_item_extras_ptr(Qmenu *qm) {
-  Usz offset = sizeof(ITEM *) * qm->items_cap;
-  return (struct Qmenu_item_extra *)((char *)qm->ncurses_items + offset);
+ORCA_NOINLINE static void qmenu_reprint(Qmenu *qm) {
+  WINDOW *win = qm->qblock.content_window;
+  Qmenu_item *items = qm->items;
+  bool isfront = qm->is_frontmost;
+  werase(win);
+  for (Usz i = 0, n = qm->items_count; i < n; ++i) {
+    bool iscur = items[i].id == qm->current_item;
+    wattrset(win, isfront ? iscur ? A_BOLD : A_NORMAL : A_DIM);
+    wmove(win, (int)i, iscur ? 1 : 3);
+    if (iscur)
+      waddstr(win, "> ");
+    waddstr(win, items[i].text);
+  }
 }
-// Get the curses menu item user pointer out, turn it to an int, and use it as
-// an index into the 'extras' arrays.
-ORCA_FORCEINLINE static struct Qmenu_item_extra *
-qmenu_itemextra(struct Qmenu_item_extra *extras, ITEM *item) {
-  return extras + (int)(intptr_t)(item_userptr(item));
-}
 void qmenu_set_title(Qmenu *qm, char const *title) {
   qblock_set_title(&qm->qblock, title);
 }
 void qmenu_add_choice(Qmenu *qm, int id, char const *text) {
   assert(id != 0);
-  Usz idx;
-  ITEM **items;
-  struct Qmenu_item_extra *extras;
-  qmenu_allocitems(qm, 1, &idx, &items, &extras);
-  items[0] = new_item(text, NULL);
-  set_item_userptr(items[0], (void *)(uintptr_t)idx);
-  extras[0].user_id = id;
-  extras[0].owns_string = false;
-  extras[0].is_spacer = false;
+  Qmenu_item *item = qmenu_allocitems(qm, 1);
+  item->text = text;
+  item->id = id;
+  item->owns_string = false;
+  item->is_spacer = false;
+  if (!qm->current_item)
+    qm->current_item = id;
 }
-void qmenu_add_submenu(Qmenu *qm, int id, char const *text) {
-  assert(id != 0);
-  qm->has_submenu_item = true; // don't add +1 right padding to subwindow
-  Usz idx;
-  ITEM **items;
-  struct Qmenu_item_extra *extras;
-  qmenu_allocitems(qm, 1, &idx, &items, &extras);
-  items[0] = new_item(text, ">");
-  set_item_userptr(items[0], (void *)(uintptr_t)idx);
-  extras[0].user_id = id;
-  extras[0].owns_string = false;
-  extras[0].is_spacer = false;
-}
 void qmenu_add_printf(Qmenu *qm, int id, char const *fmt, ...) {
   va_list ap;
   va_start(ap, fmt);
@@ -442,85 +442,42 @@
   va_end(ap);
   if (printedsize != textsize)
     exit(1); // todo better handling?
-  Usz idx;
-  ITEM **items;
-  struct Qmenu_item_extra *extras;
-  qmenu_allocitems(qm, 1, &idx, &items, &extras);
-  items[0] = new_item(buffer, NULL);
-  set_item_userptr(items[0], (void *)(uintptr_t)idx);
-  extras[0].user_id = id;
-  extras[0].owns_string = true;
-  extras[0].is_spacer = false;
+  Qmenu_item *item = qmenu_allocitems(qm, 1);
+  item->text = buffer;
+  item->id = id;
+  item->owns_string = true;
+  item->is_spacer = false;
+  if (!qm->current_item)
+    qm->current_item = id;
 }
 void qmenu_add_spacer(Qmenu *qm) {
-  Usz idx;
-  ITEM **items;
-  struct Qmenu_item_extra *extras;
-  qmenu_allocitems(qm, 1, &idx, &items, &extras);
-  items[0] = new_item(" ", NULL);
-  item_opts_off(items[0], O_SELECTABLE);
-  set_item_userptr(items[0], (void *)(uintptr_t)idx);
-  extras[0].user_id = 0;
-  extras[0].owns_string = false;
-  extras[0].is_spacer = true;
+  Qmenu_item *item = qmenu_allocitems(qm, 1);
+  item->text = " ";
+  item->id = 0;
+  item->owns_string = false;
+  item->is_spacer = true;
 }
 void qmenu_set_current_item(Qmenu *qm, int id) {
-  ITEM **items = qm->ncurses_items;
-  struct Qmenu_item_extra *extras = qmenu_item_extras_ptr(qm);
-  ITEM *found = NULL;
-  for (Usz i = 0, n = qm->items_count; i < n; i++) {
-    ITEM *item = items[i];
-    if (qmenu_itemextra(extras, item)->user_id == id) {
-      found = item;
-      break;
-    }
-  }
-  if (!found)
+  if (qm->current_item == id)
     return;
-  if (qm->ncurses_menu) {
-    set_current_item(qm->ncurses_menu, found);
-  } else {
-    qm->initial_item = found;
-  }
+  qm->current_item = id;
+  qm->needs_reprint = 1;
 }
-int qmenu_current_item(Qmenu *qm) {
-  ITEM *item = NULL;
-  if (qm->ncurses_menu)
-    item = current_item(qm->ncurses_menu);
-  if (!item)
-    item = qm->initial_item;
-  if (!item)
-    return 0;
-  struct Qmenu_item_extra *extras = qmenu_item_extras_ptr(qm);
-  return qmenu_itemextra(extras, item)->user_id;
-}
-void qmenu_set_displayed_active(Qmenu *qm, bool active) {
-  // Could add a flag in the Qmenu to avoid redundantly changing this stuff.
-  set_menu_fore(qm->ncurses_menu, active ? A_BOLD : A_DIM);
-  set_menu_back(qm->ncurses_menu, active ? A_NORMAL : A_DIM);
-  set_menu_grey(qm->ncurses_menu, active ? A_DIM : A_DIM);
-}
+int qmenu_current_item(Qmenu *qm) { return qm->current_item; }
 void qmenu_push_to_nav(Qmenu *qm) {
-  // new_menu() will get angry if there are no items in the menu. We'll get a
-  // null pointer back, and our code will get angry. Instead, just add an empty
-  // spacer item. This will probably only ever occur as a programming error,
-  // but we should try to avoid having to deal with qmenu_push_to_nav()
-  // returning a non-ignorable error for now.
+  // Probably a programming error if there are no items. Make the menu visible
+  // so the programmer knows something went wrong.
   if (qm->items_count == 0)
     qmenu_add_spacer(qm);
-  // Allocating items always leaves an extra available item at the end. This is
-  // so we can assign a NULL to it here, since ncurses requires the array to be
-  // null terminated instead of using a count.
-  qm->ncurses_items[qm->items_count] = NULL;
-  qm->ncurses_menu = new_menu(qm->ncurses_items);
-  set_menu_mark(qm->ncurses_menu, " > ");
-  set_menu_fore(qm->ncurses_menu, A_BOLD);
-  set_menu_grey(qm->ncurses_menu, A_DIM);
-  set_menu_format(qm->ncurses_menu, 30, 1); // temp to allow large Y
-  int menu_min_h, menu_min_w;
-  scale_menu(qm->ncurses_menu, &menu_min_h, &menu_min_w);
-  if (!qm->has_submenu_item)
-    menu_min_w += 1; // temp hack
+  Usz n = qm->items_count;
+  Qmenu_item *items = qm->items;
+  int menu_min_h = (int)n, menu_min_w = 0;
+  for (Usz i = 0; i < n; ++i) {
+    int item_w = (int)strlen(items[i].text);
+    if (item_w > menu_min_w)
+      menu_min_w = item_w;
+  }
+  menu_min_w += 3 + 1; // left " > " plus 1 empty space on right
   if (qm->qblock.title) {
     // Stupid lack of wcswidth() means we can't know how wide this string is
     // actually displayed. Just fake it for now, until we have Unicode strings
@@ -529,58 +486,50 @@
     if (title_w > menu_min_w)
       menu_min_w = title_w;
   }
-  if (qm->initial_item)
-    set_current_item(qm->ncurses_menu, qm->initial_item);
   qnav_stack_push(&qm->qblock, menu_min_h, menu_min_w);
-  set_menu_win(qm->ncurses_menu, qm->qblock.outer_window);
-  set_menu_sub(qm->ncurses_menu, qm->qblock.content_window);
-  // TODO use this to set how "big" the menu is, visually, for scrolling.
-  // (ncurses can't figure that out on its own, aparently...)
-  // We'll need to split apart some work chunks so that we calculate the size
-  // beforehand.
-  // set_menu_format(qm->ncurses_menu, 5, 1);
-  post_menu(qm->ncurses_menu);
 }
 
-void qmenu_free(Qmenu *qm) {
-  unpost_menu(qm->ncurses_menu);
-  free_menu(qm->ncurses_menu);
-  struct Qmenu_item_extra *extras = qmenu_item_extras_ptr(qm);
-  for (Usz i = 0; i < qm->items_count; ++i) {
-    ITEM *item = qm->ncurses_items[i];
-    struct Qmenu_item_extra *extra = qmenu_itemextra(extras, item);
-    char const *freed_str = NULL;
-    if (extra->owns_string)
-      freed_str = item_name(item);
-    free_item(qm->ncurses_items[i]);
-    if (freed_str)
-      free((void *)freed_str);
+static void qmenu_free(Qmenu *qm) {
+  Qmenu_item *items = qm->items;
+  for (Usz i = 0, n = qm->items_count; i < n; ++i) {
+    if (items[i].owns_string)
+      free((void *)items[i].text);
   }
-  free(qm->ncurses_items);
+  free(qm->items);
   free(qm);
 }
 
-ORCA_NOINLINE
-static void qmenu_drive_upordown(Qmenu *qm, int req_up_or_down) {
-  struct Qmenu_item_extra *extras = qmenu_item_extras_ptr(qm);
-  ITEM *starting = current_item(qm->ncurses_menu);
-  menu_driver(qm->ncurses_menu, req_up_or_down);
-  ITEM *cur = current_item(qm->ncurses_menu);
+ORCA_NOINLINE static void qmenu_drive_upordown(Qmenu *qm, bool downwards) {
+  Qmenu_item *items = qm->items;
+  Usz n = qm->items_count;
+  if (n <= 1)
+    return;
+  int cur_id = qm->current_item;
+  Usz starting = 0;
+  for (; starting < n; ++starting) {
+    if (items[starting].id == cur_id)
+      goto found;
+  }
+  return;
+found:;
+  Usz current = starting;
   for (;;) {
-    if (!cur || cur == starting)
+    if (downwards && current < n - 1)
+      current++;
+    else if (!downwards && current > 0)
+      current--;
+    if (current == starting)
       break;
-    if (!qmenu_itemextra(extras, cur)->is_spacer)
+    if (!items[current].is_spacer)
       break;
-    ITEM *prev = cur;
-    menu_driver(qm->ncurses_menu, req_up_or_down);
-    cur = current_item(qm->ncurses_menu);
-    if (cur == prev)
-      break;
   }
+  if (current != starting) {
+    qm->current_item = items[current].id;
+    qm->needs_reprint = 1;
+  }
 }
 
 bool qmenu_drive(Qmenu *qm, int key, Qmenu_action *out_action) {
-  struct Qmenu_item_extra *extras = qmenu_item_extras_ptr(qm);
   switch (key) {
   case 27: {
     out_action->any.type = Qmenu_action_type_canceled;
@@ -588,17 +537,15 @@
   }
   case ' ':
   case '\r':
-  case KEY_ENTER: {
-    ITEM *cur = current_item(qm->ncurses_menu);
+  case KEY_ENTER:
     out_action->picked.type = Qmenu_action_type_picked;
-    out_action->picked.id = cur ? qmenu_itemextra(extras, cur)->user_id : 0;
+    out_action->picked.id = qm->current_item;
     return true;
-  }
   case KEY_UP:
-    qmenu_drive_upordown(qm, REQ_UP_ITEM);
+    qmenu_drive_upordown(qm, false);
     return false;
   case KEY_DOWN:
-    qmenu_drive_upordown(qm, REQ_DOWN_ITEM);
+    qmenu_drive_upordown(qm, true);
     return false;
   }
   return false;
@@ -625,12 +572,21 @@
   qf->id = id;
   return qf;
 }
-
-Qform *qform_of(Qblock *qb) { return ORCA_CONTAINER_OF(qb, Qform, qblock); }
-
+static void qform_free(Qform *qf) {
+  curs_set(0);
+  unpost_form(qf->ncurses_form);
+  free_form(qf->ncurses_form);
+  for (Usz i = 0; i < qf->fields_count; ++i) {
+    free_field(qf->ncurses_fields[i]);
+  }
+  free(qf);
+}
 int qform_id(Qform const *qf) { return qf->id; }
-
-void qform_add_text_line(Qform *qf, int id, char const *initial) {
+Qform *qform_of(Qblock *qb) { return ORCA_CONTAINER_OF(qb, Qform, qblock); }
+void qform_set_title(Qform *qf, char const *title) {
+  qblock_set_title(&qf->qblock, title);
+}
+void qform_add_line_input(Qform *qf, int id, char const *initial) {
   FIELD *f = new_field(1, 30, 0, 0, 0, 0);
   if (initial)
     set_field_buffer(f, 0, initial);
@@ -640,7 +596,6 @@
   ++qf->fields_count;
   qf->ncurses_fields[qf->fields_count] = NULL;
 }
-
 void qform_push_to_nav(Qform *qf) {
   qf->ncurses_form = new_form(qf->ncurses_fields);
   int form_min_h, form_min_w;
@@ -653,21 +608,12 @@
   curs_set(1);
   form_driver(qf->ncurses_form, REQ_END_LINE);
 }
-
-void qform_set_title(Qform *qf, char const *title) {
-  qblock_set_title(&qf->qblock, title);
+void qform_single_line_input(int id, char const *title, char const *initial) {
+  Qform *qf = qform_create(id);
+  qform_set_title(qf, title);
+  qform_add_line_input(qf, 1, initial);
+  qform_push_to_nav(qf);
 }
-
-void qform_free(Qform *qf) {
-  curs_set(0);
-  unpost_form(qf->ncurses_form);
-  free_form(qf->ncurses_form);
-  for (Usz i = 0; i < qf->fields_count; ++i) {
-    free_field(qf->ncurses_fields[i]);
-  }
-  free(qf);
-}
-
 bool qform_drive(Qform *qf, int key, Qform_action *out_action) {
   switch (key) {
   case 27:
@@ -707,7 +653,6 @@
   form_driver(qf->ncurses_form, key);
   return false;
 }
-
 static Usz size_without_trailing_spaces(char const *str) {
   Usz size = strlen(str);
   for (;;) {
@@ -719,8 +664,7 @@
   }
   return size;
 }
-
-FIELD *qform_find_field(Qform const *qf, int id) {
+static FIELD *qform_find_field(Qform const *qf, int id) {
   Usz count = qf->fields_count;
   for (Usz i = 0; i < count; ++i) {
     FIELD *f = qf->ncurses_fields[i];
@@ -729,7 +673,6 @@
   }
   return NULL;
 }
-
 bool qform_get_text_line(Qform const *qf, int id, oso **out) {
   FIELD *f = qform_find_field(qf, id);
   if (!f)
@@ -741,4 +684,14 @@
   Usz trimmed = size_without_trailing_spaces(buf);
   osoputlen(out, buf, trimmed);
   return true;
+}
+bool qform_get_single_text_line(Qform const *qf, struct oso **out) {
+  return qform_get_text_line(qf, 1, out);
+}
+oso *qform_get_nonempty_single_line_input(Qform *qf) {
+  oso *s = NULL;
+  if (qform_get_text_line(qf, 1, &s) && osolen(s) > 0)
+    return s;
+  osofree(s);
+  return NULL;
 }
--- a/term_util.h
+++ b/term_util.h
@@ -56,16 +56,16 @@
   Qblock_type_qform,
 } Qblock_type_tag;
 
-typedef struct {
+typedef struct Qblock {
   Qblock_type_tag tag;
   WINDOW *outer_window, *content_window;
   char const *title;
+  struct Qblock *down, *up;
   int y, x;
 } Qblock;
 
 typedef struct {
-  Qblock *blocks[16];
-  Usz count;
+  Qblock *top, *bottom;
   bool occlusion_dirty;
 } Qnav_stack;
 
@@ -120,6 +120,7 @@
 Qblock *qnav_top_block(void);
 void qnav_stack_pop(void);
 bool qnav_draw(void); // also clear qnav_stack.occlusion_dirty
+void qnav_adjust_term_size(void);
 
 void qblock_print_frame(Qblock *qb, bool active);
 void qblock_set_title(Qblock *qb, char const *title);
@@ -141,12 +142,10 @@
 int qmenu_id(Qmenu const *qm);
 void qmenu_set_title(Qmenu *qm, char const *title);
 void qmenu_add_choice(Qmenu *qm, int id, char const *text);
-void qmenu_add_submenu(Qmenu *qm, int id, char const *text);
 void qmenu_add_printf(Qmenu *qm, int id, char const *fmt, ...)
     ORCA_TERM_UTIL_PRINTF(3, 4);
 void qmenu_add_spacer(Qmenu *qm);
 void qmenu_set_current_item(Qmenu *qm, int id);
-void qmenu_set_displayed_active(Qmenu *qm, bool active);
 void qmenu_push_to_nav(Qmenu *qm);
 int qmenu_current_item(Qmenu *qm);
 bool qmenu_drive(Qmenu *qm, int key, Qmenu_action *out_action);
@@ -156,11 +155,14 @@
 Qform *qform_create(int id);
 int qform_id(Qform const *qf);
 Qform *qform_of(Qblock *qb);
-void qform_add_text_line(Qform *qf, int id, char const *initial);
-void qform_push_to_nav(Qform *qf);
 void qform_set_title(Qform *qf, char const *title);
+void qform_add_line_input(Qform *qf, int id, char const *initial);
+void qform_push_to_nav(Qform *qf);
+void qform_single_line_input(int id, char const *title, char const* initial);
 bool qform_drive(Qform *qf, int key, Qform_action *out_action);
 bool qform_get_text_line(Qform const *qf, int id, struct oso **out);
+bool qform_get_single_text_line(Qform const *qf, struct oso **out);
+struct oso *qform_get_nonempty_single_line_input(Qform *qf);
 
 extern Qnav_stack qnav_stack;
 
--- a/tool
+++ b/tool
@@ -1,5 +1,5 @@
-#!/usr/bin/env bash
-set -eu -o pipefail
+#!/bin/sh
+set -euf
 
 print_usage() {
 cat <<EOF
@@ -40,8 +40,12 @@
 EOF
 }
 
-if [[ -z "${1:-}" ]]; then
-  echo "Error: Command required" >&2
+warn() { printf 'Warning: %s\n' "$*" >&2; }
+fatal() { printf 'Error: %s\n' "$*" >&2; exit 1; }
+script_error() { printf 'Script error: %s\n' "$*" >&2; exit 1; }
+
+if [ -z "${1:-}" ]; then
+  printf 'Error: Command required\n' >&2
   print_usage >&2
   exit 1
 fi
@@ -49,18 +53,17 @@
 cmd=$1
 shift
 
-os=
 case $(uname -s | awk '{print tolower($0)}') in
   linux*) os=linux;;
   darwin*) os=mac;;
   cygwin*) os=cygwin;;
   *bsd*) os=bsd;;
-  *) os=unknown;;
+  *) os=unknown; warn "Build script not tested on this platform";;
 esac
 
 cc_exe="${CC:-cc}"
 
-if [[ $os = cygwin ]]; then
+if [ $os = cygwin ]; then
   # Under cygwin, specifically ignore the mingw compilers if they're set as the
   # CC environment variable. This may be the default from the cygwin installer.
   # But we want to use 'gcc' from the cygwin gcc-core package (probably aliased
@@ -74,8 +77,7 @@
   # happens. So we'll just explicitly set it to gcc. This might mess up people
   # who have clang installed but not gcc, I guess? Is that even possible?
   case $cc_exe in
-  i686-w64-mingw32-gcc.exe|\
-  x86_64-w64-mingw32-gcc.exe)
+  i686-w64-mingw32-gcc.exe|x86_64-w64-mingw32-gcc.exe)
     cc_exe=gcc;;
   esac
 fi
@@ -90,151 +92,138 @@
 config_mode=release
 
 while getopts c:dhsv-: opt_val; do
-  case "$opt_val" in
-    -)
-      case "$OPTARG" in
-        harden) protections_enabled=1;;
-        help) print_usage; exit 0;;
-        static) static_enabled=1;;
-        pie) pie_enabled=1;;
-        portmidi) portmidi_enabled=1;;
-        no-portmidi|noportmidi) portmidi_enabled=0;;
-        mouse) mouse_disabled=0;;
-        no-mouse|nomouse) mouse_disabled=1;;
-        *)
-          echo "Unknown long option --$OPTARG" >&2
-          print_usage >&2
-          exit 1
-          ;;
-      esac
-      ;;
-    c) cc_exe="$OPTARG";;
+  case $opt_val in
+    -) case $OPTARG in
+         harden) protections_enabled=1;;
+         help) print_usage; exit 0;;
+         static) static_enabled=1;;
+         pie) pie_enabled=1;;
+         portmidi) portmidi_enabled=1;;
+         no-portmidi|noportmidi) portmidi_enabled=0;;
+         mouse) mouse_disabled=0;;
+         no-mouse|nomouse) mouse_disabled=1;;
+         *) printf 'Unknown option --%s\n' "$OPTARG" >&2; exit 1;;
+       esac;;
+    c) cc_exe=$OPTARG;;
     d) config_mode=debug;;
     h) print_usage; exit 0;;
     s) stats_enabled=1;;
     v) verbose=1;;
     \?) print_usage >&2; exit 1;;
-    *) break;;
   esac
 done
 
-arch=
 case $(uname -m) in
   x86_64) arch=x86_64;;
   *) arch=unknown;;
 esac
 
-warn() {
-  echo "Warning: $*" >&2
-}
-fatal() {
-  echo "Error: $*" >&2
-  exit 1
-}
-script_error() {
-  echo "Script error: $*" >&2
-  exit 1
-}
-
 verbose_echo() {
-  if [[ $verbose = 1 ]]; then
-    echo "$@"
+  # Don't print 'timed_stats' if it's the first part of the command
+  if [ $verbose = 1 ] && [ $# -gt 1 ]; then
+    printf '%s ' "$@" | sed -E -e 's/^timed_stats[[:space:]]+//' -e 's/ $//' \
+      | tr -d '\n'
+    printf '\n'
   fi
   "$@"
 }
 
-TIMEFORMAT='%3R'
-
-last_time=
-
 file_size() {
   wc -c < "$1" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//'
 }
 
+timed_stats_result=
 timed_stats() {
-  if [[ $stats_enabled = 1 ]]; then
-    { last_time=$( { time "$@" 1>&3- 2>&4-; } 2>&1 ); } 3>&1 4>&2
+  if [ $stats_enabled = 1 ] && command -v time >/dev/null 2>&1; then
+    TIMEFORMAT='%3R'
+    { timed_stats_result=$( { time "$@" 1>&3- 2>&4-; } 2>&1 ); } 3>&1 4>&2
   else
     "$@"
   fi
 }
 
-version_string_normalized() {
-  echo "$@" | awk -F. '{ printf("%d%03d%03d%03d\n", $1,$2,$3,$4); }';
+normalized_version() {
+  printf '%s\n' "$@" | awk -F. '{ printf("%d%03d%03d%03d\n", $1,$2,$3,$4); }';
 }
 
-if [[ ($os == unknown) ]]; then
-  warn "Build script not tested on this platform"
-fi
-
-# This is not perfect by any means
 cc_id=
 cc_vers=
 lld_detected=0
-if cc_vers=$(echo -e '#ifndef __clang__\n#error Not found\n#endif\n__clang_major__.__clang_minor__.__clang_patchlevel__' | "$cc_exe" -E -xc - 2>/dev/null | tail -n 1 | tr -d '\040'); then
-  cc_id=clang
-  # Mac clang/llvm doesn't say the real version of clang. Just assume it's 3.9.0
-  if [[ $os == mac ]]; then
-    cc_vers=3.9.0
-  else
-    if command -v "lld" >/dev/null 2>&1; then
-      lld_detected=1
-    fi
-  fi
-elif cc_vers=$(echo -e '#ifndef __GNUC__\n#error Not found\n#endif\n__GNUC__.__GNUC_MINOR__.__GNUC_PATCHLEVEL__' | "$cc_exe" -E -xc - 2>/dev/null | tail -n 1 | tr -d '\040'); then
-  cc_id=gcc
-elif cc_vers=$(echo -e '#ifndef __TINYC__\n#error Not found\n#endif\n__TINYC__' | "$cc_exe" -E -xc - 2>/dev/null | tail -n 1 | tr -d '\040'); then
-  cc_id=tcc
+lld_name=lld
+if preproc_result=$( \
+  ("$cc_exe" -E -xc - 2>/dev/null | tail -n 2 | tr -d '\040') <<EOF
+#if defined(__clang__)
+clang
+__clang_major__.__clang_minor__.__clang_patchlevel__
+#elif defined(__GNUC__)
+gcc
+__GNUC__.__GNUC_MINOR__.__GNUC_PATCHLEVEL__
+#elif defined(__TINYC__)
+tcc
+__TINYC__
+#else
+#error Unknown compiler
+#endif
+EOF
+); then
+  cc_id=$(printf %s "$preproc_result" | head -n 1)
+  cc_vers=$(printf %s "$preproc_result" | tail -n 1)
 fi
 
-if [[ -z $cc_id ]]; then
-  warn "Failed to detect compiler type"
+if [ "$cc_id" = clang ]; then
+  case $os in
+    # Mac clang/llvm doesn't say the real version of clang. Assume it's 3.9.0.
+    mac) cc_vers=3.9.0;;
+    *)
+      # Debian names versions clang like "clang-9" and also LLD like "lld-9".
+      # To tell clang to use LLD, we have to pass an argument like
+      # '-fuse-ld=lld'. You would expect that the Debian versions of clang,
+      # like clang-9, would want '-fuse-ld=lld-9', but it seems to work both as
+      # '-fuse-ld=lld-' and also as '-fuse-ld=lld'. I'm not sure if this holds
+      # true if multiple versions of clang are installed.
+      if output=$(printf %s "$cc_exe" | awk -F- '
+          /^clang\+?\+?-/ && $NF ~ /^[0-9]+$/ { a=$NF }
+          END { if (a == "") exit -1; printf("lld-%s", a) }'); then
+        lld_name=$output
+      fi
+      if command -v "$lld_name" >/dev/null 2>&1; then lld_detected=1; fi
+    ;;
+  esac
 fi
-if [[ -z $cc_vers ]]; then
-  warn "Failed to detect compiler version"
-fi
 
+test -z "$cc_id" && warn "Failed to detect compiler type"
+test -z "$cc_vers" && warn "Failed to detect compiler version"
+
+cc_vers_normalized=$(normalized_version "$cc_vers")
+
 cc_vers_is_gte() {
-  if [[ $(version_string_normalized "$cc_vers") -ge $(version_string_normalized "$1") ]]; then
-    return 0
-  else
-    return 1
-  fi
+  test "$cc_vers_normalized" -ge "$(normalized_version "$1")"
 }
 
 cc_id_and_vers_gte() {
-  if [[ $cc_id == "$1" ]] && cc_vers_is_gte "$2"; then
-    return 0
-  else
-    return 1
-  fi
+  test "$cc_id" = "$1" && cc_vers_is_gte "$2"
 }
 
+# Append arguments to a string, separated by newlines. Like a bad array.
 add() {
-  if [[ -z "${1:-}" ]]; then
-    script_error "At least one argument required for array add"
+  if [ -z "${1:-}" ]; then
+    script_error "At least one argument required for add"
   fi
-  local array_name
-  array_name=${1}
+  _add_name=${1}
   shift
-  eval "$array_name+=($(printf "'%s' " "$@"))"
+  while [ -n "${1+x}" ]; do
+    # shellcheck disable=SC2034
+    _add_hidden=$1
+    eval "$_add_name"'=$(printf '"'"'%s\n%s.'"' "'"$'"$_add_name"'" "$_add_hidden")'
+    eval "$_add_name"'=${'"$_add_name"'%.}'
+    shift
+  done
 }
 
-concat() {
-  if [[ -z "${1:-}" || -z "${2:-}" ]]; then
-    script_error "Two arguments required for array concat"
-  fi
-  local lhs_name
-  local rhs_name
-  lhs_name=${1}
-  rhs_name=${2}
-  eval "$lhs_name+=(\"\${${rhs_name}[@]}\")"
-}
-
 try_make_dir() {
-  if ! [[ -e "$1" ]]; then
+  if ! [ -e "$1" ]; then
     verbose_echo mkdir "$1"
-  elif ! [[ -d "$1" ]]; then
+  elif ! [ -d "$1" ]; then
     fatal "File $1 already exists but is not a directory"
   fi
 }
@@ -242,10 +231,10 @@
 build_dir=build
 
 build_target() {
-  local cc_flags=()
-  local libraries=()
-  local source_files=()
-  local out_exe
+  cc_flags=
+  libraries=
+  source_files=
+  out_exe=
   add cc_flags -std=c99 -pipe -finput-charset=UTF-8 -Wall -Wpedantic -Wextra \
     -Wwrite-strings
   if cc_id_and_vers_gte gcc 6.0.0 || cc_id_and_vers_gte clang 3.9.0; then
@@ -253,10 +242,10 @@
       -Werror=implicit-function-declaration -Werror=implicit-int \
       -Werror=incompatible-pointer-types -Werror=int-conversion
   fi
-  if [[ $cc_id = tcc ]]; then
+  if [ "$cc_id" = tcc ]; then
     add cc_flags -Wunsupported
   fi
-  if [[ $os = mac && $cc_id = clang ]]; then
+  if [ $os = mac ] && [ "$cc_id" = clang ]; then
     # The clang that's shipped with Mac 10.12 has bad behavior for issuing
     # warnings for structs initialed with {0} in C99. We have to disable this
     # warning, or it will issue a bunch of useless warnings. It might be fixed
@@ -264,26 +253,26 @@
     # indecipherable, so we'll just always turn it off.
     add cc_flags -Wno-missing-field-initializers
   fi
-  if [[ $lld_detected = 1 ]]; then
-    add cc_flags -fuse-ld=lld
+  if [ $lld_detected = 1 ]; then
+    add cc_flags "-fuse-ld=$lld_name"
   fi
-  if [[ $protections_enabled = 1 ]]; then
+  if [ $protections_enabled = 1 ]; then
     add cc_flags -D_FORTIFY_SOURCE=2 -fstack-protector-strong
   fi
-  if [[ $pie_enabled = 1 ]]; then
+  if [ $pie_enabled = 1 ]; then
     add cc_flags -pie -fpie -Wl,-pie
   # Only explicitly specify no-pie if cc version is new enough
   elif cc_id_and_vers_gte gcc 6.0.0 || cc_id_and_vers_gte clang 6.0.0; then
     add cc_flags -no-pie -fno-pie
   fi
-  if [[ $static_enabled = 1 ]]; then
+  if [ $static_enabled = 1 ]; then
     add cc_flags -static
   fi
   case $config_mode in
     debug)
       add cc_flags -DDEBUG -ggdb
-      # cygwin gcc doesn't seem to have this stuff, just elide for now
-      if [[ $os != cygwin ]]; then
+      # cygwin gcc doesn't seem to have this stuff, so just elide for now
+      if [ $os != cygwin ]; then
         if cc_id_and_vers_gte gcc 6.0.0 || cc_id_and_vers_gte clang 3.9.0; then
           add cc_flags -fsanitize=address -fsanitize=undefined \
             -fsanitize=float-divide-by-zero
@@ -293,63 +282,50 @@
             -fsanitize=unsigned-integer-overflow
         fi
       fi
-      if [[ $os = mac ]]; then
-        # Our mac clang does not have -Og
-        add cc_flags -O1
-      else
-        add cc_flags -Og
-        # needed if address is already specified? doesn't work on mac clang, at
-        # least
-        # add cc_flags -fsanitize=leak
-      fi
+      case $os in
+        mac) add cc_flags -O1;; # Our Mac clang does not have -Og
+        *) add cc_flags -Og;;
+      esac
       case $cc_id in
-        tcc) add cc_flags -g -bt 10;;
+        tcc) add cc_flags -g -bt10;;
       esac
-      ;;
+    ;;
     release)
       add cc_flags -DNDEBUG -O2 -g0
-      if [[ $protections_enabled != 1 ]]; then
+      if [ $protections_enabled != 1 ]; then
         add cc_flags -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0
         case $cc_id in
-          gcc|clang) add cc_flags -fno-stack-protector;;
+          gcc|clang) add cc_flags -fno-stack-protector
         esac
       fi
-      if [[ $os = mac ]]; then
-        # todo some stripping option
-        true
-      else
-        # -flto is good on both clang and gcc on Linux
-        case $cc_id in
-          gcc|clang)
-            if [[ $os != bsd ]]; then
-              add cc_flags -flto
-            fi
-        esac
-        add cc_flags -s
-      fi
-      ;;
+      # -flto is good on both clang and gcc on Linux and Cygwin. Not supported
+      # on BSD, and no improvement on Mac. -s gives an obsolescence warning on
+      # Mac. For tcc, -flto gives and unsupported warning, and -s is ignored.
+      case $cc_id in gcc|clang) case $os in
+        linux|cygwin) add cc_flags -flto -s;;
+        bsd) add cc_flags -s;;
+      esac esac
+    ;;
     *) fatal "Unknown build config \"$config_mode\"";;
   esac
 
   case $arch in
     x86_64)
-      # 'nehalem' tuning actually produces faster code for orca than later
-      # archs, for both gcc and clang, even if it's running on a later arch
-      # CPU. This is likely due to smaller emitted code size. gcc earlier than
-      # 4.9 does not recognize the arch flag for it it, though, and I haven't
-      # tested a compiler that old, so I don't know what optimization behavior
-      # we get with it is. Just leave it at default, in that case.
       case $cc_id in
+        # 'nehalem' tuning actually produces faster code for orca than later
+        # archs, for both gcc and clang, even if it's running on a later arch
+        # CPU. This is likely due to smaller emitted code size. gcc earlier
+        # than 4.9 does not recognize the arch flag for it it, though, and I
+        # haven't tested a compiler that old, so I don't know what optimization
+        # behavior we get with it is. Just leave it at default, in that case.
         gcc)
           if cc_vers_is_gte 4.9; then
             add cc_flags -march=nehalem
           fi
-          ;;
-        clang)
-          add cc_flags -march=nehalem
-          ;;
+        ;;
+        clang) add cc_flags -march=nehalem;;
       esac
-      ;;
+    ;;
   esac
 
   add source_files gbuffer.c field.c vmio.c sim.c
@@ -357,7 +333,7 @@
     cli)
       add source_files cli_main.c
       out_exe=cli
-      ;;
+    ;;
     orca|tui)
       add source_files osc_out.c term_util.c sysmisc.c thirdparty/oso.c tui_main.c
       add cc_flags -D_XOPEN_SOURCE_EXTENDED=1
@@ -369,14 +345,14 @@
       out_exe=orca
       case $os in
         mac)
-          local brew_prefix=
           if ! brew_prefix=$(printenv HOMEBREW_PREFIX); then
-             brew_prefix=/usr/local/
+             brew_prefix=/usr/local
           fi
-          local ncurses_dir="$brew_prefix/opt/ncurses"
-          if ! [[ -d "$ncurses_dir" ]]; then
-            echo "Error: ncurses directory not found at $ncurses_dir" >&2
-            echo "Install with: brew install ncurses" >&2
+          ncurses_dir="$brew_prefix/opt/ncurses"
+          if ! [ -d "$ncurses_dir" ]; then
+            printf 'Error: ncurses directory not found at %s\n' \
+              "$ncurses_dir" >&2
+            printf 'Install with: brew install ncurses\n' >&2
             exit 1
           fi
           # prefer homebrew version of ncurses if installed. Will give us
@@ -384,11 +360,12 @@
           add libraries "-L$ncurses_dir/lib"
           add cc_flags "-I$ncurses_dir/include"
           # todo mach time stuff for mac?
-          if [[ $portmidi_enabled = 1 ]]; then
-            local portmidi_dir="$brew_prefix/opt/portmidi"
-            if ! [[ -d "$portmidi_dir" ]]; then
-              echo "Error: PortMidi directory not found at $portmidi_dir" >&2
-              echo "Install with: brew install portmidi" >&2
+          if [ $portmidi_enabled = 1 ]; then
+            portmidi_dir="$brew_prefix/opt/portmidi"
+            if ! [ -d "$portmidi_dir" ]; then
+              printf 'Error: PortMidi directory not found at %s\n' \
+                "$portmidi_dir" >&2
+              printf 'Install with: brew install portmidi\n' >&2
               exit 1
             fi
             add libraries "-L$portmidi_dir/lib"
@@ -398,7 +375,7 @@
           add cc_flags -DORCA_OS_MAC
         ;;
         bsd)
-          if [[ $portmidi_enabled = 1 ]]; then
+          if [ $portmidi_enabled = 1 ]; then
             add libraries "-L/usr/local/lib"
             add cc_flags "-I/usr/local/include"
           fi
@@ -409,42 +386,77 @@
           add cc_flags -D_POSIX_C_SOURCE=200809L
         ;;
       esac
-      add libraries -lmenuw -lformw -lncursesw
-      if [[ $portmidi_enabled = 1 ]]; then
+      # Depending on the Linux distro, ncurses might have been built with tinfo
+      # as a separate library that explicitly needs to be linked, or it might
+      # not. And if it does, it might need to be either -ltinfo or -ltinfow.
+      # Yikes. If this is Linux, let's try asking pkg-config what it thinks.
+      curses_flags=0
+      if [ $os = linux ]; then
+        if curses_flags=$(pkg-config --libs ncursesw formw 2>/dev/null); then
+          # Split by spaces intentionall
+          # shellcheck disable=SC2086
+          IFS=' ' add libraries $curses_flags
+          curses_flags=1
+        else
+          curses_flags=0
+        fi
+      fi
+      # If we didn't get the flags by pkg-config, just guess. (This will work
+      # most of the time, including on Mac with Homebrew, and cygwin.)
+      if [ $curses_flags = 0 ]; then
+        add libraries -lncursesw -lformw
+      fi
+      if [ $portmidi_enabled = 1 ]; then
         add libraries -lportmidi
         add cc_flags -DFEAT_PORTMIDI
-        if [[ $config_mode = debug ]]; then
-          echo -e "Warning: The PortMidi library contains code that may trigger address sanitizer in debug builds.\\nThese are not bugs in orca." >&2
+        if [ $config_mode = debug ]; then
+          cat >&2 <<EOF
+Warning: The PortMidi library contains code that may trigger address sanitizer
+in debug builds. These are probably not bugs in orca.
+EOF
         fi
       fi
-      if [[ $mouse_disabled = 1 ]]; then
+      if [ $mouse_disabled = 1 ]; then
         add cc_flags -DFEAT_NOMOUSE
       fi
-      ;;
+    ;;
     *)
-      echo -e "Unknown build target '$1'\\nValid targets: orca, cli" >&2
+      printf 'Unknown build target %s\nValid build targets: %s\n' \
+        "$1" 'orca, cli' >&2
       exit 1
-      ;;
+    ;;
   esac
   try_make_dir "$build_dir"
-  if [[ $config_mode = debug ]]; then
+  if [ $config_mode = debug ]; then
     build_dir=$build_dir/debug
     try_make_dir "$build_dir"
   fi
-  local out_path=$build_dir/$out_exe
-  # bash versions quirk: empty arrays might give error on expansion, use +
-  # trick to avoid expanding second operand
-  verbose_echo timed_stats "$cc_exe" "${cc_flags[@]}" -o "$out_path" "${source_files[@]}" ${libraries[@]+"${libraries[@]}"}
-  if [[ $stats_enabled = 1 ]]; then
-    echo "time: $last_time"
-    echo "size: $(file_size "$out_path")"
+  out_path=$build_dir/$out_exe
+  IFS='
+'
+  # shellcheck disable=SC2086
+  verbose_echo timed_stats "$cc_exe" $cc_flags -o "$out_path" $source_files $libraries
+  compile_ok=$?
+  if [ $stats_enabled = 1 ]; then
+    if [ -n "$timed_stats_result" ]; then
+      printf '%s\n' "time: $timed_stats_result"
+    else
+      printf '%s\n' "time: unavailable (missing 'time' command)"
+    fi
+    if [ $compile_ok = 0 ]; then
+      printf '%s\n' "size: $(file_size "$out_path")"
+    fi
   fi
 }
 
 print_info() {
-  local linker_name
-  if [[ $lld_detected = 1 ]]; then
+  if [ $lld_detected = 1 ]; then
     linker_name=LLD
+    # Not sure if we should always print the specific LLD name or not. Or never
+    # print it.
+    if [ "$lld_name" != lld ]; then
+      linker_name="$linker_name ($lld_name)"
+    fi
   else
     linker_name=default
   fi
@@ -462,36 +474,38 @@
 
 case $cmd in
   info)
-    if [[ "$#" -gt 1 ]]; then
-      fatal "Too many arguments for 'info'"
-    fi
-    print_info; exit 0;;
+    test "$#" -gt 1 && fatal "Too many arguments for 'info'"
+    print_info; exit 0
+  ;;
   build)
-    if [[ "$#" -lt 1 ]]; then
-      fatal "Too few arguments for 'build'"
-    fi
-    if [[ "$#" -gt 1 ]]; then
-      echo "Too many arguments for 'build'" >&2
-      echo "The syntax has changed. Updated usage examples:" >&2
-      echo "./tool build --portmidi orca   (release)" >&2
-      echo "./tool build -d orca           (debug)" >&2
+    test "$#" -lt 1 && fatal "Too few arguments for 'build'"
+    if [ "$#" -gt 1 ]; then
+      cat >&2 <<EOF
+Too many arguments for 'build'
+The syntax has changed. Updated usage examples:
+./tool build --portmidi orca   (release)
+./tool build -d orca           (debug)
+EOF
       exit 1
     fi
     build_target "$1"
-    ;;
+  ;;
   clean)
-    if [[ -d "$build_dir" ]]; then
-      verbose_echo rm -rf "$build_dir"
+    if [ -d "$build_dir" ]; then
+      verbose_echo rm -rf "$build_dir";
     fi
-    ;;
-  help) print_usage; exit 0;;
-  -*)
-    echo "The syntax has changed for the 'tool' build script." >&2
-    echo "The options now need to come after the command name." >&2
-    echo "Do it like this instead:" >&2
-    echo "./tool build --portmidi orca" >&2
+  ;;
+  help)
+    print_usage; exit 0
+  ;;
+  -*) cat >&2 <<EOF
+The syntax has changed for the 'tool' build script.
+The options now need to come after the command name.
+Do it like this instead:
+./tool build --portmidi orca
+EOF
     exit 1
-    ;;
+  ;;
   *) fatal "Unrecognized command $cmd";;
 esac
 
--- a/tui_main.c
+++ b/tui_main.c
@@ -82,8 +82,6 @@
   case 's':
   case 'W':
   case 'w':
-  case 'Z':
-  case 'z':
     return Glyph_class_movement;
   case '!':
   case ':':
@@ -1663,8 +1661,8 @@
   return visual_coord;
 }
 
-staticni void ged_mouse_event(Ged *a, Usz vis_y, Usz vis_x,
-                              mmask_t mouse_bstate) {
+ORCA_OK_IF_UNUSED staticni void ged_mouse_event(Ged *a, Usz vis_y, Usz vis_x,
+                                                mmask_t mouse_bstate) {
   if (mouse_bstate & BUTTON1_RELEASED) {
     // hard-disables tracking, but also disables further mouse stuff.
     // mousemask() with our original parameters seems to work to get into the
@@ -2001,9 +1999,6 @@
 #endif
 };
 enum {
-  Single_form_item_id = 1,
-};
-enum {
   Autofit_nicely_id = 1,
   Autofit_tightly_id,
 };
@@ -2096,13 +2091,10 @@
   qmenu_push_to_nav(qm);
 }
 static void push_soft_margins_form(int init_y, int init_x) {
-  Qform *qf = qform_create(Set_soft_margins_form_id);
   char buff[128];
   int snres = snprintf(buff, sizeof buff, "%dx%d", init_x, init_y);
   char const *inistr = snres > 0 && (Usz)snres < sizeof buff ? buff : "2x1";
-  qform_set_title(qf, "Set Margins");
-  qform_add_text_line(qf, Single_form_item_id, inistr);
-  qform_push_to_nav(qf);
+  qform_single_line_input(Set_soft_margins_form_id, "Set Margins", inistr);
 }
 static void push_plainorfancy_menu(int menu_id, char const *title,
                                    bool initial_fancy) {
@@ -2129,16 +2121,12 @@
   qmenu_push_to_nav(qm);
 }
 static void push_osc_output_address_form(char const *initial) {
-  Qform *qf = qform_create(Osc_output_address_form_id);
-  qform_set_title(qf, "Set OSC Output Address");
-  qform_add_text_line(qf, Single_form_item_id, initial);
-  qform_push_to_nav(qf);
+  qform_single_line_input(Osc_output_address_form_id, "Set OSC Output Address",
+                          initial);
 }
 static void push_osc_output_port_form(char const *initial) {
-  Qform *qf = qform_create(Osc_output_port_form_id);
-  qform_set_title(qf, "Set OSC Output Port");
-  qform_add_text_line(qf, Single_form_item_id, initial);
-  qform_push_to_nav(qf);
+  qform_single_line_input(Osc_output_port_form_id, "Set OSC Output Port",
+                          initial);
 }
 enum {
   Playback_menu_midi_bclock = 1,
@@ -2320,10 +2308,7 @@
   }
 }
 static void push_open_form(char const *initial) {
-  Qform *qf = qform_create(Open_form_id);
-  qform_set_title(qf, "Open");
-  qform_add_text_line(qf, Single_form_item_id, initial);
-  qform_push_to_nav(qf);
+  qform_single_line_input(Open_form_id, "Open", initial);
 }
 staticni bool try_save_with_msg(Field *field, oso const *str) {
   if (!osolen(str))
@@ -2339,28 +2324,19 @@
   return ok;
 }
 static void push_save_as_form(char const *initial) {
-  Qform *qf = qform_create(Save_as_form_id);
-  qform_set_title(qf, "Save As");
-  qform_add_text_line(qf, Single_form_item_id, initial);
-  qform_push_to_nav(qf);
+  qform_single_line_input(Save_as_form_id, "Save As", initial);
 }
 static void push_set_tempo_form(Usz initial) {
-  Qform *qf = qform_create(Set_tempo_form_id);
   char buff[64];
   int snres = snprintf(buff, sizeof buff, "%zu", initial);
   char const *inistr = snres > 0 && (Usz)snres < sizeof buff ? buff : "120";
-  qform_set_title(qf, "Set BPM");
-  qform_add_text_line(qf, Single_form_item_id, inistr);
-  qform_push_to_nav(qf);
+  qform_single_line_input(Set_tempo_form_id, "Set BPM", inistr);
 }
 static void push_set_grid_dims_form(Usz init_height, Usz init_width) {
-  Qform *qf = qform_create(Set_grid_dims_form_id);
   char buff[128];
   int snres = snprintf(buff, sizeof buff, "%zux%zu", init_width, init_height);
   char const *inistr = snres > 0 && (Usz)snres < sizeof buff ? buff : "57x25";
-  qform_set_title(qf, "Set Grid Size");
-  qform_add_text_line(qf, Single_form_item_id, inistr);
-  qform_push_to_nav(qf);
+  qform_single_line_input(Set_grid_dims_form_id, "Set Grid Size", inistr);
 }
 
 #ifdef FEAT_PORTMIDI
@@ -2405,14 +2381,6 @@
 }
 #endif
 
-staticni oso *get_nonempty_singular_form_text(Qform *qf) {
-  oso *s = NULL;
-  if (qform_get_text_line(qf, Single_form_item_id, &s) && osolen(s) > 0)
-    return s;
-  osofree(s);
-  return NULL;
-}
-
 staticni bool read_int(char const *str, int *out) {
   int a;
   int res = sscanf(str, "%d", &a);
@@ -3135,7 +3103,7 @@
       case Qform_action_type_submitted: {
         switch (qform_id(qf)) {
         case Open_form_id: {
-          oso *temp_name = get_nonempty_singular_form_text(qf);
+          oso *temp_name = qform_get_nonempty_single_line_input(qf);
           if (!temp_name)
             break;
           expand_home_tilde(&temp_name);
@@ -3168,7 +3136,7 @@
           break;
         }
         case Save_as_form_id: {
-          oso *temp_name = get_nonempty_singular_form_text(qf);
+          oso *temp_name = qform_get_nonempty_single_line_input(qf);
           if (!temp_name)
             break;
           qnav_stack_pop();
@@ -3179,7 +3147,7 @@
           break;
         }
         case Set_tempo_form_id: {
-          oso *tmpstr = get_nonempty_singular_form_text(qf);
+          oso *tmpstr = qform_get_nonempty_single_line_input(qf);
           if (!tmpstr)
             break;
           int newbpm = atoi(osoc(tmpstr));
@@ -3193,7 +3161,7 @@
         case Osc_output_address_form_id: {
           oso *addr = NULL;
           // Empty string is OK here
-          if (qform_get_text_line(qf, Single_form_item_id, &addr)) {
+          if (qform_get_single_text_line(qf, &addr)) {
             if (osolen(addr))
               ososwap(&t->osc_address, &addr);
             else
@@ -3207,7 +3175,7 @@
           break;
         }
         case Osc_output_port_form_id: {
-          oso *portstr = get_nonempty_singular_form_text(qf);
+          oso *portstr = qform_get_nonempty_single_line_input(qf);
           if (!portstr)
             break;
           qnav_stack_pop();
@@ -3219,7 +3187,7 @@
           break;
         }
         case Set_grid_dims_form_id: {
-          oso *tmpstr = get_nonempty_singular_form_text(qf);
+          oso *tmpstr = qform_get_nonempty_single_line_input(qf);
           if (!tmpstr)
             break;
           int newheight, newwidth;
@@ -3243,7 +3211,7 @@
           break;
         }
         case Set_soft_margins_form_id: {
-          oso *tmpstr = get_nonempty_singular_form_text(qf);
+          oso *tmpstr = qform_get_nonempty_single_line_input(qf);
           if (!tmpstr)
             break;
           bool do_save = false;
@@ -3565,6 +3533,7 @@
   }
   case KEY_RESIZE:
     tui_adjust_term_size(&t, &cont_window);
+    qnav_adjust_term_size();
     goto event_loop;
 #ifndef FEAT_NOMOUSE
   case KEY_MOUSE: {
--