Skip to content

Interactive screen programs in PLANC - the patterns that work

What this page is. The other VTM pages tell you what the calls do. This one tells you how to put them together into a program that reacts to keys, redraws windows, and does things on a clock - and it exists because every one of these patterns was got WRONG first, on a real ND-100, before it was got right.

Everything here was measured on D100 between 2026-08-24 and 2026-08-25. The worked example is SINTRAN/XMSG/TESTUI/TESTUI.PLNC - 1379 lines, compiled and running. Where a claim is not measured, it says so.

Read first: VTM-API-REFERENCE.md for the calls, VTM-VIEWPORT-HOW-TO-USE-IT.md for the viewport, PLANC-UI-VTM-GUIDE.md for PLANC-SCREEN-H.


0. The shape of the program

    INISTACK stack
    blankscreen                     % VTM's first call - the CTYTP bits appear here
    <read the terminal type, refuse a printing terminal>
    drawMain
    MON3(1, -1, echoTab)            % take the keyboard: no echo
    MON71(1)                        % and no ESC abort

    DO
        pollKey =: key
        IF key = 0 THEN
            <anything on a clock goes here>
            MN104(pollHold, 1)      % sleep a tenth of a second
        ELSIF key = ... THEN
            <handle it>
        ENDIF
    ENDDO

exit:
    blankscreen
    MON3(1, 0, echoTab)             % give the keyboard back - BOTH halves
    MON72(1)
    resetscreen

Six things in that skeleton are the patterns below. None of them is obvious and none of them is in any ND manual, because VTM's manual number is literally "Internal".


1. Own the keyboard - and give it back

Poll, do not block

intacc (PLANC-SCREEN-H) blocks and can only give back a number, so it cannot serve a menu with letter keys and it cannot coexist with a timer. MON1 blocks too. TerminalNoWait does not help - it was tried on this machine and MON1 went on blocking anyway.

MON66 (ISIZE, InBufferSpace) answers how many bytes are WAITING. Ask first, read only when there is one, and blocking stops mattering:

    ROUTINE VOID, INTEGER : pollKey
        INTEGER : waiting
        INTEGER : ch
        MON66(1) =: waiting
        IF waiting <= 0 THEN
            0 RETURN
        ENDIF
        MON1(1) =: ch
        % TERMINAL INPUT CARRIES AN EVEN-PARITY BIT IN BIT 7 - S arrives as
        % 211, not 83. Mask it or nothing ever matches and the program looks
        % hung rather than wrong.
        ch AND 127 RETURN
    ENDROUTINE

Sleep when the buffer is empty or this is a spin, not a poll: MN104(5, 1), type 1 = basic units of a fiftieth of a second. The manual says no parameter may be zero.

Function keys are a DIFFERENT route - VTINBT, not MON1

The poll above reads ordinary characters as bytes. A function key is an ESC SEQUENCE, and VTINBT is what turns one into a single code.

VTM-KEY-CODES.md is the whole table - what VTINBT returns for every key, measured on D100 with SINTRAN/XMSG/SINTRAN-CHAT/KEYPROB.PLNC. No ND document describes any of it, so that page is the only record there is. It also names two traps that make VTINBT look as though it decodes nothing: type it as three values IN and one OUT, and pass VARIABLES rather than literals.

Not measured by this page - the key-codes page did that work.

Note the names: MON66 but MN104 and MN113 - the runtime drops the O at three digits.

Take the echo, and take ESC to make that safe

A raw key loop has no say in where the cursor is, so SINTRAN echoes each keypress wherever the last drawing finished - inside your windows. Measured: a stray 2 in one window and a 1 in another.

    BOOLEAN ARRAY PACKED : echoTab(0:127)     % never read except for strategy 7
    ...
    MON3(1, -1, echoTab)      % below zero = no echo (the password-field case)
    MON71(1)                  % take ESC away from SINTRAN

Disabling ESC is what makes disabling the echo safe, not bravado. An ESC abort skips your exit path, and a terminal handed back with no echo at all is far worse than a stray character.

AND ESC STILL QUITS. Because SINTRAN is no longer intercepting it, ESC arrives at pollKey as ordinary byte 27 - handle it exactly like your quit key. The escape hatch is not lost, it is routed through your own tidy-up instead of around it. MEASURED: ESC left the program and @TIME echoed normally at the prompt afterwards.

Restore BOTH halves on every exit path. MON3(1, 0, echoTab) and MON72(1).


2. FLUSH, or your timer will look broken

VTM buffers its output. A terminal READ is what flushes it.

