vt100 wip.

This commit is contained in:
Kelvin Sherlock 2018-11-24 13:26:47 -05:00
parent 30df8aef9e
commit 6f409db93e
5 changed files with 808 additions and 23 deletions

View File

@ -129,7 +129,6 @@
screen->scrollUp(window);
} else cursor.y++;
}
}
arg1 = any ${ _scratch[0] = ((fc & 0x7f) - 32); };

View File

@ -9,35 +9,54 @@
#import <Cocoa/Cocoa.h>
#import "Emulator.h"
#include "iGeometry.h"
#include "Screen.h"
#ifdef __cplusplus
#include <vector>
#include <bitset>
struct vt100_context : public context {
enum {
G0, G1
};
enum {
CharSet_A,
CharSet_B,
CharSet_0,
CharSet_1,
CharSet_2,
};
unsigned G0_charset = CharSet_B;
unsigned G1_charset = CharSet_B;
unsigned charset = G0;
};
#endif
@interface VT100 : NSObject <Emulator> {
unsigned _state;
BOOL _altKeyPad;
unsigned cs;
vt100_context _context;
Screen::CursorType _cursorType;
BOOL _private;
vt100_context _saved_context;
BOOL _keyMode;
BOOL _vt52Mode;
BOOL _graphics;
iPoint _dca;
struct __vt100flags {
unsigned int DECANM:1; // vt52 mode
unsigned int DECANM:1; // ANSI/vt52 mode
unsigned int DECARM:1; // auto repeat mode.
unsigned int DECAWM:1; // autowrap mode
unsigned int DECCKM:1; // cursor key mode.
unsigned int DECKPAM:1; // alternate keypad.
unsigned int DECKPNM:1; // not alternate keypad.
//unsigned int DECKPNM:1; // not alternate keypad.
unsigned int DECCOLM:1; // 80/132 mode.
unsigned int DECSCLM:1; // scrolling
unsigned int DECSCNM:1; // screen
@ -46,17 +65,15 @@
unsigned int LNM:1; // line feed new line mode.
unsigned int VT52GM:1; // vt52 graphics mode
} _flags;
#ifdef __cplusplus
std::vector<int> _parms;
std::vector<unsigned> _args;
std::bitset<80> _tabs;
#endif
}
-(void)tab: (Screen *)screen;
@end

View File

@ -89,8 +89,6 @@ enum {
_flags.DECOM = 0;
_flags.DECINLM = 0;
_flags.LNM = 0;
}

771
Emulators/VT100.mm.ragel Normal file
View File

