191 changes: 174 additions & 17 deletions source/platform/terminal.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#include <internal/constmap.h>
#include <internal/constarr.h>
#include <internal/codepage.h>
#include <internal/win32con.h>
#include <internal/getenv.h>
#include <internal/base64.h>
#include <internal/utf8.h>
Expand Down Expand Up @@ -176,6 +177,78 @@ static bool keyFromLetter(uint letter, uint mod, KeyDownEvent &keyDown) noexcept
return true;
}

void GetChBuf::reject() noexcept
{
while (size)
unget();
}

// getNum, getInt: INVARIANT: the last non-digit read key (or -1)
// can be accessed with 'last()' and can also be ungetted.

bool GetChBuf::getNum(uint &result) noexcept
{
uint num = 0, digits = 0;
int k;
while ((k = get(true)) != -1 && '0' <= k && k <= '9')
{
num = 10 * num + (k - '0');
++digits;
}
if (digits)
return (result = num), true;
return false;
}

bool GetChBuf::getInt(int &result) noexcept
{
int num = 0, digits = 0, sign = 1;
int k = get(true);
if (k == '-')
{
sign = -1;
k = get(true);
}
while (k != -1 && '0' <= k && k <= '9')
{
num = 10 * num + (k - '0');
++digits;
k = get(true);
}
if (digits)
return (result = sign*num), true;
return false;
}

bool GetChBuf::readStr(TStringView str) noexcept
{
size_t origSize = size;
size_t i = 0;
while (i < str.size() && get() == str[i])
++i;
if (i == str.size())
return true;
while (origSize < size)
unget();
return false;
}

bool CSIData::readFrom(GetChBuf &buf) noexcept
// Pre: "\x1B[" has just been read.
{
length = 0;
for (uint i = 0; i < maxLength; ++i)
{
if (!buf.getNum(_val[i]))
_val[i] = UINT_MAX;
int k = buf.last();
if (k == -1) return false;
if ((terminator = (uint) k) != ';')
return (length = i + 1), true;
}
return false;
}

// The default mouse experience with Ncurses is not always good. To work around
// some issues, we request and parse mouse events manually.