Every PLANC-SCREEN-H program redraws and then calls intacc, so the read flushes the repaint and nobody ever sees the buffer. The moment your program draws something without a keystroke behind it - a timer, a message arriving, a poll - the drawing goes into the buffer and stays there.

    IMPORT ( ROUTINE VOID, INTEGER : VTWBUF )     % NO arguments
    ...
    VTWBUF =: st                                   % after drawing, before sleeping

The symptom points at the wrong component. It does not look like a stale screen, it looks like your timer stopped. What proved it: with the timer apparently dead, one keypress made three lines appear at once, two of them stamped seconds earlier. Lines that arrive together but are stamped five seconds apart were drawn on time and shown late.


3. Windows: use the viewport

VTDSCR moves VTM's ORIGIN, so everything afterwards - VTPCUR, VTCREC, and through PLANC-SCREEN-H also frame, bytdis, blankarea, blankscreen - is measured from the rectangle's corner. It CLIPS (for VTWRIT), and it does NOT nest: a second VTDSCR replaces the first and reads its rectangle against the PHYSICAL screen.

    ROUTINE VOID, VOID (INTEGER, INTEGER, INTEGER, INTEGER) : winEnter(row, col, high, wide)
        INTEGER : st
        VTDSCR(row, col, row + high - 1, col + wide - 1) =: st   % ABSOLUTE always
    ENDROUTINE

    ROUTINE VOID, VOID : winLeave
        INTEGER : st
        VTDSCR(1, 1, -1, -1) =: st        % -1 = "to the end". VTINIT is not the only way back
    ENDROUTINE

What this buys: window code stops carrying the window's position. TESTUI's big window used to have eighteen start columns, each 65 - length, every one wrong the moment the window moved. Now they are all local and the position appears once.


4. You are the window manager - four rules

VTM has no z-order and no save-under, and neither does PLANC-SCREEN-H. So a stack order is something you write down, and three separate places have to read it.

Rule 0: KEEP EVERY WINDOW'S RECTANGLE AT MODULE LEVEL

This one comes first because the other three are impossible without it, and it is the rule that was missing when the rest of this section was written.

Row, column, height and width must OUTLIVE the routine that draws the window. If they are locals - computed, drawn with, and gone when the routine returns - then nothing else in the program knows where the window is, and from that moment:

  • you cannot clip, because there is no rectangle to test a cell against;
  • you cannot repaint only what was covered on close, because there is no rectangle to repaint.

So the program falls back to drawAll on close and to "do not paint while a window is open", and those are not bad choices - they are the only ones left. The bug is upstream of both.

TESTUI.PLNC keeps smallRow/smallCol/smallHigh/smallWide and bigRow/... at module level, and everything it does well follows from that. CHAT.PLNC's drawPanel kept them as locals, and broke Rules 1, 1b and 2 as a direct result - reported by Ronny 2026-08-31, the SECOND time the same family of fault reached him.

The check takes a second: in the routine that opens a window, are the four numbers visible from anywhere else? If not, stop.

Rule 1: CLIP what you paint - never SKIP it

A covered window must not paint through the one on top. The tempting cure - "a covered window paints nothing" - is safe and WRONG. It shipped for half a day and froze nine visible lines because a window covered two of them.

    % Is one cell of this window visible? Window's own coordinates in,
    % physical conversion in ONE place.
    ROUTINE VOID, BOOLEAN (INTEGER, INTEGER) : visibleAt(lrow, lcol)
        INTEGER : prow
        INTEGER : pcol
        scrRow + lrow - 1 =: prow
        scrCol + lcol - 1 =: pcol
        IF smallOpen THEN
            IF prow >= smallRow AND prow <= smallRow + smallHigh - 1 THEN
                IF pcol >= smallCol AND pcol <= smallCol + smallWide - 1 THEN
                    FALSE RETURN
                ENDIF
            ENDIF
        ENDIF
        ...                                  % one block per window above you
        TRUE RETURN
    ENDROUTINE

Then walk the line once and paint each visible run as it ends. A test per character costs nothing on the CPU; what it saves is the wire, which is the slow half.

Rule 1b: NEVER repaint the whole screen for a partial change

This is the rule that gets broken, and it gets broken by writing drawAll. It is one call, it always looks right, and it rubs out twenty-four rows to alter three of them.

Before writing a repaint, name the regions the change actually touched, and paint those:

What changed What to repaint NOT
a line arrived the pane rows that differ the pane, the frame, the title
the window switched pane + status line + bar the whole screen
a window was closed the bar the whole screen
the room name changed the status line + the bar the pane

The frame, the title and the typed line almost never change. A repaint that includes them is telling you the author did not ask the question.