@ -0,0 +1,771 @@
//
// VT100.mm.ragel
// TwoTerm
//
// Created by Kelvin Sherlock on 4/6/2018.
//
#include <sys/ttydefaults.h>
#include <cctype>
#include <cstdio>
#include <numeric>
#include <algorithm>
#include <utility>
#import "VT100.h"
#include "OutputChannel.h"
#include "Screen.h"
#include "algorithm.h"
#define ESC "\x1b"
%%{
machine vt100;
alphtype unsigned int;
esc = 0x1b;
cancel = 0x30 | 0x32;
action clear_args {
_args.clear();
_args.push_back(0);
}
action answerback {
output->write(ESC "[?1;0c");
}
# DECOM - locked to scrolling region
# DECAWM - autowrap
action forward {
if (cursor.x > window_x.second) {
cursor.x = window_x.first;
if (_flags.DECAWM) {
if (cursor_in_region()) {
if (cursor.y == region_y.second)
screen->scrollUp(window);
else {
if (cursor.y < window_y.second) cursor.y++;
}
}
}
}
}
#
# ROM test - scrolling only happens if at bottom of scrolling region.
#
action linefeed {
/*
* ROM test - scrolling ONLY happens if at bottom line of scrolling region.
*
*/
if (cursor.y == region_y.second) {
screen->scrollUp(window);
} else {
cursor.y = std::min(cursor.y + 1u, window_y.second);
}
}
action reverse_linefeed {
if (cursor.y == region_y.first) {
screen->scrollDown(window);
} else {
cursor.y = std::min(cursor.y - 1u, window_y.first);
}
}
#
# ROM TEST - up/down can't escape the scrolling region.
#
action cursor_up {
/* cursor up */
unsigned count = _flags.DECANM ? std::min(1u, _args.front()) : 1;
if (cursor_in_region()) {
cursor.y = clamp(cursor.y - count, region_y.first, region_y.second);
} else {
cursor.y = clamp(cursor.y - count, window_y.first, window_y.second);
}
}
action cursor_down {
/* cursor down */
unsigned count = _flags.DECANM ? std::min(1u, _args.front()) : 1;
if (cursor_in_region()) {
cursor.y = clamp(cursor.y + count, region_y.first, region_y.second);
} else {
cursor.y = clamp(cursor.y + count, window_y.first, window_y.second);
}
}
action cursor_left {
/* cursor left */
unsigned count = _flags.DECANM ? std::min(1u, _args.front()) : 1;
cursor.x = clamp(cursor.x - count, window_x.first, window_x.second);
}
action cursor_right {
/* cursor right */
unsigned count = _flags.DECANM ? std::min(1u, _args.front()) : 1;
cursor.x = clamp(cursor.x + count, window_x.first, window_x.second);
}
action cup {
/* Cursor Position aka DCA */
/* todo - numbering of lines depends on DECOM */
unsigned y = _args.size() > 0 ? _args[0] : 0;
unsigned x = _args.size() > 1 ? _args[1] : 0;
if (x == 0) x = 1;
if (y == 0) y = 1;
if (_flags.DECOM) {
cursor.y = clamp(y - 1 + region_y.first, region_y.first, region_y.second);
cursor.x = clamp(x - 1, window_x.first, window_x.second);
} else {
cursor.y = clamp(y - 1, window_y.first, window_y.second);
cursor.x = clamp(x - 1, window_x.first, window_x.second);
}
}
action vt52_dca {
/* todo - how are invalid values handled? */
/* todo - DECOM supported? */
if (_flags.DECOM) {
cursor.y = clamp(_args[0] + region_y.first, region_y.first, region_y.second);
cursor.x = clamp(_args[1], window_x.first, window_x.second);
} else {
cursor.y = clamp(_args[0], window_y.first, window_y.second);
cursor.x = clamp(_args[1], window_x.first, window_x.second);
}
}
action stbm {
/* Set Top and Bottom Margins */
/* aka scrolling region */
unsigned top = _args.size() > 0 ? _args[0] : 0;
unsigned bottom = _args.size() > 1 ? _args[1] : 0;
if (top == 0) top = 1;
if (bottom == 0) bottom = 24;
if (top < bottom && bottom <= 24) {
--top;
window = iRect(0, top, 80, bottom - top);
// also home the cursor ... depeds on DECOM.
if (_flags.DECOM) cursor = window.origin;
else cursor = iPoint(0, 0);
region_y.first = top;
region_y.second = bottom - 1;
}
}
action reset_mode {
for (auto m : _args) {
switch (m) {
case 1: if (_private) _flags.DECCKM = 0; break;
case 2: if (_private) _flags.DECANM = 0; break;
case 3: if (_private) _flags.DECCOLM = 0; break;
case 4: if (_private) _flags.DECSCLM = 0; break;
case 5: if (_private) _flags.DECSCNM = 0; break;
case 6: if (_private) {
_flags.DECOM = 0;
/* also move to new origin */
cursor = iPoint(0, 0);
}
break;
case 7: if (_private) _flags.DECAWM = 0; break;
case 8: if (_private) _flags.DECARM = 0; break;
case 9: if (_private) _flags.DECINLM = 0; break;
case 20: if (!_private) _flags.LNM = 0; break;
}
}
}
action set_mode {
for (auto m : _args) {
switch (m) {
case 1: if (_private) _flags.DECCKM = 1; break;
case 2: if (_private) _flags.DECANM = 1; break;
case 3: if (_private) _flags.DECCOLM = 1; break;
case 4: if (_private) _flags.DECSCLM = 1; break;
case 5: if (_private) _flags.DECSCNM = 1; break;
case 6: if (_private) {
_flags.DECOM = 1;
/* also move to new origin */
cursor = window.origin;
}
case 7: if (_private) _flags.DECAWM = 1; break;
case 8: if (_private) _flags.DECARM = 1; break;
case 9: if (_private) _flags.DECINLM = 1; break;
case 20: if (!_private) _flags.LNM = 1; break;
}
}
}
action tbc {
/* TBC Tabulation Clear */
for (auto arg : _args) {
switch (arg) {
case 0: if (cursor.x <= 79) _tabs[cursor.x] = 0; break;
case 3: _tabs.reset(); break;
}
}
}
action dsr {
/* Device Status Report */
for (auto arg : _args) {
switch(arg) {
default: break;
case 5: /* report status */
output->write(ESC "[0n");
break;
case 6: { /* cursor position report */
char buffer[16];
iPoint pt = cursor;
pt.x++;
pt.y++;
if (_flags.DECOM) pt.y -= region_y.first;
snprintf(buffer, sizeof(buffer)-1, ESC "[%u;%uR", pt.y, pt.x);
output->write(buffer);
break;
}
}
}
}
action erase_line {
// mode 0 and 1 erase cursor.x
iRect r(0, cursor.y, 80, 1);
for (auto arg : _args) {
switch (arg) {
case 0: {
// x ... eos
r.origin.x = cursor.x;
r.size.width = 80 - cursor.x;
screen->eraseRect(r);
break;
}
case 1: {
// 0 ... x
r.origin.x = 0;
r.size.width = cursor.x + 1;
screen->eraseRect(r);
break;
}
case 2: {
// 0 .. eos
r.origin.x = 0;
r.size.width = 80;
screen->eraseRect(r);
break;
}
}
}
}
action erase_screen {
iRect r(0, cursor.y, 80, 1);
for (auto arg : _args) {
switch (arg) {
case 0: {
// x .. eos
r.origin.x = cursor.x;
r.size.width = 80 - cursor.x;
screen->eraseRect(r);
iRect tmp(0, cursor.y + 1, 80, 24 - cursor.y - 1);
screen->eraseRect(tmp);
}
case 1: {
// 0 ... x
// 0 ... x
r.origin.x = 0;
r.size.width = cursor.x + 1;
screen->eraseRect(r);
iRect tmp(0, 0, 80, cursor.y - 1);
screen->eraseRect(tmp);
break;
}
case 2: {
iRect tmp(0, 0, 80, 24);
screen->eraseRect(tmp);
break;
}
}
}
}
action vt52_erase_line {
iRect r(cursor.x, cursor.y, 80 - cursor.x, 1);
screen->eraseRect(r);
}
action vt52_erase_screen {
iRect r(cursor.x, cursor.y, 80 - cursor.x, 1);
screen->eraseRect(r);
r = iRect(0, cursor.y + 1, 80, 24 - cursor.y - 1);
screen->eraseRect(r);
}
action sgr {
/* Select Graphical Rendition */
for (auto arg : _args) {
switch(arg) {
case 0: _context.flags = Screen::FlagNormal; break;
case 1: _context.flags |= Screen::FlagBold; break;
case 4: _context.flags |= Screen::FlagUnderscore; break;
case 5: _context.flags |= Screen::FlagBlink; break;
case 7: _context.flags |= Screen::FlagInverse; break;
}
}
}
action sc {
/* Save Cursor */
_saved_context = _context;
}
# todo -- what if DECOM changes?
action rc {
_context.cursor = _saved_context.cursor;
_context.charset = _saved_context.charset;
_context.G0_charset = _saved_context.G0_charset;
_context.G1_charset = _saved_context.G1_charset;
}
control_codes = (
0x05 $answerback
| 0x07 ${ NSBeep(); }
| 0x08 ${ if (cursor.x) cursor.x--; }
| 0x09 ${ cursor.x = tab(cursor.x); }
| (0x0a | 0x0b | 0x0c) ${ if (_flags.LNM) cursor.x = 0; } $linefeed
| 0x0d ${ cursor.x = 0; }
| 0x0e ${ _context.charset = vt100_context::G1; }
| 0x0f ${ _context.charset = vt100_context::G0; }
| 0x11 ${ /* xon */ }
| 0x13 ${ /* xoff */ }
| cntrl - esc
);
args = (
';' ${ _args.push_back(0); }
| [0-9] ${ _args.back() *= 10; _args.back() += fc - '0'; }
)**
# >to(clear_args)
;
lbrace = (
'A' $cursor_up
| 'B' $cursor_down
| 'C' $cursor_right
| 'D' $cursor_left
| ('H' | 'f') $cup
| 'K' $erase_line
| 'J' $erase_screen
| 'm' $sgr
| 'r' $stbm
| 'g' $tbc
| 'h' $set_mode
| 'l' $reset_mode
| 'n' $dsr
| 'c' ${ if (_args.front() == 0) output->write(ESC "[?1;0c"); }
| 'y' ${ /* tests */ }
| 'q' ${ /* led lights */ }
| 'x' # DECREQTPARM
)
;
lparen = [AB012] ${
switch (fc) {
case 'A': _context.G0_charset = vt100_context::CharSet_A; break;
case 'B': _context.G0_charset = vt100_context::CharSet_B; break;
case '0': _context.G0_charset = vt100_context::CharSet_0; break;
case '1': _context.G0_charset = vt100_context::CharSet_1; break;
case '2': _context.G0_charset = vt100_context::CharSet_2; break;
}
};
rparen = [AB012] ${
switch (fc) {
case 'A': _context.G1_charset = vt100_context::CharSet_A; break;
case 'B': _context.G1_charset = vt100_context::CharSet_B; break;
case '0': _context.G1_charset = vt100_context::CharSet_0; break;
case '1': _context.G1_charset = vt100_context::CharSet_1; break;
case '2': _context.G1_charset = vt100_context::CharSet_2; break;
}
};
# #3 = DECDHL - Double-Height Line (top half)
# #4 = DECDHL - Double-Height Line (bottom half)
# #5 = DECSWL Single-width Line
# #6 = DECDWL Double-Width Line
pound = (
'3'
| '4'
| '5'
| '6'
| '7' # DECHCP - Hard Copy
| '8' ${
/* DECALN */
screen->fillScreen(char_info('E', 0));
}
);
dca_arg = any ${ _args.push_back((fc & 0x7f) - 32); } ;
escape_vt52 := (
'A' $cursor_up
| 'B' $cursor_down
| 'C' $cursor_right
| 'D' $cursor_left
| 'F' ${ _flags.VT52GM = 1; }
| 'G' ${ _flags.VT52GM = 0; }
| 'H' ${ cursor = window.origin; } # todo - DECOM?
| 'I' $reverse_linefeed
| 'J' $vt52_erase_screen
| 'K' $vt52_erase_line
| 'Y' ${ _args.clear(); } dca_arg dca_arg $vt52_dca
| 'Z' ${ output->write(ESC "/Z"); }
| '=' ${ _flags.DECKPAM = 1;}
| '>' ${ _flags.DECKPAM = 0; }
| '<' ${ _flags.DECANM = 1; }
)
@{ fgoto main; }
$err{ fgoto main; }
;
csi = '[' ${ _args.clear(); _args.push_back(0); _private = NO; };
private_flag = ('?' ${ _private = YES; })?;
escape := (
( control_codes | esc )**
| cancel ${ /* cancel */ fgoto main; }
| csi private_flag args lbrace
| '(' lparen
| ')' rparen
| '#' pound
| 'D' $linefeed
| 'E' ${cursor.x = 0; } $linefeed
| 'H' ${ if (cursor.x <= 79) _tabs[cursor.x] = 1; }
| 'M' $reverse_linefeed
| 'Z' ${ output->write(ESC "[?1;0c"); }
| '7' $sc
| '8' $rc
| '=' ${ _flags.DECKPAM = 1;}
| '>' ${ _flags.DECKPAM = 0; }
# | 'N'
# | 'O'
| 'c' ${ [self reset: YES]; /* should also clear the screen */ }
| '1' # DECG - graphic processor on (vt105?)
| '2' # DECG - graphic processor off (vt105?)
)
@{ fnext main; }
$err{ fgoto main; }
;
main := (
control_codes
| esc ${
if (_flags.DECANM) fgoto escape; else fgoto escape_vt52;
}
| 0x20 .. 0x7f $forward ${
screen->putc(fc, _context);
cursor.x++;
}
)**
$err{ fgoto main; }
;
write data;
}%%
@implementation VT100
+(void)load
{
[EmulatorManager registerClass: self];
}
-(id)init
{
self = [super init];
[self reset: YES];
return self;
}
+(NSString *)name
{
return @"VT100";
}
-(NSString *)name
{
return @"VT100";
}
-(const char *)termName
{
return "vt100";
}
-(BOOL)resizable
{
return NO;
}
-(struct winsize)defaultSize
{
struct winsize ws = { 24, 80, 0, 0};
return ws;
}
-(void)reset: (BOOL)hard
{
%% write init;
_args.clear();
_flags.DECANM = 1; // ansi/vt100 mode
_flags.DECARM = 0;
_flags.DECAWM = 1;
_flags.DECCKM = 0;
_flags.DECKPAM = 0;
//_flags.DECKPNM = 1;
_flags.DECCOLM = 0;
_flags.DECSCLM = 0;
_flags.DECSCNM = 0;
_flags.DECOM = 0;
_flags.DECINLM = 0;
_flags.LNM = 0;
_flags.VT52GM = 0;
if (hard) {
_context.cursor = iPoint(0,0);
_context.window = iRect(0, 0, 80, 24);
_tabs.reset();
_tabs[8] = true;
_tabs[16] = true;
_tabs[24] = true;
_tabs[32] = true;
_tabs[40] = true;
_tabs[48] = true;
_tabs[56] = true;
_tabs[64] = true;
_tabs[72] = true;
}
_context.flags = 0;
_context.charset = vt100_context::G0;
_context.G0_charset = vt100_context::CharSet_B;
_context.G1_charset = vt100_context::CharSet_B;
}
-(void)processData: (uint8_t *)data length: (size_t)length screen:(Screen *)screen output:(OutputChannel *)output
{
std::transform(data, data + length, data, [](uint8_t c){ return c & 0x7f; });
const uint8_t *eof = nullptr;
const uint8_t *p = data;
const uint8_t *pe = std::copy_if(data, data + length, data, [](uint8_t c){
if (c == 0 || c == 0x7f) return false;
return true;
});
if (p == pe) return;
iPoint &cursor = _context.cursor;
iRect &window = _context.window;
std::pair<unsigned, unsigned> window_x(0u, 80-1);
std::pair<unsigned, unsigned> window_y(0u, 24-1);
std::pair<unsigned, unsigned> region_y(window.minY(), window.maxY()-1);
auto cursor_in_region = [&](){
return cursor.y >= region_y.first && cursor.y <= region_y.second;
};
/* todo - vt100 rom sometimes tabs to 80, sometimes doesn't. */
/* difference being: tab, x < x wraps to next line? */
auto tab = [&](unsigned x) -> unsigned {
if (x >= 79) return x;
for (x = x + 1; x < 80; ++x) {
if (_tabs[x]) return x;
}
return 79; //?
};
%%write exec;
if (cursor.x == 80) screen->setCursor(iPoint(79, cursor.y));
else screen->setCursor(cursor);
}
static const char *RemapKey(unichar uc, NSEventModifierFlags flags, struct __vt100flags vt100flags)
{
auto DECKPAM = vt100flags.DECKPAM;
/*
DECKPAM Keypad Application Mode (DEC Private)
The auxiliary keypad keys will transmit control sequences as defined in Tables 3-7 and 3-8.
*/
auto DECCKM = vt100flags.DECCKM;
/*
DECCKM Cursor Keys Mode (DEC Private)
This is a private parameter applicable to set mode (SM) and reset mode (RM) control sequences. This mode is only effective when the terminal is in keypad application mode (see DECKPAM) and the ANSI/VT52 mode (DECANM) is set (see DECANM). Under these conditions, if the cursor key mode is reset, the four cursor function keys will send ANSI cursor control commands. If cursor key mode is set, the four cursor function keys will send application functions.
*/
auto LNM = vt100flags.LNM;
auto DECANM = vt100flags.DECANM;
/*
DECANM ANSI/VT52 Mode (DEC Private)
This is a private parameter applicable to set mode (SM) and reset mode (RM) control sequences. The reset state causes only VT52 compatible escape sequences to be interpreted and executed. The set state causes only ANSI "compatible" escape and control sequences to be interpreted and executed.
*/
#if 0
/* Device-independent bits found in event modifier flags */
typedef NS_OPTIONS(NSUInteger, NSEventModifierFlags) {
NSEventModifierFlagCapsLock = 1 << 16, // Set if Caps Lock key is pressed.
NSEventModifierFlagShift = 1 << 17, // Set if Shift key is pressed.
NSEventModifierFlagControl = 1 << 18, // Set if Control key is pressed.
NSEventModifierFlagOption = 1 << 19, // Set if Option or Alternate key is pressed.
NSEventModifierFlagCommand = 1 << 20, // Set if Command key is pressed.
NSEventModifierFlagNumericPad = 1 << 21, // Set if any key in the numeric keypad is pressed.
NSEventModifierFlagHelp = 1 << 22, // Set if the Help key is pressed.
NSEventModifierFlagFunction = 1 << 23, // Set if any function key is pressed.
// Used to retrieve only the device-independent modifier flags, allowing applications to mask off the device-dependent modifier flags, including event coalescing information.
NSEventModifierFlagDeviceIndependentFlagsMask = 0xffff0000UL
};
#endif
/*
NSEnterCharacter: keypad Enter Key
NSNewlineCharacter: \n
NSCarriageReturnCharacter: \r (main Enter/Return key)
*/
//NSLog(@"%c %02x %08lx", isprint(uc) ? uc : '.', uc, (unsigned long)flags);
if (DECKPAM && (flags & NSNumericPadKeyMask)) {
switch (uc) {
case '0': return DECANM ? ESC "Op" : ESC "?p";
case '1': return DECANM ? ESC "Oq" : ESC "?q";
case '2': return DECANM ? ESC "Or" : ESC "?r";
case '3': return DECANM ? ESC "Os" : ESC "?s";
case '4': return DECANM ? ESC "Ot" : ESC "?t";
case '5': return DECANM ? ESC "Ou" : ESC "?u";
case '6': return DECANM ? ESC "Ov" : ESC "?v";
case '7': return DECANM ? ESC "Ow" : ESC "?w";
case '8': return DECANM ? ESC "Ox" : ESC "?x";
case '9': return DECANM ? ESC "Oy" : ESC "?y";
case ',': return DECANM ? ESC "Ol" : ESC "?l";
case '-': return DECANM ? ESC "Om" : ESC "?m";
case '.': return DECANM ? ESC "On" : ESC "?n";
case NSEnterCharacter: return DECANM ? ESC "OM" : ESC "?M";
}
}
switch(uc) {
case NSUpArrowFunctionKey:
if (DECANM) return DECKPAM ? ESC "OA" : ESC "[A";
return ESC "A";
case NSDownArrowFunctionKey:
if (DECANM) return DECKPAM ? ESC "OB" : ESC "[B";
return ESC "B";
case NSRightArrowFunctionKey:
if (DECANM) return DECKPAM ? ESC "OC" : ESC "[C";
return ESC "C";
case NSLeftArrowFunctionKey:
if (DECANM) return DECKPAM ? ESC "OD" : ESC "[D";
return ESC "D";
case NSF1FunctionKey: return DECANM ? ESC "OP" : ESC "P";
case NSF2FunctionKey: return DECANM ? ESC "OQ" : ESC "Q";
case NSF3FunctionKey: return DECANM ? ESC "OR" : ESC "R";
case NSF4FunctionKey: return DECANM ? ESC "OS" : ESC "S";
/* return/enter key is CR */
case NSEnterCharacter: case NSNewlineCharacter: case NSCarriageReturnCharacter:
return LNM ? "\n\r" : "\r";
// NSDeleteCharacter IS 0x7f.
//case NSDeleteCharacter: return "\x7f"; //
}
return nullptr;
}
-(void)keyDown:(NSEvent *)event screen:(Screen *)screen output:(OutputChannel *)output
{
NSEventModifierFlags flags = [event modifierFlags];
NSString *chars = [event charactersIgnoringModifiers];
NSUInteger length = [chars length];
for (unsigned i = 0; i < length; ++i) {
unichar uc = [chars characterAtIndex: i];
const char *str = RemapKey(uc, flags, _flags);
if (str) {
output->write(str);
} else if (uc <= 0x7f) {
uint8_t c = uc;
if (flags & NSControlKeyMask) c = CTRL(c);
output->write(c);
}
}
}
@end

View File

@ -143,12 +143,12 @@ namespace {
| 0x09 $tab
| 0x0a $linefeed
| 0x0d ${ cursor.x = 0; }
| 0x0e when vt50 arg1 arg2 $dca
| 0x0e when vt50h arg1 arg2 $dca
| cntrl - esc
);
escape_codes = (
control_codes
| esc
escape_codes = control_codes* <: (
esc
| 'A' ${ if (cursor.y) cursor.y--; }
| 'B' when vt50h ${ if (cursor.y < window.maxY() -1) cursor.y++; }
| 'C' ${ if (cursor.x < window.maxX() -1) cursor.x++; }