Expand Down Expand Up @@ -209,6 +282,7 @@ void TermIO::keyModsOn(StdioCtl &io) noexcept
"\x1B[?2004h" // Enable bracketed paste.
"\x1B[>4;1m" // Enable modifyOtherKeys (XTerm).
"\x1B[>1u" // Disambiguate escape codes (Kitty).
"\x1B[?9001h" // Enable win32-input-mode (Conpty).
far2lEnableSeq
;
io.write(seq.data(), seq.size());
Expand All @@ -235,6 +309,7 @@ void TermIO::keyModsOff(StdioCtl &io, EventSource &source, InputState &state) no
{
TStringView seq = far2lPingSeq
far2lDisableSeq
"\x1B[?9001l" // Disable win32-input-mode (Conpty).
"\x1B[<u" // Restore previous keyboard mode (Kitty).
"\x1B[>4m" // Reset modifyOtherKeys (XTerm).
"\x1B[?2004l" // Disable bracketed paste.
Expand Down Expand Up @@ -307,10 +382,15 @@ ParseResult TermIO::parseEscapeSeq(GetChBuf &buf, TEvent &ev, InputState &state)
CSIData csi;
if (csi.readFrom(buf))
{
if (csi.terminator() == 'u')
return parseFixTermKey(csi, ev);
else
return parseCSIKey(csi, ev, state);
switch (csi.terminator)
{
case 'u':
return parseFixTermKey(csi, ev);
case '_':
return parseWin32InputModeKeyOrEscapeSeq(csi, buf.in, ev, state);
default:
return parseCSIKey(csi, ev, state);
}
}
break;
}
Expand Down Expand Up @@ -458,10 +538,10 @@ ParseResult TermIO::parseCSIKey(const CSIData &csi, TEvent &ev, InputState &stat
// https://invisible-island.net/xterm/xterm-function-keys.html
// https://invisible-island.net/xterm/ctlseqs/ctlseqs.html
{
uint terminator = csi.terminator();
uint terminator = csi.terminator;
if (csi.length == 1 && terminator == '~')
{
switch (csi.val[0])
switch (csi.getValue(0))
{
case 1: ev.keyDown = {{kbHome}}; break;
case 2: ev.keyDown = {{kbIns}}; break;
Expand Down Expand Up @@ -497,23 +577,23 @@ ParseResult TermIO::parseCSIKey(const CSIData &csi, TEvent &ev, InputState &stat
default: return Rejected;
}
}
else if (csi.length == 1 && csi.val[0] == 1)
else if (csi.length == 1 && csi.getValue(0) == 1)
{
if (!keyFromLetter(terminator, XTermModDefault, ev.keyDown))
return Rejected;
}
else if (csi.length == 2)
{
uint mod = csi.val[1];
if (csi.val[0] == 1)
uint mod = csi.getValue(1);
if (csi.getValue(0) == 1)
{
if (!keyFromLetter(terminator, mod, ev.keyDown))
return Rejected;
}
else if (terminator == '~')
{
ushort keyCode = 0;
switch (csi.val[0])
switch (csi.getValue(0))
{
case 2: keyCode = kbIns; break;
case 3: keyCode = kbDel; break;
Expand All @@ -534,16 +614,16 @@ ParseResult TermIO::parseCSIKey(const CSIData &csi, TEvent &ev, InputState &stat
case 29: keyCode = kbNoKey; break; // Menu key (XTerm).
default: return Rejected;
}
ev.keyDown = keyWithXTermMods(keyCode, csi.val[1]);
ev.keyDown = keyWithXTermMods(keyCode, csi.getValue(1));
}
else
return Rejected;
}
else if (csi.length == 3 && csi.val[0] == 27 && terminator == '~')
else if (csi.length == 3 && csi.getValue(0) == 27 && terminator == '~')
{
// XTerm's "modifyOtherKeys" mode.
uint key = csi.val[2];
uint mod = csi.val[1];
uint key = csi.getValue(2);
uint mod = csi.getValue(1);
if (!keyFromCodepoint(key, mod, ev.keyDown))
return Ignored;
}
Expand Down Expand Up @@ -571,11 +651,11 @@ ParseResult TermIO::parseFixTermKey(const CSIData &csi, TEvent &ev) noexcept
// http://www.leonerd.org.uk/hacks/fixterms/
{

if (csi.length < 1 || csi.terminator() != 'u')
if (csi.length < 1 || csi.terminator != 'u')
return Rejected;

uint key = csi.val[0];
uint mods = (csi.length > 1) ? max(csi.val[1], 1) : 1;
uint key = csi.getValue(0);
uint mods = (csi.length > 1) ? max(csi.getValue(1), 1) : 1;
if (keyFromCodepoint(key, mods, ev.keyDown))
{
ev.what = evKeyDown;
Expand Down Expand Up @@ -630,6 +710,83 @@ ParseResult TermIO::parseOSC(GetChBuf &buf, InputState &state) noexcept
return Ignored;
}

static ParseResult parseWin32InputModeKey(const CSIData &csi, TEvent &ev, InputState &state) noexcept
// https://github.com/microsoft/terminal/blob/main/doc/specs/%234999%20-%20Improved%20keyboard%20handling%20in%20Conpty.md
{
if (csi.terminator != '_')
return Rejected;

KEY_EVENT_RECORD kev;
kev.wVirtualKeyCode = (ushort) csi.getValue(0, 0);
kev.wVirtualScanCode = (ushort) csi.getValue(1, 0);
kev.uChar.UnicodeChar = (ushort) csi.getValue(2, 0);
kev.bKeyDown = (ushort) csi.getValue(3, 0);
kev.dwControlKeyState = (ushort) csi.getValue(4, 0);
kev.wRepeatCount = (ushort) csi.getValue(5, 1);

if (kev.bKeyDown && getWin32Key(kev, ev, state))
{
TermIO::normalizeKey(ev.keyDown);
return Accepted;
}
return Ignored;
}

// Due to issue https://github.com/microsoft/terminal/issues/15083, Conpty will
// emit ANSI escape sequences wrapped in win32-input-mode events. This class
// allows handling these sequences properly.

class Win32InputModeUnwrapper : public InputGetter
{
InputGetter &in;
InputState &state;

public:

Win32InputModeUnwrapper(InputGetter &aIn, InputState &aState) noexcept :
in(aIn), state(aState)
{
}

int get() noexcept override
{
GetChBuf buf(in);
CSIData csi;
TEvent ev {};
if ( buf.get() == '\x1B' && buf.get() == '['
&& csi.readFrom(buf) && csi.terminator == '_'
&& parseWin32InputModeKey(csi, ev, state) == Accepted
&& ev.keyDown.charScan.scanCode == 0
&& ev.keyDown.textLength == 1 )
return ev.keyDown.text[0];
buf.reject();
return -1;
}

void unget(int) noexcept override
{
// Do nothing. It is desirable not to reject win32-input-mode events,
// as that would just spill escape sequences into the input queue.
}
};

ParseResult TermIO::parseWin32InputModeKeyOrEscapeSeq(const CSIData &csi, InputGetter &in, TEvent &ev, InputState &state) noexcept
{
ParseResult res = parseWin32InputModeKey(csi, ev, state);
if (res == Accepted && ev.keyDown == 0x001B)
{
// We received the initiator of an escape sequence wrapped in
// win32-input-mode events.
Win32InputModeUnwrapper unwrapper(in, state);
GetChBuf buf(unwrapper);
res = parseEscapeSeq(buf, ev, state);
// Avoid propagating 'Rejected' because we have used a secondary GetChBuf.
if (res != Accepted)
res = Ignored;
}
return res;
}

static bool setOsc52Clipboard(StdioCtl &io, TStringView text, InputState &state) noexcept
{
TStringView prefix = "\x1B]52;;";
Expand Down
1 change: 1 addition & 0 deletions source/tvision/prntcnst.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ static const TConstant eventCodes[] =
NM(evMouseMove),
NM(evMouseAuto),
NM(evMouseWheel),
NM(evMouse),
NM(evKeyDown),
NM(evCommand),
NM(evBroadcast),
Expand Down
82 changes: 1 addition & 81 deletions test/platform/far2l.test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,91 +4,11 @@
#include <internal/far2l.h>

#include <test.h>
#include "terminal.test.h"

namespace tvision
{

class StrInputGetter : public InputGetter
{
TStringView str;
size_t i {0};

public:

StrInputGetter(TStringView aStr) noexcept :
str(aStr)
{
}

int get() noexcept override
{
return i < str.size() ? str[i++] : -1;
}

void unget(int) noexcept override
{
if (i > 0)
--i;
}
};

struct ParseResultEvent
{
ParseResult parseResult;
TEvent ev;
};

static bool operator==(const ParseResultEvent &a, const ParseResultEvent &b)
{
if (a.parseResult != b.parseResult)
return false;
if (a.parseResult == Ignored)
return true;
if (a.ev.what != b.ev.what)
return false;
if (a.ev.what == evNothing)
return true;
if (a.ev.what == evKeyDown)
return
a.ev.keyDown.keyCode == b.ev.keyDown.keyCode &&
a.ev.keyDown.controlKeyState == b.ev.keyDown.controlKeyState &&
a.ev.keyDown.getText() == b.ev.keyDown.getText();
abort();
}

static std::ostream &operator<<(std::ostream &os, const ParseResultEvent &p)
{
os << "{";
switch (p.parseResult)
{
case Rejected: os << "Rejected"; break;
case Accepted: os << "Accepted"; break;
case Ignored: os << "Ignored"; break;
}
os << ", {";
printEventCode(os, p.ev.what);
os << ", {{";
printKeyCode(os, p.ev.keyDown.keyCode);
os << "}, {";
printControlKeyState(os, p.ev.keyDown.controlKeyState);
os << "}, '" << p.ev.keyDown.getText() << "'}}";
return os;
}

constexpr static TEvent keyDownEv(ushort keyCode, ushort controlKeyState, TStringView text)
{
TEvent ev {};
ev.what = evKeyDown;
ev.keyDown.keyCode = keyCode;
ev.keyDown.controlKeyState = controlKeyState;
while (ev.keyDown.textLength <= sizeof(ev.keyDown.text) && ev.keyDown.textLength < text.size())
{
ev.keyDown.text[ev.keyDown.textLength] = text[ev.keyDown.textLength];
++ev.keyDown.textLength;
}
return ev;
}

const ushort
kbS = 0x1f73, kb9 = 0x0a39;

Expand Down
40 changes: 40 additions & 0 deletions test/platform/terminal.test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#include <internal/terminal.h>

#include <test.h>
#include "terminal.test.h"

static bool operator==(const KeyDownEvent &a, const KeyDownEvent &b)
{
Expand Down Expand Up @@ -50,4 +51,43 @@ TEST(TermIO, ShouldNormalizeKeys)
}
}

TEST(TermIO, ShouldReadWin32InputModeKeys)
{
static const TestCase<TStringView, ParseResultEvent> testCases[] =
{
{"[65;30;65;1;16;1_", {Accepted, keyDownEv(0x1e41, kbShift, "A")}},
{"[65;30;65;1;16_", {Accepted, keyDownEv(0x1e41, kbShift, "A")}},
{"[16;42;0;0;0;1_", {Ignored}},
{"[65;30;97;1;0;1_", {Accepted, keyDownEv(0x1e61, 0x0000, "a")}},
{"[65;30;97;1_", {Accepted, keyDownEv(0x1e61, 0x0000, "a")}},
{"[112;59;0;1;8;1_", {Accepted, keyDownEv(kbCtrlF1, kbLeftCtrl, "")}},
{"[112;59;;1;8_", {Accepted, keyDownEv(kbCtrlF1, kbLeftCtrl, "")}},
{"[112;59;0;0;8;1_", {Ignored}},
// https://github.com/microsoft/terminal/issues/15083
// SGR mouse event
{"[0;0;27;1;0;1_"
"\x1B[0;0;91;1;0;1_"
"\x1B[0;0;60;1;0;1_"
"\x1B[0;0;48;1;0;1_"
"\x1B[0;0;59;1;0;1_"
"\x1B[0;0;53;1;0;1_"
"\x1B[0;0;50;1;0;1_"
"\x1B[0;0;59;1;0;1_"
"\x1B[0;0;49;1;0;1_"
"\x1B[0;0;50;1;0;1_"
"\x1B[0;0;77;1;0;1_", {Accepted, mouseEv({51, 11}, 0x0000, 0x0000, mbLeftButton, 0x0000)}},
};

for (auto &testCase : testCases)
{
StrInputGetter in(testCase.input);
GetChBuf buf(in);
ParseResultEvent actual {};
InputState state {};
actual.parseResult = TermIO::parseEscapeSeq(buf, actual.ev, state);
expectResultMatches(actual, testCase);
EXPECT_EQ(in.bytesLeft(), 0);
}
}

} // namespace tvision
136 changes: 136 additions & 0 deletions test/platform/terminal.test.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
#ifndef TVISION_TERMINAL_TEST_H
#define TVISION_TERMINAL_TEST_H

#include <internal/terminal.h>

namespace tvision
{

class StrInputGetter : public InputGetter
{
TStringView str;
size_t i {0};

public:

StrInputGetter(TStringView aStr) noexcept :
str(aStr)
{
}

int get() noexcept override
{
return i < str.size() ? str[i++] : -1;
}

void unget(int) noexcept override
{
if (i > 0)
--i;
}

int bytesLeft() noexcept
{
return str.size() - i;
}
};

struct ParseResultEvent
{
ParseResult parseResult;
TEvent ev;
};

static bool operator==(const ParseResultEvent &a, const ParseResultEvent &b)
{
if (a.parseResult != b.parseResult)
return false;
if (a.parseResult == Ignored)
return true;
if (a.ev.what != b.ev.what)
return false;
if (a.ev.what == evNothing)
return true;
if (a.ev.what == evKeyDown)
return
a.ev.keyDown.keyCode == b.ev.keyDown.keyCode &&
a.ev.keyDown.controlKeyState == b.ev.keyDown.controlKeyState &&
a.ev.keyDown.getText() == b.ev.keyDown.getText();
if (a.ev.what == evMouse)
return
a.ev.mouse.where == b.ev.mouse.where &&
a.ev.mouse.eventFlags == b.ev.mouse.eventFlags &&
a.ev.mouse.controlKeyState == b.ev.mouse.controlKeyState &&
a.ev.mouse.buttons == b.ev.mouse.buttons &&
a.ev.mouse.wheel == b.ev.mouse.wheel;
abort();
}

inline std::ostream &operator<<(std::ostream &os, const ParseResultEvent &p)
{
os << "{";
switch (p.parseResult)
{
case Rejected: os << "Rejected"; break;
case Ignored: os << "Ignored"; break;
case Accepted:
{
os << "Accepted, {";
printEventCode(os, p.ev.what);
os << ", {";
if (p.ev.what == evKeyDown)
{
os << "{";
printKeyCode(os, p.ev.keyDown.keyCode);
os << "}, {";
printControlKeyState(os, p.ev.keyDown.controlKeyState);
os << "}, '" << p.ev.keyDown.getText() << "'";
}
else if (p.ev.what == evMouse)
{
os << "(" << p.ev.mouse.where.x << "," << p.ev.mouse.where.y << ")";
os << ", ";
printMouseEventFlags(os, p.ev.mouse.eventFlags);
os << ", ";
printControlKeyState(os, p.ev.mouse.controlKeyState);
os << ", ";
printMouseButtonState(os, p.ev.mouse.buttons);
os << ", ";
printMouseWheelState(os, p.ev.mouse.wheel);
}
os << "}}";
}
}
os << "}";
return os;
}

constexpr TEvent keyDownEv(ushort keyCode, ushort controlKeyState, TStringView text)
{
TEvent ev {};
ev.what = evKeyDown;
ev.keyDown.keyCode = keyCode;
ev.keyDown.controlKeyState = controlKeyState;
while (ev.keyDown.textLength <= sizeof(ev.keyDown.text) && ev.keyDown.textLength < text.size())
{
ev.keyDown.text[ev.keyDown.textLength] = text[ev.keyDown.textLength];
++ev.keyDown.textLength;
}
return ev;
}

constexpr TEvent mouseEv(TPoint where, ushort eventFlags, ushort controlKeyState, uchar buttons, uchar wheel)
{
TEvent ev {};
ev.what = evMouse;
ev.mouse.where = where;
ev.mouse.eventFlags = eventFlags;
ev.mouse.controlKeyState = controlKeyState;
ev.mouse.buttons = buttons;
ev.mouse.wheel = wheel;
return ev;
}

} // namespace tvision

#endif // TVISION_TERMINAL_TEST_H