MEASURED 2026-08-26 in the chat client: window switching and window closing were both written as drawAll, and the arriving-message path cleared all seventeen pane rows with VTCREC before drawing seventeen back - for one new line. All three were written with this page already open in the repo.

Rule 1c: a row that already shows the right thing is not painted at all

Padding every stored line to full width (Rule in section 5) means a write needs no erase - so the only question left is which rows differ. Keep a shadow of what is on each row and skip the rest:

    INTEGER ARRAY : rowShown(0:16)   % the LINE NUMBER on each row, -1 = blank
    INTEGER : shadowWin := 0         % which buffer the shadow describes

Compare against a line number that never repeats, not a ring slot - a ring reuses slots, so a slot number would compare equal for two different lines and the pane would keep stale text. Count every line ever added and name each row by that.

Throw the shadow away when the buffer changes (a different window, a different room). Line numbers are counted per buffer, so row 3 of one and row 3 of another compare equal and would be left alone wrongly.

What it costs, per arriving line, on a seventeen-row pane:

Case Rows written
pane still filling 1
nothing moved 0
pane full, shifting up 17 - unavoidable, there is no scroll verb

Rule 2: every CLOSE repaints the stack BOTTOM UP

Not "put back the window I overlapped" - that works with two windows and breaks with three.

Rule 3: every OPEN puts back the windows ABOVE it

Opening paints over whatever was there, higher windows included. repaintAbove(level) costs nothing for the window that is already on top.

One stack order, three consumers. In TESTUI: scroll -> small -> big.


5. Scrolling is yours to do

No scrolling region exists at any layer in any ND product - searched across every BRF entry, every link-map symbol and the whole documentation library. A VT100 has DECSTBM and VTM has no way to reach it.

So keep the lines yourself in a ring buffer - scrolling is then one index change and no text is copied at all:

    BYTES : scrBuf(0:699)          % 10 slots x 70 characters
    INTEGER : scrCount             % lines held, 0..10
    INTEGER : scrFirst             % slot holding the OLDEST line

Two things make it cheap:

  • Store every line PADDED to full width. A short line painted over a long one would leave the old tail on screen; padding removes the need to erase first.
  • Only a FULL window costs a repaint. While there is still room the new line goes on the next free row and nothing else moves - one line, not ten.

6. Do not count characters by hand

MAXINDEX(text, 1) asks an array its declared upper bound and needs no IMPORT - it is a standard routine (ND-60.117.5 section 3.17, page 153). A PLANC string literal subscripts from ZERO, so the length is MAXINDEX + 1:

    ROUTINE VOID, VOID (INTEGER, BYTES) : bigLine(row, text)
        bytdis(row, bigWide - 5 - MAXINDEX(text, 1), 0, text, '')
    ENDROUTINE

MEASURED: all eighteen lines land on one column. A hand-counted length builds clean and shows only as a line in the wrong place - the same family as the 'ALn' width trap.

Restriction, page 249: not available on array parameters inside a STANDARD routine (the FORTRAN/COBOL calling sequence). An ordinary PLANC routine is fine.


7. The traps, all measured

Trap What you see The fix
bytdis width 0 with a SUBARRAY The line runs off the right and wipes borders it could not reach Pass the FIELD WIDTH. Width 0 is right for a literal only
No flush "The timer stopped" VTWBUF after any drawing not followed by a read
CLOCK's year is 1998, not 98 Date prints 24-08- 8 Clamp inside the helper: v MOD 100. digits(199) is a read 190 bytes out of bounds and PLANC checks nothing
A covered window frozen Buffer updates, screen does not Clip per cell, do not skip
Opening under a window The new window's frame across the old one repaintAbove
Key echo Stray characters inside windows MON3(1, -1, ...) plus MON71(1), restored on exit
Parity bit Nothing ever matches your key constants ch AND 127
A PLANC LOCAL may not be initialised *** ERROR - INITIAL VALUE ILLEGAL HERE Declare bare, assign after INISTACK. Module level MAY be initialised
VTDSCR does not nest A sub-window lands at the physical corner Always pass absolute coordinates

8. What it costs on the wire

The reason any of this care is worth taking: on a real 9600-baud line a full 24x80 repaint is about 2000 characters, roughly two seconds. The numbers TESTUI works to:

Action Characters
Whole screen ~2000
One window ~100-800
One scroll line, window not full 70
One scroll line, window FULL ~700 (ten lines move)
One clipped line under a window only the visible runs

On an emulator you feel none of it. On the machine these programs were written for, you feel all of it.


See also