diff --git a/Source/Emulator/io.c b/Source/Emulator/io.c index 58d19e6..1f6de28 100644 --- a/Source/Emulator/io.c +++ b/Source/Emulator/io.c @@ -610,14 +610,48 @@ static int consoleKeyFromEscape(void) { // One key from standard input: a byte as it arrived, or one of the console's own key values // where a terminal sent a sequence meaning one. +// ---- The terminal's erase key is not the one this machine knows ---- +// +// SplitBit's Backspace is 0x08. That is what Voyager's keyboard sends and what CosmOS's line +// editor looks for. A POSIX terminal sends whatever ITS erase character is, and on most of +// them that is 0x7F. +// +// It went unnoticed for as long as the terminal was doing the editing: in canonical mode the +// tty eats its own erase character and hands over a finished line. Key mode turns ICANON off, +// which is the point of it, and from then on the byte arrives raw and means nothing to the +// editor. So Backspace worked in Voyager and stopped working in the console-only emulator on +// the day the shell started editing the line itself. +// +// VERASE IS ASKED RATHER THAN 0x7F ASSUMED, because the terminal is what knows: it was saved +// on the way into raw mode, some terminals really are set to 0x08, and a person who has moved +// their erase key somewhere else has said where it is. +// +// ONLY WHEN STANDARD INPUT IS A TERMINAL. A file or a pipe holding 0x7F holds a byte that +// somebody wrote, not a key somebody pressed, and rewriting it would corrupt input that has +// nothing to do with terminals. consoleTerminalSaved is exactly that question: it is only +// ever set after an isatty succeeded. +static int consoleEraseNormalized(int got) { + if (!consoleTerminalSaved || got < 0) { + return got; + } + const cc_t erase = consoleSavedTerminal.c_cc[VERASE]; + // A terminal with no erase key at all, which says so this way, has nothing to translate. + if (erase == _POSIX_VDISABLE) { + return got; + } + return got == (int)erase ? 0x08 : got; +} + static int consoleKeyFromInput(int mayWait) { for (;;) { int got; if (consoleHeldByte >= 0) { + // Already normalized on the way in, and doing it twice would be wrong the day + // 0x08 is somebody's erase character: it is its own answer. got = consoleHeldByte; consoleHeldByte = -1; } else { - got = consoleFromInput(mayWait); + got = consoleEraseNormalized(consoleFromInput(mayWait)); } if (got < 0) { return got; diff --git a/SplitBit Programming Manual.md b/SplitBit Programming Manual.md index 59fda33..0fb55f9 100644 --- a/SplitBit Programming Manual.md +++ b/SplitBit Programming Manual.md @@ -307,6 +307,8 @@ Backspace is 0x08 and always has been. It is a different key from Delete and doe **The console normalises, which is what it has always done.** Behind a window it turns the key somebody pressed into a byte; on a terminal it turns `ESC [ A` and its neighbours into the same byte. That is the same act it performs on Return and Backspace, and it is why a program does not have to know which of the two it is talking to. The translation happens only when there really is a terminal: a file or a pipe holds exactly the bytes somebody put in it, and a program reading one gets those bytes untouched - which is also how a test presses an arrow key. +**Backspace is part of that, and the terminal says what it is.** A terminal has an erase character of its own, and while it is doing the editing it consumes that character itself and hands over a finished line. Key mode stops it editing, so from then on the byte arrives as it was sent - usually 0x7F, sometimes 0x08, and whatever the person at the keyboard has configured. The console asks the terminal which one it is rather than assuming, and delivers 0x08. Forward Delete stays 0x86: they are two keys and they stay two keys. + **These arrive in key mode only.** Line mode delivers characters, and a program in line mode is being handed a line that something else has already finished editing, so a key meaning "move the cursor left" arrived too late to mean anything. The console drops them there. This is what a terminal does too: it has always given a program in line mode backspace and line kill, and has never given it arrow keys. **But asking the status port in line mode does not throw one away.** A key line mode will not deliver is not the same as a key that is gone, because the mode can change: a program that looks at the status port and then asks for key mode - which is exactly what a system does before it reads a line - would otherwise find that the first key it was reaching for had been swallowed by the looking. So the console holds it and delivers it as soon as something is willing to take it. Reading the data port in line mode does discard it, and must: that read is the delivery, and a key held there would be met again forever. diff --git a/Tests/terminal.sh b/Tests/terminal.sh index 643d285..d30df0b 100755 --- a/Tests/terminal.sh +++ b/Tests/terminal.sh @@ -91,6 +91,9 @@ show: INIB 0x86 XOR BRQ showDelete + INIB 0x08 + XOR + BRQ showBack INIB 0x1B XOR BRQ showEscape @@ -109,6 +112,10 @@ showDelete: INIA 0x44 ; 'D' OUTA 0x00 RET +showBack: + INIA 0x42 ; 'B' + OUTA 0x00 + RET showEscape: INIA 0x45 ; 'E' OUTA 0x00 @@ -134,6 +141,7 @@ python3 - "$ROOT" "$BUILD" <<'PY' import os import pty import select +import termios import signal import re import subprocess @@ -251,11 +259,17 @@ os.close(fd) # expects the far end to know what that means, and the console is the far end. Every other # test in this suite feeds a file, where the translation deliberately does not happen, so # this is the only place the sequences are ever read as sequences. -def typedAt(typing, seconds=4): +def typedAt(typing, seconds=4, erase=None): """Runs the naming program under a pseudo-terminal, types at it, and gives back the - letters it printed.""" + letters it printed. erase sets the terminal's own erase character first, which is set on + the SLAVE side in the child: that is the terminal the machine will find and save, and + configuring the master would be configuring something else.""" pid, fd = pty.fork() if pid == 0: + if erase is not None: + attributes = termios.tcgetattr(0) + attributes[6][termios.VERASE] = erase + termios.tcsetattr(0, termios.TCSANOW, attributes) os.execv(emulator, [emulator, "--fast", os.path.join(build, "escape.bin")]) # The machine has to have asked for key mode before anything is typed at it. Until it # does, the terminal is still holding what arrives until Return and the sequences would @@ -293,6 +307,40 @@ seen = typedAt([(b"\x1b[A", 0.2), (b"\x1b[D", 0.2), (b"\x1b[3~", 0.2)]) report(seen == b"ULD", "a terminal's escape sequences arrive as keys", "" if seen == b"ULD" else "expected ULD, saw %r" % seen) + +# ---- Backspace, whatever this terminal calls it ---- +# +# SplitBit's Backspace is 0x08 and CosmOS's line editor looks for that. A POSIX terminal +# sends its own erase character, which is usually 0x7F, and while the terminal was doing the +# editing nobody could tell: canonical mode ate the byte and handed over a finished line. Key +# mode turns that off, so from the day the shell started editing its own line, Backspace +# worked in Voyager and did nothing at all in the console-only emulator. +# +# Both settings are tried because the fix reads VERASE rather than assuming 0x7F, and a +# terminal set to 0x08 has to keep working - it is already sending the right byte. +seen = typedAt([(b"\x7f", 0.3), (b"\x1b[3~", 0.2)], erase=0x7F) +report(seen == b"BD", "a terminal's erase character arrives as Backspace", + "" if seen == b"BD" else "expected BD, saw %r" % seen) + +seen = typedAt([(b"\x08", 0.3), (b"\x1b[3~", 0.2)], erase=0x08) +report(seen == b"BD", "and a terminal that already sends 0x08 still works", + "" if seen == b"BD" else "expected BD, saw %r" % seen) + + +# ---- And nothing is rewritten when there is no terminal ---- +# +# The translation is a thing done TO A TERMINAL, because a terminal is the only thing that +# has an erase character. A file or a pipe holding 0x7F holds a byte somebody wrote, and a +# console that rewrote it would corrupt input that has nothing to do with keys. +fed = os.path.join(build, "erase.keys") +with open(fed, "wb") as f: + f.write(b"\x7f\x7f\x7f") +ran = subprocess.run([emulator, "--fast", os.path.join(build, "escape.bin")], + stdin=open(fed, "rb"), capture_output=True) +said = ran.stdout.split(b"Execution")[0].strip() +report(said == b"???", "a 0x7F from a file is left alone", + "" if said == b"???" else "expected ??? for three unnamed bytes, saw %r" % said) + # ---- And pressing Escape is still pressing Escape ---- # # Which is the whole difficulty: Up and Escape both begin with 0x1B and the only thing