The Case of the Invisible Popups

October 5, 2026

The session log screen has two popups: [H] on a row pops up that connection’s full history, and Enter pops up the session’s details. The SysOp account-review screen pops up a confirmation with the temp password when you approve a request. File lists pop up a preview panel. All four had been built the same way — a Window, a hint list, a Label shoved into the contents panel — and all four now shared the same bug report: the popup doesn’t appear.

What made this one a haunting rather than a bug report was that the screens they sat on kept working perfectly. ESC still closed whatever popup was “open” — you could even tell your keystrokes were going somewhere. The modal was there. It was interacting. It was just… not visible. Not one row of it. Nothing.

What the code looked like

The popup pattern in the codebase was uniform, and looked reasonable:

BasicWindow popup = new BasicWindow("Connection History");
popup.setHints(List.of(WindowHint.MODAL, WindowHint.CENTERED));
popup.getContents().addComponent(new Label(String.join("\n", lines)));
session.gui().addWindow(popup);

Four screens, four variations on this. Compare it with the widget that always worked — MessageDialog, the BBS’s own wrapper:

var contents = getContents();
contents.setLayoutManager(new LinearLayout(LinearLayout.Direction.VERTICAL, 0));

The missing line was right there. The only difference between the popup that rendered and the popups that didn’t was a call to setLayoutManager.

Why a missing layout manager means “one cell wide”

The window stack sizes every window from its contents panel’s preferred size. The contents panel is an AbstractContainer, and its preferred-size math delegates — if it can:

protected TerminalSize calculatePreferredSize() {
    if (layoutManager == null) return new TerminalSize(1, 1);
    return layoutManager.getPreferredSize(children);
}

That guard was a deliberate choice when the container was written: a container with no layout manager has no defined layout, so it reports a minimal stub rather than guessing. Sensible on its own. But the failure it produces downstream is exquisitely silent:

  1. The window stack asks the contents panel how big it wants to be.
  2. Panel says: one column, one row.
  3. CENTERED places that 1×1 rectangle in the exact middle of the screen.
  4. The window border — which still exists! — draws one character of border around one empty cell. On an 80×24 terminal that’s a single glyph in the center of your screen, rendered in a theme where it is nearly indistinguishable from the background. On some frames it scrolled with the next repaint. You would never call it a popup. Nobody did.

Then the modal input routing kicks in: all keystrokes route to the “active window,” which is the now-invisible stub, whose only handler is “any key closes me.” So the popup worked, from the input’s point of view — pressed keys went in, ESC closed it, focus returned to the screen. Every observable behavior except rendering said the feature was fine.

That’s why nothing crashed and nothing logged. A NullPointerException would have been kind. A 1×1 centered window is a feature that succeeds at every step and fails only in aggregate.

Why it took so long to surface

No code path ever changed. The popups had probably been 1×1 since they were written — but they were also all behind sysop screens: account review, session log, file ops. The person testing them was the person who wrote them, on the screens used least, and a popup you’ve never seen work reads as a feature that doesn’t exist yet, not a feature that’s broken. File-area reports finally forced the audit, and once one popup was confirmed 1×1 under a debug trace, all four went onto the same diff.

The general lesson is worth the price of admission: there is no component whose failure mode is “renders empty” that doesn’t get misread as “feature missing.” A crash gets reported. An empty render gets absorbed — filed in the category of things the BBS presumably doesn’t do.

The fix

One line per screen, plus two small correctness notes that fell out of testing the fix:

popup.getContents().setLayoutManager(new LinearLayout(LinearLayout.Direction.VERTICAL, 0));

The two notes:

  • A single multi-line Label still reports the wrong width to the centered placement in some edge cases — the preferred-size math for a multi-line label is per-line columns, but the placement path can read the whole string’s longest line including the newline separators. One component per line is what MessageDialog already did; the fixed popups now do the same.
  • requestRefresh() after addWindow — without it the centered stub could survive one more frame in the same corner of the screen. Harmless after the fix, but cheap insurance.

The commit drops two table columns and re-fits a third at the same time — unrelated housekeeping that rode along — but the popup fix is four calls to setLayoutManager, and every popup in the codebase now goes through the class that does it right.

The takeaway

Two rules that generalize past this codebase:

Containers without layout managers are undefined behavior in slow motion. If your toolkit lets a container exist with no layout policy, its preferred size is a lie you’ll be told at the worst moment. Either the toolkit should refuse to place such a window, or the default layout should be something harmless and visible. “1×1” manages to be neither.

Test your modals the way users meet them — by pressing the key and looking. None of the four screens had a test that opened the popup and asserted it occupied more than one cell. The regression suite now anchors preferred sizes, so the next layoutless container fails a test instead of a person.