This commit is contained in:
Leonard Hecker
2026-03-18 22:39:20 +01:00
parent 78f70111ae
commit 0090e2882e
8 changed files with 1302 additions and 583 deletions

View File

@@ -1,8 +1,6 @@
#include "pch.h"
#include "InputBuffer.h"
#pragma warning(disable : 4100)
void InputBuffer::write(std::string_view text)
{
m_buf.append(text);
@@ -10,18 +8,13 @@ void InputBuffer::write(std::string_view text)
bool InputBuffer::hasData() const noexcept
{
return m_readPos < m_buf.size();
return m_readPos < m_buf.size() || m_recordReadPos < m_records.size();
}
size_t InputBuffer::pendingEventCount() const noexcept
{
// Rough lower-bound: each byte in the buffer can generate at least 1 record,
// and we may already have pre-parsed records queued.
const auto queued = (m_records.size() > m_recordReadPos) ? (m_records.size() - m_recordReadPos) : 0;
const auto rawBytes = (m_buf.size() > m_readPos) ? (m_buf.size() - m_readPos) : 0;
// Each raw byte produces at most 2 records (key down + up), so this is
// an over-estimate. But for GetNumberOfConsoleInputEvents we need a
// lower bound that's non-zero when data is available.
return queued + rawBytes;
}
@@ -42,9 +35,7 @@ size_t InputBuffer::readInputRecords(INPUT_RECORD* dst, size_t maxRecords, bool
{
// Parse any new data from the raw buffer into m_records.
if (m_readPos < m_buf.size())
{
m_readPos = parse(m_readPos);
}
parseTokensToRecords();
const auto available = m_records.size() - m_recordReadPos;
const auto toCopy = std::min(available, maxRecords);
@@ -54,8 +45,6 @@ size_t InputBuffer::readInputRecords(INPUT_RECORD* dst, size_t maxRecords, bool
if (!peek)
{
m_recordReadPos += toCopy;
// If we've consumed all records, reset the vector.
if (m_recordReadPos == m_records.size())
{
m_records.clear();
@@ -65,9 +54,7 @@ size_t InputBuffer::readInputRecords(INPUT_RECORD* dst, size_t maxRecords, bool
}
if (!peek)
{
compact();
}
return toCopy;
}
@@ -82,7 +69,6 @@ void InputBuffer::flush()
void InputBuffer::compact()
{
// Only compact when we've consumed a meaningful prefix.
if (m_readPos > 4096 || (m_readPos > 0 && m_readPos == m_buf.size()))
{
m_buf.erase(0, m_readPos);
@@ -91,428 +77,294 @@ void InputBuffer::compact()
}
// ============================================================================
// VT Parser
// Token → INPUT_RECORD conversion
// ============================================================================
//
// Grammar (simplified):
// Ground:
// 0x1B → Escape
// 0x00..0x1A, 0x1C..0x7F → plain character (emit key event)
// 0x80..0xFF → UTF-8 lead byte (consume full codepoint, emit key event)
//
// Escape:
// '[' → CsiEntry
// 'O' → Ss3
// 0x20..0x7E → Alt+<char> (emit key event with LEFT_ALT_PRESSED)
// timeout/buf-end → bare ESC key
//
// CsiEntry:
// '0'..'9',';' → accumulate parameters
// 'A'..'Z','a'..'z','~','_' → dispatch CSI sequence
//
// Ss3:
// 'A'..'Z' → dispatch SS3 key
size_t InputBuffer::parse(size_t pos)
void InputBuffer::parseTokensToRecords()
{
while (pos < m_buf.size())
const auto input = std::string_view{ m_buf.data() + m_readPos, m_buf.size() - m_readPos };
auto stream = m_parser.parse(input);
VtToken token;
while (stream.next(token))
{
const auto consumed = parseGround(pos);
if (consumed == 0)
break; // Need more data.
pos += consumed;
}
return pos;
}
size_t InputBuffer::parseGround(size_t pos)
{
const auto ch = static_cast<uint8_t>(m_buf[pos]);
if (ch == 0x1B) // ESC
{
// Look ahead to decide if this is ESC-prefix or bare ESC.
if (pos + 1 >= m_buf.size())
switch (token.type)
{
// TODO: ESC timeout for out-of-proc hosts (e.g. sshd).
// For now, treat a lone ESC at the end of the buffer as a bare ESC key.
// In-proc hosts always deliver complete sequences, so this is fine.
ParsedKey key;
key.vk = VK_ESCAPE;
key.scanCode = vkToScanCode(VK_ESCAPE);
key.ch = 0x1B;
emitKey(key);
return 1;
}
const auto next = static_cast<uint8_t>(m_buf[pos + 1]);
if (next == '[')
{
// ESC [ → CSI
const auto consumed = parseCsi(pos + 2);
if (consumed == 0)
return 0; // Incomplete CSI, need more data.
return 2 + consumed;
}
if (next == 'O')
{
// ESC O → SS3
const auto consumed = parseSs3(pos + 2);
if (consumed == 0)
return 0; // Incomplete SS3, need more data.
return 2 + consumed;
}
if (next >= 0x20 && next <= 0x7E)
{
// Alt+character.
ParsedKey key;
key.ch = static_cast<wchar_t>(next);
key.vk = LOBYTE(VkKeyScanW(key.ch));
key.scanCode = vkToScanCode(key.vk);
key.modifiers = LEFT_ALT_PRESSED;
emitKey(key);
return 2;
}
// ESC followed by something unexpected — just emit bare ESC.
ParsedKey key;
key.vk = VK_ESCAPE;
key.scanCode = vkToScanCode(VK_ESCAPE);
key.ch = 0x1B;
emitKey(key);
return 1;
}
if (ch < 0x20) // C0 control characters.
{
ParsedKey key;
key.ch = static_cast<wchar_t>(ch);
switch (ch)
{
case '\r': // Enter
key.vk = VK_RETURN;
key.scanCode = vkToScanCode(VK_RETURN);
key.ch = L'\r';
case VtToken::Text:
handleText(token.payload);
break;
case '\n': // Ctrl+Enter or LF
key.vk = VK_RETURN;
key.scanCode = vkToScanCode(VK_RETURN);
key.ch = L'\n';
key.modifiers = LEFT_CTRL_PRESSED;
case VtToken::Ctrl:
handleCtrl(token.ch);
break;
case '\t': // Tab
key.vk = VK_TAB;
key.scanCode = vkToScanCode(VK_TAB);
key.ch = L'\t';
case VtToken::Esc:
handleEsc(token.ch);
break;
case '\b': // Backspace
key.vk = VK_BACK;
key.scanCode = vkToScanCode(VK_BACK);
key.ch = L'\b';
case VtToken::SS3:
handleSs3(token.ch);
break;
case 0x00: // Ctrl+Space or Ctrl+@
key.vk = VK_SPACE;
key.scanCode = vkToScanCode(VK_SPACE);
key.ch = L'\0';
key.modifiers = LEFT_CTRL_PRESSED;
case VtToken::Csi:
handleCsi(*token.csi);
break;
default:
// Ctrl+A..Ctrl+Z: ch = 0x01..0x1A
if (ch >= 0x01 && ch <= 0x1A)
{
key.ch = static_cast<wchar_t>(ch);
key.vk = static_cast<WORD>('A' + ch - 1);
key.scanCode = vkToScanCode(key.vk);
key.modifiers = LEFT_CTRL_PRESSED;
}
// Osc/Dcs — irrelevant for input records, skip.
break;
}
emitKey(key);
return 1;
}
if (ch == 0x7F) // DEL — treat as backspace.
// Advance m_readPos by what the parser consumed.
m_readPos += stream.offset();
}
void InputBuffer::handleText(std::string_view text)
{
// Decode UTF-8 codepoints and emit key events.
const auto* bytes = reinterpret_cast<const uint8_t*>(text.data());
size_t i = 0;
while (i < text.size())
{
ParsedKey key;
uint32_t cp;
size_t seqLen;
const auto b = bytes[i];
if (b < 0x80) { cp = b; seqLen = 1; }
else if ((b & 0xE0) == 0xC0) { cp = b & 0x1F; seqLen = 2; }
else if ((b & 0xF0) == 0xE0) { cp = b & 0x0F; seqLen = 3; }
else if ((b & 0xF8) == 0xF0) { cp = b & 0x07; seqLen = 4; }
else { cp = 0xFFFD; seqLen = 1; i++; continue; }
if (i + seqLen > text.size()) break;
for (size_t j = 1; j < seqLen; j++)
{
const auto cont = bytes[i + j];
if ((cont & 0xC0) != 0x80) { cp = 0xFFFD; break; }
cp = (cp << 6) | (cont & 0x3F);
}
i += seqLen;
// Emit one or two INPUT_RECORDs (surrogate pair for supplementary plane).
if (cp <= 0xFFFF)
{
ParsedKey key;
key.ch = static_cast<wchar_t>(cp);
key.vk = LOBYTE(VkKeyScanW(key.ch));
if (key.vk == 0xFF) key.vk = 0;
key.scanCode = vkToScanCode(key.vk);
emitKey(key);
}
else if (cp <= 0x10FFFF)
{
ParsedKey key;
key.ch = static_cast<wchar_t>(0xD800 + ((cp - 0x10000) >> 10));
emitKey(key);
key.ch = static_cast<wchar_t>(0xDC00 + ((cp - 0x10000) & 0x3FF));
emitKey(key);
}
}
}
void InputBuffer::handleCtrl(char ch)
{
ParsedKey key;
switch (static_cast<uint8_t>(ch))
{
case '\r':
key.vk = VK_RETURN;
key.scanCode = vkToScanCode(VK_RETURN);
key.ch = L'\r';
break;
case '\n':
key.vk = VK_RETURN;
key.scanCode = vkToScanCode(VK_RETURN);
key.ch = L'\n';
key.modifiers = LEFT_CTRL_PRESSED;
break;
case '\t':
key.vk = VK_TAB;
key.scanCode = vkToScanCode(VK_TAB);
key.ch = L'\t';
break;
case '\b':
key.vk = VK_BACK;
key.scanCode = vkToScanCode(VK_BACK);
key.ch = L'\b';
emitKey(key);
return 1;
}
// ASCII printable or UTF-8 multi-byte sequence.
// Decode one UTF-8 codepoint.
size_t seqLen = 1;
uint32_t codepoint = ch;
if (ch >= 0x80)
{
if ((ch & 0xE0) == 0xC0) { seqLen = 2; codepoint = ch & 0x1F; }
else if ((ch & 0xF0) == 0xE0) { seqLen = 3; codepoint = ch & 0x0F; }
else if ((ch & 0xF8) == 0xF0) { seqLen = 4; codepoint = ch & 0x07; }
else { seqLen = 1; codepoint = 0xFFFD; } // Invalid lead byte.
if (pos + seqLen > m_buf.size())
return 0; // Incomplete codepoint, need more data.
for (size_t i = 1; i < seqLen; i++)
break;
case 0x7F:
key.vk = VK_BACK;
key.scanCode = vkToScanCode(VK_BACK);
key.ch = L'\b';
break;
case 0x00:
key.vk = VK_SPACE;
key.scanCode = vkToScanCode(VK_SPACE);
key.ch = L'\0';
key.modifiers = LEFT_CTRL_PRESSED;
break;
default:
if (ch >= 0x01 && ch <= 0x1A)
{
const auto cont = static_cast<uint8_t>(m_buf[pos + i]);
if ((cont & 0xC0) != 0x80)
{
codepoint = 0xFFFD;
seqLen = i; // Consume up to the bad byte.
break;
}
codepoint = (codepoint << 6) | (cont & 0x3F);
key.ch = static_cast<wchar_t>(ch);
key.vk = static_cast<WORD>('A' + ch - 1);
key.scanCode = vkToScanCode(key.vk);
key.modifiers = LEFT_CTRL_PRESSED;
}
break;
}
// Emit one or two INPUT_RECORDs (surrogate pair for codepoints > 0xFFFF).
if (codepoint <= 0xFFFF)
emitKey(key);
}
void InputBuffer::handleEsc(char ch)
{
ParsedKey key;
if (ch == '\0')
{
ParsedKey key;
key.ch = static_cast<wchar_t>(codepoint);
// Bare ESC (timeout or end of buffer).
key.vk = VK_ESCAPE;
key.scanCode = vkToScanCode(VK_ESCAPE);
key.ch = 0x1B;
}
else if (ch >= 0x20 && ch <= 0x7E)
{
// Alt+character.
key.ch = static_cast<wchar_t>(ch);
key.vk = LOBYTE(VkKeyScanW(key.ch));
if (key.vk == 0xFF) key.vk = 0; // No VK mapping.
key.scanCode = vkToScanCode(key.vk);
emitKey(key);
}
else if (codepoint <= 0x10FFFF)
{
// Surrogate pair.
const auto hi = static_cast<wchar_t>(0xD800 + ((codepoint - 0x10000) >> 10));
const auto lo = static_cast<wchar_t>(0xDC00 + ((codepoint - 0x10000) & 0x3FF));
ParsedKey key;
key.ch = hi;
emitKey(key);
key.ch = lo;
emitKey(key);
key.modifiers = LEFT_ALT_PRESSED;
}
else
{
// Invalid codepoint → U+FFFD.
ParsedKey key;
key.ch = 0xFFFD;
emitKey(key);
// Unexpected — emit bare ESC.
key.vk = VK_ESCAPE;
key.scanCode = vkToScanCode(VK_ESCAPE);
key.ch = 0x1B;
}
return seqLen;
emitKey(key);
}
size_t InputBuffer::parseCsi(size_t pos)
void InputBuffer::handleSs3(char ch)
{
// Parse parameters and find the final byte.
const auto paramEnd = parseCsiParams(pos);
if (paramEnd >= m_buf.size())
return 0; // Incomplete, need more data.
static constexpr WORD keypadLut[] = {
VK_UP, // A
VK_DOWN, // B
VK_RIGHT,// C
VK_LEFT, // D
0, // E
VK_END, // F
0, // G
VK_HOME, // H
};
const auto finalByte = static_cast<uint8_t>(m_buf[paramEnd]);
const auto totalConsumed = paramEnd - pos + 1;
ParsedKey key;
key.modifiers = ENHANCED_KEY;
if (ch >= 'A' && ch <= 'H')
{
key.vk = keypadLut[ch - 'A'];
if (key.vk == 0) return;
key.scanCode = vkToScanCode(key.vk);
}
else if (ch >= 'P' && ch <= 'S')
{
key.vk = static_cast<WORD>(VK_F1 + (ch - 'P'));
key.scanCode = vkToScanCode(key.vk);
}
else
{
return; // Unknown SS3.
}
emitKey(key);
}
void InputBuffer::handleCsi(const VtCsi& csi)
{
// Win32 Input Mode: CSI Vk ; Sc ; Uc ; Kd ; Cs ; Rc _
if (finalByte == '_')
if (csi.finalByte == '_' && csi.paramCount >= 4)
{
if (m_paramCount >= 4) // At minimum: Vk, Sc, Uc, Kd
{
ParsedKey key;
key.vk = static_cast<WORD>(m_params[0]);
key.scanCode = static_cast<WORD>(m_params[1]);
key.ch = static_cast<wchar_t>(m_params[2]);
key.keyDown = m_params[3] != 0;
key.modifiers = (m_paramCount >= 5) ? static_cast<DWORD>(m_params[4]) : 0;
key.repeatCount = (m_paramCount >= 6) ? static_cast<WORD>(m_params[5]) : 1;
if (key.repeatCount == 0)
key.repeatCount = 1;
key.isW32IM = true;
emitKey(key);
}
return totalConsumed;
ParsedKey key;
key.vk = static_cast<WORD>(csi.params[0]);
key.scanCode = static_cast<WORD>(csi.params[1]);
key.ch = static_cast<wchar_t>(csi.params[2]);
key.keyDown = csi.params[3] != 0;
key.modifiers = (csi.paramCount >= 5) ? static_cast<DWORD>(csi.params[4]) : 0;
key.repeatCount = (csi.paramCount >= 6) ? static_cast<WORD>(csi.params[5]) : 1;
if (key.repeatCount == 0) key.repeatCount = 1;
key.isW32IM = true;
emitKey(key);
return;
}
// Cursor keys and simple CSI sequences: CSI [params] <letter>
if (finalByte >= 'A' && finalByte <= 'Z')
// Cursor keys: CSI [1;mod] A-H
static constexpr WORD keypadLut[] = {
VK_UP, VK_DOWN, VK_RIGHT, VK_LEFT, 0, VK_END, 0, VK_HOME,
};
if (csi.finalByte >= 'A' && csi.finalByte <= 'H')
{
WORD vk = 0;
switch (finalByte)
{
case 'A': vk = VK_UP; break;
case 'B': vk = VK_DOWN; break;
case 'C': vk = VK_RIGHT; break;
case 'D': vk = VK_LEFT; break;
case 'H': vk = VK_HOME; break;
case 'F': vk = VK_END; break;
case 'P': vk = VK_F1; break;
case 'Q': vk = VK_F2; break;
case 'R': vk = VK_F3; break;
case 'S': vk = VK_F4; break;
case 'Z': // Shift+Tab (CSI Z = "backtab")
{
ParsedKey key;
key.vk = VK_TAB;
key.scanCode = vkToScanCode(VK_TAB);
key.ch = L'\t';
key.modifiers = SHIFT_PRESSED;
emitKey(key);
return totalConsumed;
}
default:
// Unknown CSI sequence — discard.
return totalConsumed;
}
if (vk != 0)
{
ParsedKey key;
key.vk = vk;
key.scanCode = vkToScanCode(vk);
key.modifiers = ENHANCED_KEY;
// Modifier in second parameter: CSI 1;{mod} A
if (m_paramCount >= 2)
key.modifiers |= vtModifierToControlKeyState(m_params[1]);
emitKey(key);
}
return totalConsumed;
}
// Generic keys: CSI {num} ~ (Insert, Delete, PgUp, PgDn, F5F12)
if (finalByte == '~' && m_paramCount >= 1)
{
WORD vk = 0;
switch (m_params[0])
{
case 1: vk = VK_HOME; break;
case 2: vk = VK_INSERT; break;
case 3: vk = VK_DELETE; break;
case 4: vk = VK_END; break;
case 5: vk = VK_PRIOR; break; // PageUp
case 6: vk = VK_NEXT; break; // PageDown
case 15: vk = VK_F5; break;
case 17: vk = VK_F6; break;
case 18: vk = VK_F7; break;
case 19: vk = VK_F8; break;
case 20: vk = VK_F9; break;
case 21: vk = VK_F10; break;
case 23: vk = VK_F11; break;
case 24: vk = VK_F12; break;
default:
return totalConsumed; // Unknown — discard.
}
const auto vk = keypadLut[csi.finalByte - 'A'];
if (vk == 0) return;
ParsedKey key;
key.vk = vk;
key.scanCode = vkToScanCode(vk);
key.modifiers = ENHANCED_KEY;
// Modifier in second parameter: CSI {num};{mod} ~
if (m_paramCount >= 2)
key.modifiers |= vtModifierToControlKeyState(m_params[1]);
if (csi.paramCount >= 2)
key.modifiers |= vtModifierToControlKeyState(csi.params[1]);
emitKey(key);
return totalConsumed;
return;
}
// Shift+Tab: CSI Z
if (csi.finalByte == 'Z')
{
ParsedKey key;
key.vk = VK_TAB;
key.scanCode = vkToScanCode(VK_TAB);
key.ch = L'\t';
key.modifiers = SHIFT_PRESSED;
emitKey(key);
return;
}
// Generic keys: CSI {num} [;mod] ~
if (csi.finalByte == '~' && csi.paramCount >= 1)
{
static constexpr struct { uint16_t param; WORD vk; } genericLut[] = {
{ 1, VK_HOME }, { 2, VK_INSERT }, { 3, VK_DELETE },
{ 4, VK_END }, { 5, VK_PRIOR }, { 6, VK_NEXT },
{ 15, VK_F5 }, { 17, VK_F6 }, { 18, VK_F7 },
{ 19, VK_F8 }, { 20, VK_F9 }, { 21, VK_F10 },
{ 23, VK_F11 }, { 24, VK_F12 },
};
for (const auto& entry : genericLut)
{
if (csi.params[0] == entry.param)
{
ParsedKey key;
key.vk = entry.vk;
key.scanCode = vkToScanCode(entry.vk);
key.modifiers = ENHANCED_KEY;
if (csi.paramCount >= 2)
key.modifiers |= vtModifierToControlKeyState(csi.params[1]);
emitKey(key);
return;
}
}
}
// Unrecognised CSI — discard.
return totalConsumed;
}
size_t InputBuffer::parseSs3(size_t pos)
{
if (pos >= m_buf.size())
return 0; // Need more data.
const auto finalByte = static_cast<uint8_t>(m_buf[pos]);
if (finalByte >= 'A' && finalByte <= 'Z')
{
WORD vk = 0;
switch (finalByte)
{
case 'A': vk = VK_UP; break;
case 'B': vk = VK_DOWN; break;
case 'C': vk = VK_RIGHT; break;
case 'D': vk = VK_LEFT; break;
case 'H': vk = VK_HOME; break;
case 'F': vk = VK_END; break;
case 'P': vk = VK_F1; break;
case 'Q': vk = VK_F2; break;
case 'R': vk = VK_F3; break;
case 'S': vk = VK_F4; break;
default:
return 1; // Unknown — discard.
}
ParsedKey key;
key.vk = vk;
key.scanCode = vkToScanCode(vk);
key.modifiers = ENHANCED_KEY;
emitKey(key);
return 1;
}
// Unknown SS3 sequence — discard.
return 1;
}
size_t InputBuffer::parseCsiParams(size_t pos)
{
m_paramCount = 0;
memset(m_params, 0, sizeof(m_params));
int current = 0;
bool hasDigit = false;
while (pos < m_buf.size())
{
const auto ch = static_cast<uint8_t>(m_buf[pos]);
if (ch >= '0' && ch <= '9')
{
current = current * 10 + (ch - '0');
hasDigit = true;
pos++;
continue;
}
if (ch == ';')
{
if (m_paramCount < MaxParams)
m_params[m_paramCount++] = current;
current = 0;
hasDigit = false;
pos++;
continue;
}
// Final byte or intermediate byte — stop parameter parsing.
// Store the last accumulated parameter.
if (hasDigit || m_paramCount > 0)
{
if (m_paramCount < MaxParams)
m_params[m_paramCount++] = current;
}
return pos; // pos now points at the final byte.
}
// Ran off the end of the buffer — incomplete.
return pos;
}
void InputBuffer::emitKey(const ParsedKey& key)
{
if (key.isW32IM)
{
// W32IM: emit exactly one record as specified.
INPUT_RECORD rec{};
rec.EventType = KEY_EVENT;
rec.Event.KeyEvent.bKeyDown = key.keyDown ? TRUE : FALSE;
@@ -525,7 +377,6 @@ void InputBuffer::emitKey(const ParsedKey& key)
return;
}
// Standard path: emit a key-down and key-up pair.
INPUT_RECORD down{};
down.EventType = KEY_EVENT;
down.Event.KeyEvent.bKeyDown = TRUE;
@@ -546,13 +397,9 @@ WORD InputBuffer::vkToScanCode(WORD vk)
return static_cast<WORD>(MapVirtualKeyW(vk, MAPVK_VK_TO_VSC));
}
DWORD InputBuffer::vtModifierToControlKeyState(int vtMod)
DWORD InputBuffer::vtModifierToControlKeyState(uint16_t vtMod)
{
// VT modifier parameter is 1-based: value = 1 + (flags).
// Bit 0 = Shift, Bit 1 = Alt, Bit 2 = Ctrl.
if (vtMod <= 1)
return 0;
if (vtMod <= 1) return 0;
const auto flags = vtMod - 1;
DWORD state = 0;
if (flags & 1) state |= SHIFT_PRESSED;

View File

@@ -1,120 +1,85 @@
#pragma once
#include <string>
#include <string_view>
#include <vector>
// InputBuffer: Text-based input buffer with an integrated VT parser.
//
// The hosting terminal writes UTF-8 VT sequences via write(). Consumers
// (console API handlers) dequeue data in one of two forms:
//
// 1. As raw VT text (ENABLE_VIRTUAL_TERMINAL_INPUT is set):
// readRawText() returns the raw bytes 1:1.
//
// 2. As INPUT_RECORDs (ENABLE_VIRTUAL_TERMINAL_INPUT is NOT set):
// readInputRecords() parses VT sequences on-demand and converts them
// to key events. The parser recognises:
// - Win32InputMode (W32IM, CSI ... _) for full INPUT_RECORD fidelity
// - Standard VT520 cursor/function keys (CSI AD, CSI ~, SS3)
// - Plain text (each grapheme cluster → key down + key up pair)
//
// The parser is intentionally minimal — just enough to cover what terminals
// actually send. It does NOT handle mouse, focus, or DCS sequences since
// those are irrelevant for the input record path.
//
// Thread safety: NOT thread-safe. All calls must be serialized by the caller
// (Server's message loop + WriteInput are on the same thread or the
// caller holds the console lock).
class InputBuffer
{
public:
InputBuffer() = default;
// Append UTF-8 text to the input buffer.
void write(std::string_view text);
// True if there is any data available for reading.
bool hasData() const noexcept;
// Returns the number of pending INPUT_RECORDs.
// This is an estimate — it counts the *minimum* number of records
// currently parseable from the buffer.
size_t pendingEventCount() const noexcept;
// Read raw VT text (for ENABLE_VIRTUAL_TERMINAL_INPUT mode).
// Returns the number of bytes copied. `dst` is the output buffer
// of `dstCapacity` bytes.
size_t readRawText(char* dst, size_t dstCapacity);
// Generate INPUT_RECORDs from the buffer (for legacy mode).
// Fills `dst` with up to `maxRecords` records.
// Returns the number of records written.
// If `peek` is true, the buffer position is not advanced.
size_t readInputRecords(INPUT_RECORD* dst, size_t maxRecords, bool peek = false);
// Discard all buffered data.
void flush();
private:
// The VT parser state machine.
enum class ParserState
{
Ground, // Normal text.
Escape, // After ESC.
CsiEntry, // After ESC [, collecting parameters.
Ss3, // After ESC O.
};
// A parsed input event (intermediate form between VT and INPUT_RECORD).
struct ParsedKey
{
WORD vk = 0;
WORD scanCode = 0;
wchar_t ch = 0;
DWORD modifiers = 0;
WORD repeatCount = 1;
bool keyDown = true;
bool isW32IM = false; // If true, emit exactly as specified (no synthetic up/down).
};
// Parse as many events as possible from m_buf starting at m_readPos.
// Appends to m_records. Returns the new read position.
size_t parse(size_t pos);
// Parse helpers — each returns the number of bytes consumed (0 = need more data).
size_t parseGround(size_t pos);
size_t parseCsi(size_t pos);
size_t parseSs3(size_t pos);
// CSI parameter parsing. Parses semicolon-separated decimal integers
// starting at `pos`. Returns the position after the last parameter digit.
// Populates m_params and m_paramCount.
size_t parseCsiParams(size_t pos);
// Emit an INPUT_RECORD pair (key down + key up) into m_records.
void emitKey(const ParsedKey& key);
// Map a VK + modifiers to a scan code via MapVirtualKey.
static WORD vkToScanCode(WORD vk);
// Map VT modifier parameter (1-based) to dwControlKeyState flags.
static DWORD vtModifierToControlKeyState(int vtMod);
// Compact the buffer: discard consumed bytes.
void compact();
// Raw byte buffer. Text appended by write(), consumed by read*/parse.
std::string m_buf;
size_t m_readPos = 0;
// Pre-parsed INPUT_RECORD queue. Filled by parse(), drained by readInputRecords().
std::vector<INPUT_RECORD> m_records;
size_t m_recordReadPos = 0;
// CSI parameter scratch space.
static constexpr size_t MaxParams = 8;
int m_params[MaxParams]{};
size_t m_paramCount = 0;
};
#pragma once
#include <string>
#include <string_view>
#include <vector>
#include "VtParser.h"
// InputBuffer: Text-based input buffer with an integrated VT parser.
//
// The hosting terminal writes UTF-8 VT sequences via write(). Consumers
// (console API handlers) dequeue data in one of two forms:
//
// 1. As raw VT text (ENABLE_VIRTUAL_TERMINAL_INPUT is set):
// readRawText() returns the raw bytes 1:1.
//
// 2. As INPUT_RECORDs (ENABLE_VIRTUAL_TERMINAL_INPUT is NOT set):
// readInputRecords() uses VtParser to tokenize sequences on-demand
// and converts them to key events:
// - Win32InputMode (W32IM, CSI ... _) for full INPUT_RECORD fidelity
// - Standard VT520 cursor/function keys (CSI A-D, CSI ~, SS3)
// - Plain text (each codepoint -> key down + key up pair)
//
// Thread safety: NOT thread-safe. All calls must be serialized by the caller.
class InputBuffer
{
public:
InputBuffer() = default;
// Append UTF-8 text to the input buffer.
void write(std::string_view text);
// True if there is any data available for reading.
bool hasData() const noexcept;
// Returns the number of pending INPUT_RECORDs (rough estimate).
size_t pendingEventCount() const noexcept;
// Read raw VT text (for ENABLE_VIRTUAL_TERMINAL_INPUT mode).
size_t readRawText(char* dst, size_t dstCapacity);
// Generate INPUT_RECORDs from the buffer (for legacy mode).
size_t readInputRecords(INPUT_RECORD* dst, size_t maxRecords, bool peek = false);
// Discard all buffered data.
void flush();
private:
struct ParsedKey
{
WORD vk = 0;
WORD scanCode = 0;
wchar_t ch = 0;
DWORD modifiers = 0;
WORD repeatCount = 1;
bool keyDown = true;
bool isW32IM = false;
};
// Convert VtTokens into INPUT_RECORDs.
void parseTokensToRecords();
// Token interpretation helpers.
void handleText(std::string_view text);
void handleCtrl(char ch);
void handleEsc(char ch);
void handleSs3(char ch);
void handleCsi(const VtCsi& csi);
void emitKey(const ParsedKey& key);
static WORD vkToScanCode(WORD vk);
static DWORD vtModifierToControlKeyState(uint16_t vtMod);
void compact();
std::string m_buf;
size_t m_readPos = 0;
VtParser m_parser;
std::vector<INPUT_RECORD> m_records;
size_t m_recordReadPos = 0;
};

300
src/conpty/VtParser.cpp Normal file
View File

@@ -0,0 +1,300 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
#include "pch.h"
#include "VtParser.h"
bool VtParser::hasEscTimeout() const noexcept
{
return m_state == State::Esc;
}
VtParser::Stream VtParser::parse(std::string_view input) noexcept
{
return Stream{ this, input };
}
// Decode one UTF-8 codepoint from the input at the current offset.
// Advances m_off past the consumed bytes. Returns U+0000 at end-of-input.
char32_t VtParser::Stream::nextChar()
{
const auto* bytes = reinterpret_cast<const uint8_t*>(m_input.data());
const auto len = m_input.size();
if (m_off >= len)
return U'\0';
const auto b0 = bytes[m_off];
// ASCII fast path.
if (b0 < 0x80)
{
m_off++;
return static_cast<char32_t>(b0);
}
// Determine sequence length and initial bits.
size_t seqLen;
char32_t cp;
if ((b0 & 0xE0) == 0xC0) { seqLen = 2; cp = b0 & 0x1F; }
else if ((b0 & 0xF0) == 0xE0) { seqLen = 3; cp = b0 & 0x0F; }
else if ((b0 & 0xF8) == 0xF0) { seqLen = 4; cp = b0 & 0x07; }
else { m_off++; return U'\xFFFD'; } // Invalid lead byte.
if (m_off + seqLen > len)
{
// Incomplete codepoint at end of input — consume what we have.
m_off = len;
return U'\xFFFD';
}
for (size_t i = 1; i < seqLen; i++)
{
const auto cont = bytes[m_off + i];
if ((cont & 0xC0) != 0x80)
{
m_off += i;
return U'\xFFFD';
}
cp = (cp << 6) | (cont & 0x3F);
}
m_off += seqLen;
return cp;
}
bool VtParser::Stream::next(VtToken& out)
{
const auto* bytes = reinterpret_cast<const uint8_t*>(m_input.data());
const auto len = m_input.size();
// If the previous input ended with an escape character, and we're called
// with empty input (timeout fired), return the bare ESC.
if (len == 0 && m_parser->m_state == State::Esc)
{
m_parser->m_state = State::Ground;
out.type = VtToken::Esc;
out.ch = '\0';
return true;
}
while (m_off < len)
{
switch (m_parser->m_state)
{
case State::Ground:
{
const auto b = bytes[m_off];
if (b == 0x1B)
{
m_parser->m_state = State::Esc;
m_off++;
break; // Continue the outer loop to process the Esc state.
}
if (b < 0x20 || b == 0x7F)
{
m_off++;
out.type = VtToken::Ctrl;
out.ch = static_cast<char>(b);
return true;
}
// Bulk scan printable text (>= 0x20, != 0x7F, != 0x1B).
const auto beg = m_off;
do {
m_off++;
} while (m_off < len && bytes[m_off] >= 0x20 && bytes[m_off] != 0x7F && bytes[m_off] != 0x1B);
out.type = VtToken::Text;
out.payload = m_input.substr(beg, m_off - beg);
return true;
}
case State::Esc:
{
const auto ch = nextChar();
switch (ch)
{
case '[':
m_parser->m_state = State::Csi;
m_parser->m_csi.privateByte = '\0';
m_parser->m_csi.finalByte = '\0';
// Clear only params that were used last time.
while (m_parser->m_csi.paramCount > 0)
{
m_parser->m_csi.paramCount--;
m_parser->m_csi.params[m_parser->m_csi.paramCount] = 0;
}
break;
case ']':
m_parser->m_state = State::Osc;
break;
case 'O':
m_parser->m_state = State::Ss3;
break;
case 'P':
m_parser->m_state = State::Dcs;
break;
default:
m_parser->m_state = State::Ground;
out.type = VtToken::Esc;
// Truncate to char. For the sequences we care about this is always ASCII.
out.ch = static_cast<char>(ch);
return true;
}
break;
}
case State::Ss3:
{
m_parser->m_state = State::Ground;
const auto ch = nextChar();
out.type = VtToken::SS3;
out.ch = static_cast<char>(ch);
return true;
}
case State::Csi:
{
for (;;)
{
// Parse parameter digits.
if (m_parser->m_csi.paramCount < std::size(m_parser->m_csi.params))
{
auto& dst = m_parser->m_csi.params[m_parser->m_csi.paramCount];
while (m_off < len && bytes[m_off] >= '0' && bytes[m_off] <= '9')
{
const uint32_t add = bytes[m_off] - '0';
const uint32_t value = static_cast<uint32_t>(dst) * 10 + add;
dst = static_cast<uint16_t>(std::min(value, static_cast<uint32_t>(UINT16_MAX)));
m_off++;
}
}
else
{
// Overflow: skip digits.
while (m_off < len && bytes[m_off] >= '0' && bytes[m_off] <= '9')
m_off++;
}
// Need more data?
if (m_off >= len)
return false;
const auto c = bytes[m_off];
m_off++;
if (c >= 0x40 && c <= 0x7E)
{
// Final byte.
m_parser->m_state = State::Ground;
m_parser->m_csi.finalByte = static_cast<char>(c);
if (m_parser->m_csi.paramCount != 0 || m_parser->m_csi.params[0] != 0)
m_parser->m_csi.paramCount++;
out.type = VtToken::Csi;
out.csi = &m_parser->m_csi;
return true;
}
if (c == ';')
{
m_parser->m_csi.paramCount++;
}
else if (c >= '<' && c <= '?')
{
m_parser->m_csi.privateByte = static_cast<char>(c);
}
// else: intermediate bytes (0x20-0x2F) or unknown — silently skip.
}
}
case State::Osc:
case State::Dcs:
{
const auto beg = m_off;
std::string_view data;
bool partial;
for (;;)
{
// Scan for BEL (0x07) or ESC (0x1B) — potential terminators.
while (m_off < len && bytes[m_off] != 0x07 && bytes[m_off] != 0x1B)
m_off++;
data = m_input.substr(beg, m_off - beg);
partial = m_off >= len;
if (partial)
break;
const auto c = bytes[m_off];
m_off++;
if (c == 0x1B)
{
// ESC might start ST (ESC \). Check next byte.
if (m_off >= len)
{
// At end of input — save state for next chunk.
m_parser->m_state = (m_parser->m_state == State::Osc) ? State::OscEsc : State::DcsEsc;
partial = true;
break;
}
if (bytes[m_off] != '\\')
continue; // False alarm, not ST.
m_off++; // Consume the backslash.
}
// BEL or ESC \ — sequence is complete.
break;
}
const auto wasOsc = (m_parser->m_state == State::Osc);
if (!partial)
m_parser->m_state = State::Ground;
out.type = wasOsc ? VtToken::Osc : VtToken::Dcs;
out.payload = data;
out.partial = partial;
return true;
}
case State::OscEsc:
case State::DcsEsc:
{
// Previous chunk ended with ESC inside an OSC/DCS.
// Check if this chunk starts with '\' to complete the ST.
if (bytes[m_off] == '\\')
{
const auto wasOsc = (m_parser->m_state == State::OscEsc);
m_parser->m_state = State::Ground;
m_off++;
out.type = wasOsc ? VtToken::Osc : VtToken::Dcs;
out.payload = {};
out.partial = false;
return true;
}
else
{
// False alarm — the ESC was not a string terminator.
// Return it as partial payload and resume the string state.
const auto wasOsc = (m_parser->m_state == State::OscEsc);
m_parser->m_state = wasOsc ? State::Osc : State::Dcs;
out.type = wasOsc ? VtToken::Osc : VtToken::Dcs;
out.payload = "\x1b";
out.partial = true;
return true;
}
}
} // switch
} // while
return false;
}

134
src/conpty/VtParser.h Normal file
View File

@@ -0,0 +1,134 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
// A reusable VT tokenizer, ported from the Rust implementation in Microsoft Edit.
//
// The parser produces tokens from a UTF-8 byte stream. It handles chunked input
// correctly — if a sequence is split across two feed() calls, the parser buffers
// the incomplete prefix and completes it on the next call.
//
// Usage:
// VtParser parser;
// VtParser::Stream stream = parser.parse(input);
// VtToken token;
// while (stream.next(token)) {
// switch (token.type) { ... }
// }
#pragma once
#include <cstdint>
#include <string_view>
// A single CSI sequence, parsed for your convenience.
struct VtCsi
{
// The parameters of the CSI sequence.
uint16_t params[32]{};
// The number of parameters stored in params[].
size_t paramCount = 0;
// The private byte, if any. '\0' if none.
// The private byte is the first character right after the ESC [ sequence.
// It is usually a '?' or '<'.
char privateByte = '\0';
// The final byte of the CSI sequence.
// This is the last character of the sequence, e.g. 'm' or 'H'.
char finalByte = '\0';
};
struct VtToken
{
enum Type : uint8_t
{
// A bunch of text. Doesn't contain any control characters.
Text,
// A single control character, like backspace or return.
Ctrl,
// We encountered ESC x and this contains x (in `ch`).
Esc,
// We encountered ESC O x and this contains x (in `ch`).
SS3,
// A CSI sequence started with ESC [. See `csi`.
Csi,
// An OSC sequence started with ESC ]. May be partial (chunked).
Osc,
// A DCS sequence started with ESC P. May be partial (chunked).
Dcs,
};
Type type = Text;
// For Ctrl: the control byte itself.
// For Esc/SS3: the character after ESC / ESC O.
char ch = '\0';
// For Csi: pointer to the parser's Csi struct. Valid until the next next() call.
const VtCsi* csi = nullptr;
// For Text/Osc/Dcs: the string payload (points into the input buffer, zero-copy).
std::string_view payload;
// For Osc/Dcs: true if the sequence is incomplete (split across chunks).
bool partial = false;
};
class VtParser
{
public:
class Stream;
VtParser() = default;
// Returns true if the parser is in the middle of an ESC sequence,
// meaning the caller should apply a timeout before the next parse() call.
// If the timeout fires, call parse("") to flush the bare ESC.
bool hasEscTimeout() const noexcept;
// Begin parsing the given input. Returns a Stream that yields tokens.
// The returned Stream borrows from both `this` and `input` — do not
// modify either while the Stream is alive.
Stream parse(std::string_view input) noexcept;
private:
enum class State : uint8_t
{
Ground,
Esc,
Ss3,
Csi,
Osc,
Dcs,
OscEsc,
DcsEsc,
};
State m_state = State::Ground;
VtCsi m_csi;
};
// An iterator that yields VtTokens from a single parse() call.
// This is a "lending iterator" — the token references data owned by
// the parser and the input string_view.
class VtParser::Stream
{
public:
Stream(VtParser* parser, std::string_view input) noexcept
: m_parser(parser), m_input(input) {}
// The input being parsed.
std::string_view input() const noexcept { return m_input; }
// Current byte offset into the input.
size_t offset() const noexcept { return m_off; }
// True if all input has been consumed.
bool done() const noexcept { return m_off >= m_input.size(); }
// Get the next token. Returns false when no more complete tokens
// can be extracted (remaining bytes are an incomplete sequence).
bool next(VtToken& out);
// Decode and consume one UTF-8 codepoint. Returns '\0' at end.
char32_t nextChar();
private:
VtParser* m_parser;
std::string_view m_input;
size_t m_off = 0;
};

View File

@@ -16,7 +16,7 @@
<PrecompiledHeader>NotUsing</PrecompiledHeader>
</ClCompile>
<Link>
<SubSystem>Console</SubSystem>
<SubSystem>Windows</SubSystem>
</Link>
</ItemDefinitionGroup>
<ItemGroup>

View File

@@ -1,124 +1,589 @@
// A minimal Win32 terminal — Windows 95 conhost style.
// Fixed-size char32_t grid, GDI rendering, no Unicode shaping, no scrollback.
// Implements IPtyHost and uses VtParser to interpret output from the server.
#define NOMINMAX
#define WIN32_LEAN_AND_MEAN
#include <Windows.h>
#include <wil/com.h>
#include <wil/resource.h>
#include <algorithm>
#include <atomic>
#include <string>
#include <thread>
#include <conpty.h>
#include "../VtParser.h"
static std::string visualize_control_codes(std::string_view str) noexcept
// ============================================================================
// Terminal grid
// ============================================================================
static constexpr SHORT COLS = 120;
static constexpr SHORT ROWS = 30;
static constexpr int CELL_W = 8;
static constexpr int CELL_H = 16;
struct Cell
{
std::string out;
out.reserve(str.size() * 3);
for (size_t i = 0; i < str.size();)
char32_t ch = ' ';
WORD attr = FOREGROUND_RED | FOREGROUND_GREEN | FOREGROUND_BLUE;
};
struct TermState
{
Cell grid[ROWS][COLS]{};
SHORT cursorX = 0;
SHORT cursorY = 0;
WORD currentAttr = FOREGROUND_RED | FOREGROUND_GREEN | FOREGROUND_BLUE;
bool cursorVisible = true;
std::wstring title = L"conpty-test";
COLORREF colorTable[16] = {
0x000000, 0x800000, 0x008000, 0x808000, 0x000080, 0x800080, 0x008080, 0xC0C0C0,
0x808080, 0xFF0000, 0x00FF00, 0xFFFF00, 0x0000FF, 0xFF00FF, 0x00FFFF, 0xFFFFFF,
};
void scrollUp()
{
const auto ch = static_cast<unsigned char>(str[i]);
if (ch < 0x20 || ch == 0x7f)
{
// Map C0 controls to U+2400..U+241F and DEL to U+2421.
const char32_t cp = ch == 0x7f ? 0x2421 : 0x2400 + ch;
out += static_cast<char>(0xE0 | (cp >> 12));
out += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
out += static_cast<char>(0x80 | (cp & 0x3F));
}
else if (ch == 0x20)
{
// Replace space with ␣ (U+2423).
out += "\xE2\x90\xA3";
}
else
{
out += static_cast<char>(ch);
}
++i;
memmove(&grid[0], &grid[1], sizeof(Cell) * COLS * (ROWS - 1));
for (SHORT x = 0; x < COLS; x++)
grid[ROWS - 1][x] = Cell{};
}
return out;
}
static std::string u16u8(std::wstring_view str) noexcept
{
std::string out;
out.resize(str.size() * 3); // worst case
const auto len = WideCharToMultiByte(CP_UTF8, 0, str.data(), static_cast<int>(str.size()), out.data(), static_cast<int>(out.size()), nullptr, nullptr);
out.resize(std::max(0, len));
return out;
}
static void write_stdout(std::string_view str) noexcept
{
WriteFile(GetStdHandle(STD_OUTPUT_HANDLE), str.data(), static_cast<DWORD>(str.size()), nullptr, nullptr);
}
struct Host : IPtyHost
{
HRESULT QueryInterface(const IID& riid, void** ppvObject)
void advanceCursor()
{
if (ppvObject == nullptr)
cursorX++;
if (cursorX >= COLS)
{
return E_POINTER;
cursorX = 0;
cursorY++;
if (cursorY >= ROWS)
{
cursorY = ROWS - 1;
scrollUp();
}
}
}
void linefeed()
{
cursorY++;
if (cursorY >= ROWS)
{
cursorY = ROWS - 1;
scrollUp();
}
}
void putChar(char32_t ch)
{
if (cursorX < COLS && cursorY < ROWS)
{
grid[cursorY][cursorX] = { ch, currentAttr };
advanceCursor();
}
}
void eraseDisplay(int mode)
{
const Cell blank = { ' ', currentAttr };
if (mode == 0)
{
for (SHORT x = cursorX; x < COLS; x++) grid[cursorY][x] = blank;
for (SHORT y = cursorY + 1; y < ROWS; y++)
for (SHORT x = 0; x < COLS; x++) grid[y][x] = blank;
}
else if (mode == 1)
{
for (SHORT y = 0; y < cursorY; y++)
for (SHORT x = 0; x < COLS; x++) grid[y][x] = blank;
for (SHORT x = 0; x <= cursorX && x < COLS; x++) grid[cursorY][x] = blank;
}
else if (mode == 2 || mode == 3)
{
for (SHORT y = 0; y < ROWS; y++)
for (SHORT x = 0; x < COLS; x++)
grid[y][x] = blank;
}
}
void eraseLine(int mode)
{
const Cell blank = { ' ', currentAttr };
SHORT start = 0, end = COLS;
if (mode == 0) start = cursorX;
else if (mode == 1) end = cursorX + 1;
for (SHORT x = start; x < end; x++)
grid[cursorY][x] = blank;
}
};
// ============================================================================
// Globals
// ============================================================================
static TermState g_term;
static VtParser g_vtParser;
static HWND g_hwnd = nullptr;
static wil::com_ptr<IPtyServer> g_server;
static CRITICAL_SECTION g_lock;
static void invalidate()
{
if (g_hwnd)
InvalidateRect(g_hwnd, nullptr, FALSE);
}
// ============================================================================
// VT output interpreter
// ============================================================================
static COLORREF attrToFg(WORD attr)
{
return g_term.colorTable[attr & 0x0F];
}
static COLORREF attrToBg(WORD attr)
{
return g_term.colorTable[(attr >> 4) & 0x0F];
}
static void parseSGR(const VtCsi& csi)
{
if (csi.paramCount == 0)
{
g_term.currentAttr = FOREGROUND_RED | FOREGROUND_GREEN | FOREGROUND_BLUE;
return;
}
// VT→Console color index: VT uses RGB bit order, Console uses BGR.
static constexpr WORD vtToConsole[] = { 0, 4, 2, 6, 1, 5, 3, 7 };
for (size_t i = 0; i < csi.paramCount; i++)
{
const auto p = csi.params[i];
switch (p)
{
case 0: g_term.currentAttr = FOREGROUND_RED | FOREGROUND_GREEN | FOREGROUND_BLUE; break;
case 1: g_term.currentAttr |= FOREGROUND_INTENSITY; break;
case 7: g_term.currentAttr |= COMMON_LVB_REVERSE_VIDEO; break;
case 22: g_term.currentAttr &= ~FOREGROUND_INTENSITY; break;
case 27: g_term.currentAttr &= ~COMMON_LVB_REVERSE_VIDEO; break;
case 39: g_term.currentAttr = (g_term.currentAttr & ~0x0F) | 0x07; break;
case 49: g_term.currentAttr &= ~0xF0; break;
default:
if (p >= 30 && p <= 37)
g_term.currentAttr = (g_term.currentAttr & ~0x07) | vtToConsole[p - 30];
else if (p >= 40 && p <= 47)
g_term.currentAttr = (g_term.currentAttr & ~0x70) | (vtToConsole[p - 40] << 4);
else if (p >= 90 && p <= 97)
g_term.currentAttr = (g_term.currentAttr & ~0x0F) | vtToConsole[p - 90] | FOREGROUND_INTENSITY;
else if (p >= 100 && p <= 107)
g_term.currentAttr = (g_term.currentAttr & ~0xF0) | (vtToConsole[p - 100] << 4) | BACKGROUND_INTENSITY;
break;
}
}
}
static void handleCsiOutput(const VtCsi& csi)
{
const auto p0 = (csi.paramCount >= 1) ? csi.params[0] : 0;
const auto p1 = (csi.paramCount >= 2) ? csi.params[1] : 0;
switch (csi.finalByte)
{
case 'A': g_term.cursorY = std::max<SHORT>(0, g_term.cursorY - std::max<SHORT>(1, (SHORT)p0)); break;
case 'B': g_term.cursorY = std::min<SHORT>(ROWS - 1, g_term.cursorY + std::max<SHORT>(1, (SHORT)p0)); break;
case 'C': g_term.cursorX = std::min<SHORT>(COLS - 1, g_term.cursorX + std::max<SHORT>(1, (SHORT)p0)); break;
case 'D': g_term.cursorX = std::max<SHORT>(0, g_term.cursorX - std::max<SHORT>(1, (SHORT)p0)); break;
case 'H':
case 'f':
g_term.cursorY = std::clamp<SHORT>(p0 > 0 ? (SHORT)(p0 - 1) : 0, 0, ROWS - 1);
g_term.cursorX = std::clamp<SHORT>(p1 > 0 ? (SHORT)(p1 - 1) : 0, 0, COLS - 1);
break;
case 'J': g_term.eraseDisplay(p0); break;
case 'K': g_term.eraseLine(p0); break;
case 'm': parseSGR(csi); break;
case 'h':
if (csi.privateByte == '?' && p0 == 25) g_term.cursorVisible = true;
break;
case 'l':
if (csi.privateByte == '?' && p0 == 25) g_term.cursorVisible = false;
break;
default: break;
}
}
static void processOutput(std::string_view text)
{
auto stream = g_vtParser.parse(text);
VtToken token;
while (stream.next(token))
{
switch (token.type)
{
case VtToken::Text:
{
const auto* bytes = reinterpret_cast<const uint8_t*>(token.payload.data());
size_t i = 0;
while (i < token.payload.size())
{
uint32_t cp;
size_t seqLen;
const auto b = bytes[i];
if (b < 0x80) { cp = b; seqLen = 1; }
else if ((b & 0xE0) == 0xC0) { cp = b & 0x1F; seqLen = 2; }
else if ((b & 0xF0) == 0xE0) { cp = b & 0x0F; seqLen = 3; }
else if ((b & 0xF8) == 0xF0) { cp = b & 0x07; seqLen = 4; }
else { i++; continue; }
if (i + seqLen > token.payload.size()) break;
for (size_t j = 1; j < seqLen; j++)
cp = (cp << 6) | (bytes[i + j] & 0x3F);
i += seqLen;
g_term.putChar(cp);
}
break;
}
case VtToken::Ctrl:
switch (token.ch)
{
case '\r': g_term.cursorX = 0; break;
case '\n': g_term.linefeed(); break;
case '\b': if (g_term.cursorX > 0) g_term.cursorX--; break;
case '\t': g_term.cursorX = std::min<SHORT>(COLS - 1, (g_term.cursorX + 8) & ~7); break;
case '\a': MessageBeep(MB_OK); break;
default: break;
}
break;
case VtToken::Csi:
handleCsiOutput(*token.csi);
break;
case VtToken::Osc:
if (!token.partial && token.payload.size() >= 2 &&
(token.payload[0] == '0' || token.payload[0] == '2') && token.payload[1] == ';')
{
auto data = token.payload.substr(2);
const auto wLen = MultiByteToWideChar(CP_UTF8, 0, data.data(), (int)data.size(), nullptr, 0);
if (wLen > 0)
{
g_term.title.resize(wLen);
MultiByteToWideChar(CP_UTF8, 0, data.data(), (int)data.size(), g_term.title.data(), wLen);
if (g_hwnd) SetWindowTextW(g_hwnd, g_term.title.c_str());
}
}
break;
default: break;
}
}
invalidate();
}
// ============================================================================
// IPtyHost implementation
// ============================================================================
struct TestHost : IPtyHost
{
HRESULT QueryInterface(const IID& riid, void** ppvObject) override
{
if (!ppvObject) return E_POINTER;
if (riid == __uuidof(IPtyHost) || riid == __uuidof(IUnknown))
{
*ppvObject = static_cast<IPtyHost*>(this);
AddRef();
return S_OK;
}
*ppvObject = nullptr;
return E_NOINTERFACE;
}
ULONG AddRef()
ULONG AddRef() override { return m_refCount.fetch_add(1) + 1; }
ULONG Release() override
{
return m_refCount.fetch_add(1, std::memory_order_relaxed) + 1;
const auto c = m_refCount.fetch_sub(1) - 1;
if (c == 0) delete this;
return c;
}
ULONG Release()
void WriteUTF8(PTY_UTF8_STRING text) override
{
const auto count = m_refCount.fetch_sub(1, std::memory_order_relaxed) - 1;
if (count == 0)
EnterCriticalSection(&g_lock);
processOutput({ text.data, text.length });
LeaveCriticalSection(&g_lock);
}
void WriteUTF16(PTY_UTF16_STRING text) override
{
const auto len = WideCharToMultiByte(CP_UTF8, 0, text.data, (int)text.length, nullptr, 0, nullptr, nullptr);
if (len > 0)
{
delete this;
std::string utf8(len, '\0');
WideCharToMultiByte(CP_UTF8, 0, text.data, (int)text.length, utf8.data(), len, nullptr, nullptr);
EnterCriticalSection(&g_lock);
processOutput(utf8);
LeaveCriticalSection(&g_lock);
}
return count;
}
void HandleUTF8Output(LPCSTR data, SIZE_T length) override
HRESULT CreateBuffer(void** buffer) override { *buffer = nullptr; return E_NOTIMPL; }
HRESULT ReleaseBuffer(void*) override { return S_OK; }
HRESULT ActivateBuffer(void*) override { return S_OK; }
HRESULT GetScreenBufferInfo(PTY_SCREEN_BUFFER_INFO* info) override
{
write_stdout(visualize_control_codes({ data, length }));
EnterCriticalSection(&g_lock);
*info = {};
info->Size = { COLS, ROWS };
info->CursorPosition = { g_term.cursorX, g_term.cursorY };
info->Attributes = g_term.currentAttr;
info->Window = { 0, 0, COLS - 1, ROWS - 1 };
info->MaximumWindowSize = { COLS, ROWS };
info->CursorSize = 25;
info->CursorVisible = g_term.cursorVisible;
info->FontSize = { CELL_W, CELL_H };
info->FontFamily = FF_MODERN | FIXED_PITCH;
info->FontWeight = FW_NORMAL;
wcscpy_s(info->FaceName, L"Terminal");
memcpy(info->ColorTable, g_term.colorTable, sizeof(g_term.colorTable));
LeaveCriticalSection(&g_lock);
return S_OK;
}
void HandleUTF16Output(LPCWSTR data, SIZE_T length) override
HRESULT SetScreenBufferInfo(const PTY_SCREEN_BUFFER_INFO_CHANGE* change) override
{
write_stdout(visualize_control_codes(u16u8({ data, length })));
EnterCriticalSection(&g_lock);
if (change->CursorPosition)
{
g_term.cursorX = std::clamp(change->CursorPosition->X, SHORT(0), SHORT(COLS - 1));
g_term.cursorY = std::clamp(change->CursorPosition->Y, SHORT(0), SHORT(ROWS - 1));
}
if (change->Attributes) g_term.currentAttr = *change->Attributes;
if (change->CursorVisible) g_term.cursorVisible = *change->CursorVisible != 0;
if (change->ColorTable) memcpy(g_term.colorTable, change->ColorTable, sizeof(g_term.colorTable));
invalidate();
LeaveCriticalSection(&g_lock);
return S_OK;
}
HRESULT ReadBuffer(COORD pos, LONG count, PTY_CHAR_INFO* infos) override
{
EnterCriticalSection(&g_lock);
for (LONG i = 0; i < count; i++)
{
SHORT x = pos.X + static_cast<SHORT>(i);
SHORT y = pos.Y;
while (x >= COLS && y < ROWS) { x -= COLS; y++; }
if (y >= 0 && y < ROWS && x >= 0 && x < COLS)
{
infos[i].Char = static_cast<WCHAR>(g_term.grid[y][x].ch <= 0xFFFF ? g_term.grid[y][x].ch : L'?');
infos[i].Attributes = g_term.grid[y][x].attr;
}
else
{
infos[i].Char = L' ';
infos[i].Attributes = 0x07;
}
}
LeaveCriticalSection(&g_lock);
return S_OK;
}
HRESULT GetConsoleWindow(HWND* hwnd) override { *hwnd = g_hwnd; return S_OK; }
private:
std::atomic<ULONG> m_refCount{ 1 };
};
int main()
{
SetConsoleCP(CP_UTF8);
SetConsoleOutputCP(CP_UTF8);
// ============================================================================
// GDI rendering
// ============================================================================
wil::com_ptr<IPtyServer> server;
THROW_IF_FAILED(PtyCreateServer(IID_PPV_ARGS(server.addressof())));
THROW_IF_FAILED(server->SetHost(new Host()));
static void paint(HWND hwnd, HDC hdc)
{
EnterCriticalSection(&g_lock);
RECT rc;
GetClientRect(hwnd, &rc);
const auto bgBrush = CreateSolidBrush(g_term.colorTable[0]);
FillRect(hdc, &rc, bgBrush);
DeleteObject(bgBrush);
const auto font = CreateFontW(
CELL_H, CELL_W, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE,
DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS,
NONANTIALIASED_QUALITY, FIXED_PITCH | FF_MODERN, L"Terminal");
const auto oldFont = SelectObject(hdc, font);
SetBkMode(hdc, OPAQUE);
for (SHORT y = 0; y < ROWS; y++)
{
for (SHORT x = 0; x < COLS; x++)
{
const auto& cell = g_term.grid[y][x];
COLORREF fg, bg;
if (cell.attr & COMMON_LVB_REVERSE_VIDEO)
{
fg = attrToBg(cell.attr);
bg = attrToFg(cell.attr);
}
else
{
fg = attrToFg(cell.attr);
bg = attrToBg(cell.attr);
}
SetTextColor(hdc, fg);
SetBkColor(hdc, bg);
wchar_t wch = static_cast<wchar_t>(cell.ch <= 0xFFFF ? cell.ch : L'?');
if (wch < 0x20) wch = L' ';
TextOutW(hdc, x * CELL_W, y * CELL_H, &wch, 1);
}
}
if (g_term.cursorVisible)
{
RECT cur = { g_term.cursorX * CELL_W, g_term.cursorY * CELL_H + CELL_H - 2,
g_term.cursorX * CELL_W + CELL_W, g_term.cursorY * CELL_H + CELL_H };
const auto curBrush = CreateSolidBrush(g_term.colorTable[7]);
FillRect(hdc, &cur, curBrush);
DeleteObject(curBrush);
}
SelectObject(hdc, oldFont);
DeleteObject(font);
LeaveCriticalSection(&g_lock);
}
// ============================================================================
// Window procedure
// ============================================================================
static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
switch (msg)
{
case WM_PAINT:
{
PAINTSTRUCT ps;
const auto hdc = BeginPaint(hwnd, &ps);
paint(hwnd, hdc);
EndPaint(hwnd, &ps);
return 0;
}
case WM_CHAR:
{
wchar_t wch = static_cast<wchar_t>(wParam);
char utf8[4];
const auto len = WideCharToMultiByte(CP_UTF8, 0, &wch, 1, utf8, sizeof(utf8), nullptr, nullptr);
if (len > 0 && g_server)
{
PTY_UTF8_STRING input{ utf8, static_cast<SIZE_T>(len) };
g_server->WriteUTF8(input);
}
return 0;
}
case WM_KEYDOWN:
{
const char* seq = nullptr;
switch (wParam)
{
case VK_UP: seq = "\x1b[A"; break;
case VK_DOWN: seq = "\x1b[B"; break;
case VK_RIGHT: seq = "\x1b[C"; break;
case VK_LEFT: seq = "\x1b[D"; break;
case VK_HOME: seq = "\x1b[H"; break;
case VK_END: seq = "\x1b[F"; break;
case VK_INSERT: seq = "\x1b[2~"; break;
case VK_DELETE: seq = "\x1b[3~"; break;
case VK_PRIOR: seq = "\x1b[5~"; break;
case VK_NEXT: seq = "\x1b[6~"; break;
case VK_F1: seq = "\x1bOP"; break;
case VK_F2: seq = "\x1bOQ"; break;
case VK_F3: seq = "\x1bOR"; break;
case VK_F4: seq = "\x1bOS"; break;
case VK_F5: seq = "\x1b[15~"; break;
case VK_F6: seq = "\x1b[17~"; break;
case VK_F7: seq = "\x1b[18~"; break;
case VK_F8: seq = "\x1b[19~"; break;
case VK_F9: seq = "\x1b[20~"; break;
case VK_F10: seq = "\x1b[21~"; break;
case VK_F11: seq = "\x1b[23~"; break;
case VK_F12: seq = "\x1b[24~"; break;
default: return DefWindowProcW(hwnd, msg, wParam, lParam);
}
if (seq && g_server)
{
PTY_UTF8_STRING input{ seq, strlen(seq) };
g_server->WriteUTF8(input);
}
return 0;
}
case WM_DESTROY:
PostQuitMessage(0);
return 0;
default:
return DefWindowProcW(hwnd, msg, wParam, lParam);
}
}
// ============================================================================
// Entry point
// ============================================================================
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow)
{
InitializeCriticalSection(&g_lock);
WNDCLASSEXW wc{};
wc.cbSize = sizeof(wc);
wc.style = CS_HREDRAW | CS_VREDRAW;
wc.lpfnWndProc = WndProc;
wc.hInstance = hInstance;
wc.hCursor = LoadCursor(nullptr, IDC_ARROW);
wc.lpszClassName = L"ConPtyTestWindow";
RegisterClassExW(&wc);
RECT wr = { 0, 0, COLS * CELL_W, ROWS * CELL_H };
AdjustWindowRect(&wr, WS_OVERLAPPEDWINDOW, FALSE);
g_hwnd = CreateWindowExW(
0, L"ConPtyTestWindow", L"conpty-test",
WS_OVERLAPPEDWINDOW & ~(WS_THICKFRAME | WS_MAXIMIZEBOX),
CW_USEDEFAULT, CW_USEDEFAULT,
wr.right - wr.left, wr.bottom - wr.top,
nullptr, nullptr, hInstance, nullptr);
ShowWindow(g_hwnd, nCmdShow);
UpdateWindow(g_hwnd);
THROW_IF_FAILED(PtyCreateServer(IID_PPV_ARGS(g_server.addressof())));
THROW_IF_FAILED(g_server->SetHost(new TestHost()));
wil::unique_process_information pi;
THROW_IF_FAILED(server->CreateProcessW(
nullptr,
_wcsdup(L"C:\\Windows\\System32\\edit.exe"),
nullptr,
nullptr,
FALSE,
0,
nullptr,
nullptr,
THROW_IF_FAILED(g_server->CreateProcessW(
nullptr, _wcsdup(L"cmd.exe"),
nullptr, nullptr, FALSE, 0, nullptr, nullptr,
pi.addressof()));
THROW_IF_FAILED(server->Run());
// Run the console server on a background thread.
// It blocks in its message loop until all clients disconnect.
std::thread serverThread([&] {
g_server->Run();
PostMessage(g_hwnd, WM_CLOSE, 0, 0);
});
serverThread.detach();
MSG msg;
while (GetMessageW(&msg, nullptr, 0, 0))
{
TranslateMessage(&msg);
DispatchMessageW(&msg);
}
DeleteCriticalSection(&g_lock);
return 0;
}

View File

@@ -33,6 +33,7 @@
<ClCompile Include="Server.msg.l2.cpp" />
<ClCompile Include="Server.msg.l3.cpp" />
<ClCompile Include="Server.raw.cpp" />
<ClCompile Include="VtParser.cpp" />
</ItemGroup>
<ItemGroup>
<Midl Include="conpty.idl">
@@ -53,6 +54,7 @@
<ClInclude Include="pch.h" />
<ClInclude Include="InputBuffer.h" />
<ClInclude Include="Server.h" />
<ClInclude Include="VtParser.h" />
</ItemGroup>
<Import Project="$(SolutionDir)src\common.build.post.props" />
<Import Project="$(SolutionDir)src\common.nugetversions.targets" />

View File

@@ -47,6 +47,9 @@
<ClCompile Include="InputBuffer.cpp">
<Filter>Source Files</Filter>
</ClCompile>
<ClCompile Include="VtParser.cpp">
<Filter>Source Files</Filter>
</ClCompile>
</ItemGroup>
<ItemGroup>
<Midl Include="conpty.idl">
@@ -63,5 +66,8 @@
<ClInclude Include="InputBuffer.h">
<Filter>Header Files</Filter>
</ClInclude>
<ClInclude Include="VtParser.h">
<Filter>Header Files</Filter>
</ClInclude>
</ItemGroup>
</Project>