mirror of
https://github.com/mirkobrombin/raven.git
synced 2026-11-03 21:46:32 +00:00
Programs that redraw their command line read and move the cursor on every key; asking the terminal each time was slow, and wrong on terminals that never answer. The terminal is now asked only when the position may be stale. Assisted-by: Claude Code:claude-opus-5-5 AI-Scope: Wrote the cursor tracker, WriteConsoleOutputCharacter, ScrollConsoleScreenBuffer, the resize events and their tests.
927 lines
30 KiB
C++
927 lines
30 KiB
C++
#include "console.h"
|
|
|
|
#include "text.h"
|
|
|
|
#include <algorithm>
|
|
#include <cerrno>
|
|
#include <chrono>
|
|
#include <cstdio>
|
|
#include <fcntl.h>
|
|
#include <poll.h>
|
|
#include <sys/ioctl.h>
|
|
#include <unistd.h>
|
|
|
|
namespace raven {
|
|
|
|
namespace {
|
|
|
|
constexpr uint32_t shift_pressed = 0x10, left_alt_pressed = 0x2, left_ctrl_pressed = 0x8, enhanced_key = 0x100;
|
|
|
|
// Scan codes of the first IBM PC keyboard for the letters a to z.
|
|
constexpr uint8_t letter_scans[26] = {0x1e, 0x30, 0x2e, 0x20, 0x12, 0x21, 0x22, 0x23, 0x17, 0x24, 0x25, 0x26, 0x32,
|
|
0x31, 0x18, 0x19, 0x10, 0x13, 0x1f, 0x14, 0x16, 0x2f, 0x11, 0x2d, 0x15, 0x2c};
|
|
|
|
struct Punctuation {
|
|
char plain, shifted;
|
|
uint16_t key, scan;
|
|
};
|
|
// The punctuation keys of the US layout: what each gives alone and with Shift, its virtual key and its scan code.
|
|
constexpr Punctuation punctuation[] = {{';', ':', 0xba, 0x27}, {'=', '+', 0xbb, 0x0d}, {',', '<', 0xbc, 0x33}, {'-', '_', 0xbd, 0x0c},
|
|
{'.', '>', 0xbe, 0x34}, {'/', '?', 0xbf, 0x35}, {'`', '~', 0xc0, 0x29}, {'[', '{', 0xdb, 0x1a}, {'\\', '|', 0xdc, 0x2b},
|
|
{']', '}', 0xdd, 0x1b}, {'\'', '"', 0xde, 0x28}};
|
|
constexpr char shifted_digits[] = ")!@#$%^&*(";
|
|
|
|
ConsoleKey named(uint16_t key, uint16_t scan, char16_t character = 0, uint32_t controls = 0)
|
|
{
|
|
ConsoleKey made;
|
|
made.key = key;
|
|
made.scan = scan;
|
|
made.character = character;
|
|
made.controls = controls;
|
|
return made;
|
|
}
|
|
|
|
uint16_t digit_scan(char digit) { return digit == '0' ? 0x0b : uint16_t(0x02 + (digit - '1')); }
|
|
|
|
// The key that types a character on a US keyboard; characters no key types arrive with no key, as pasted text does.
|
|
ConsoleKey typed(char16_t character)
|
|
{
|
|
if (character >= u'a' && character <= u'z')
|
|
return named(uint16_t(u'A' + (character - u'a')), letter_scans[character - u'a'], character);
|
|
if (character >= u'A' && character <= u'Z')
|
|
return named(character, letter_scans[character - u'A'], character, shift_pressed);
|
|
if (character >= u'0' && character <= u'9')
|
|
return named(character, digit_scan(char(character)), character);
|
|
if (character == u' ')
|
|
return named(0x20, 0x39, character);
|
|
for (size_t digit = 0; digit < 10; ++digit)
|
|
if (character == char16_t(shifted_digits[digit]))
|
|
return named(uint16_t(u'0' + digit), digit_scan(char('0' + digit)), character, shift_pressed);
|
|
for (const auto &entry : punctuation) {
|
|
if (character == char16_t(entry.plain))
|
|
return named(entry.key, entry.scan, character);
|
|
if (character == char16_t(entry.shifted))
|
|
return named(entry.key, entry.scan, character, shift_pressed);
|
|
}
|
|
return named(0, 0, character);
|
|
}
|
|
|
|
// A control character: Enter, Tab, Backspace, or Ctrl held with a letter or symbol.
|
|
ConsoleKey control(uint8_t byte)
|
|
{
|
|
switch (byte) {
|
|
case 0x0d: return named(0x0d, 0x1c, u'\r');
|
|
case 0x0a: return named(0x0d, 0x1c, u'\n', left_ctrl_pressed);
|
|
case 0x09: return named(0x09, 0x0f, u'\t');
|
|
case 0x08:
|
|
case 0x7f: return named(0x08, 0x0e, 0x08);
|
|
case 0x1b: return named(0x1b, 0x01, 0x1b);
|
|
case 0x00: return named(0x20, 0x39, 0, left_ctrl_pressed);
|
|
case 0x1c: return named(0xdc, 0x2b, 0x1c, left_ctrl_pressed);
|
|
case 0x1d: return named(0xdd, 0x1b, 0x1d, left_ctrl_pressed);
|
|
case 0x1e: return named(u'6', 0x07, 0x1e, left_ctrl_pressed | shift_pressed);
|
|
case 0x1f: return named(0xbd, 0x0c, 0x1f, left_ctrl_pressed | shift_pressed);
|
|
default: return named(uint16_t(u'A' + byte - 1), letter_scans[byte - 1], byte, left_ctrl_pressed);
|
|
}
|
|
}
|
|
|
|
// The Shift, Alt and Ctrl a terminal encodes as 1 plus a bit mask in the second parameter of a sequence.
|
|
uint32_t modifiers(const std::vector<int> ¶meters)
|
|
{
|
|
if (parameters.size() < 2 || parameters[1] < 2)
|
|
return 0;
|
|
const int mask = parameters[1] - 1;
|
|
return (mask & 1 ? shift_pressed : 0) | (mask & 2 ? left_alt_pressed : 0) | (mask & 4 ? left_ctrl_pressed : 0);
|
|
}
|
|
|
|
ConsoleKey function_key(int number)
|
|
{
|
|
const uint16_t scan = number <= 10 ? uint16_t(0x3a + number) : number == 11 ? 0x57 : 0x58;
|
|
return named(uint16_t(0x6f + number), scan);
|
|
}
|
|
|
|
// The editing and function keys sent as ESC [ number ~.
|
|
std::optional<ConsoleKey> tilde_key(int number)
|
|
{
|
|
switch (number) {
|
|
case 1:
|
|
case 7: return named(0x24, 0x47, 0, enhanced_key);
|
|
case 2: return named(0x2d, 0x52, 0, enhanced_key);
|
|
case 3: return named(0x2e, 0x53, 0, enhanced_key);
|
|
case 4:
|
|
case 8: return named(0x23, 0x4f, 0, enhanced_key);
|
|
case 5: return named(0x21, 0x49, 0, enhanced_key);
|
|
case 6: return named(0x22, 0x51, 0, enhanced_key);
|
|
case 11: case 12: case 13: case 14: case 15: return function_key(number - 10);
|
|
case 17: case 18: case 19: case 20: case 21: return function_key(number - 11);
|
|
case 23: case 24: return function_key(number - 12);
|
|
default: return std::nullopt;
|
|
}
|
|
}
|
|
|
|
// The cursor keys, Home and End, and F1 to F4, by the final character both CSI and SS3 forms end in.
|
|
std::optional<ConsoleKey> final_key(char final)
|
|
{
|
|
switch (final) {
|
|
case 'A': return named(0x26, 0x48, 0, enhanced_key);
|
|
case 'B': return named(0x28, 0x50, 0, enhanced_key);
|
|
case 'C': return named(0x27, 0x4d, 0, enhanced_key);
|
|
case 'D': return named(0x25, 0x4b, 0, enhanced_key);
|
|
case 'H': return named(0x24, 0x47, 0, enhanced_key);
|
|
case 'F': return named(0x23, 0x4f, 0, enhanced_key);
|
|
case 'P': case 'Q': case 'R': case 'S': return function_key(final - 'P' + 1);
|
|
case 'M': return named(0x0d, 0x1c, u'\r', enhanced_key);
|
|
default: return std::nullopt;
|
|
}
|
|
}
|
|
|
|
// Decodes one UTF-8 character; 0 when it is cut off at the end, and U+FFFD for one byte that starts no character.
|
|
size_t utf8_character(std::string_view bytes, size_t at, char32_t &character)
|
|
{
|
|
const auto lead = uint8_t(bytes[at]);
|
|
const size_t length = lead < 0x80 ? 1 : (lead & 0xe0) == 0xc0 ? 2 : (lead & 0xf0) == 0xe0 ? 3 : (lead & 0xf8) == 0xf0 ? 4 : 0;
|
|
if (!length) {
|
|
character = 0xfffd;
|
|
return 1;
|
|
}
|
|
if (at + length > bytes.size())
|
|
return 0;
|
|
character = length == 1 ? lead : lead & (0x7f >> length);
|
|
for (size_t index = 1; index < length; ++index) {
|
|
const auto next = uint8_t(bytes[at + index]);
|
|
if ((next & 0xc0) != 0x80) {
|
|
character = 0xfffd;
|
|
return 1;
|
|
}
|
|
character = (character << 6) | (next & 0x3f);
|
|
}
|
|
return length;
|
|
}
|
|
|
|
void press(std::vector<ConsoleKey> &keys, ConsoleKey key)
|
|
{
|
|
keys.push_back(key);
|
|
key.down = false;
|
|
keys.push_back(key);
|
|
}
|
|
|
|
// One character that is not part of an escape sequence; 0 when it is cut off at the end.
|
|
size_t decode_character(std::string_view bytes, size_t at, uint32_t extra, std::vector<ConsoleKey> &keys)
|
|
{
|
|
const auto byte = uint8_t(bytes[at]);
|
|
if (byte < 0x20 || byte == 0x7f) {
|
|
auto key = control(byte);
|
|
key.controls |= extra;
|
|
press(keys, key);
|
|
return 1;
|
|
}
|
|
char32_t character;
|
|
const size_t used = utf8_character(bytes, at, character);
|
|
if (!used)
|
|
return 0;
|
|
if (character > 0xffff) {
|
|
// A character beyond the Basic Multilingual Plane arrives as its two surrogates.
|
|
character -= 0x10000;
|
|
for (const char16_t half : {char16_t(0xd800 + (character >> 10)), char16_t(0xdc00 + (character & 0x3ff))}) {
|
|
auto key = named(0, 0, half, extra);
|
|
press(keys, key);
|
|
}
|
|
return used;
|
|
}
|
|
auto key = typed(char16_t(character));
|
|
key.controls |= extra;
|
|
press(keys, key);
|
|
return used;
|
|
}
|
|
|
|
std::string utf8_of(char16_t character) { return to_utf8(std::u16string_view(&character, 1)); }
|
|
|
|
bool write_all(int descriptor, std::string_view bytes)
|
|
{
|
|
while (!bytes.empty()) {
|
|
const auto written = ::write(descriptor, bytes.data(), bytes.size());
|
|
if (written < 0) {
|
|
if (errno == EINTR)
|
|
continue;
|
|
if (errno == EAGAIN) {
|
|
pollfd entry{descriptor, POLLOUT, 0};
|
|
::poll(&entry, 1, 100);
|
|
continue;
|
|
}
|
|
return false;
|
|
}
|
|
bytes.remove_prefix(size_t(written));
|
|
}
|
|
return true;
|
|
}
|
|
|
|
}
|
|
|
|
int character_cells(char32_t character)
|
|
{
|
|
struct Range {
|
|
char32_t first, last;
|
|
};
|
|
// Combining marks, zero-width spaces and joiners, and variation selectors take no cell of their own.
|
|
constexpr Range none[] = {{0x0300, 0x036f}, {0x0483, 0x0489}, {0x0591, 0x05bd}, {0x0610, 0x061a}, {0x064b, 0x065f}, {0x0e31, 0x0e31},
|
|
{0x0e34, 0x0e3a}, {0x0e47, 0x0e4e}, {0x1ab0, 0x1aff}, {0x1dc0, 0x1dff}, {0x200b, 0x200f}, {0x2028, 0x202e}, {0x2060, 0x2064},
|
|
{0x20d0, 0x20ff}, {0xfe00, 0xfe0f}, {0xfe20, 0xfe2f}, {0xfeff, 0xfeff}, {0xe0100, 0xe01ef}};
|
|
// Wide and fullwidth East Asian characters and the emoji terminals draw two cells wide.
|
|
constexpr Range two[] = {{0x1100, 0x115f}, {0x231a, 0x231b}, {0x2329, 0x232a}, {0x23e9, 0x23ec}, {0x23f0, 0x23f0}, {0x23f3, 0x23f3},
|
|
{0x25fd, 0x25fe}, {0x2614, 0x2615}, {0x2648, 0x2653}, {0x267f, 0x267f}, {0x2693, 0x2693}, {0x26a1, 0x26a1}, {0x26aa, 0x26ab},
|
|
{0x26bd, 0x26be}, {0x26c4, 0x26c5}, {0x26ce, 0x26ce}, {0x26d4, 0x26d4}, {0x26ea, 0x26ea}, {0x26f2, 0x26f5}, {0x26fa, 0x26fd},
|
|
{0x2705, 0x2705}, {0x270a, 0x270b}, {0x2728, 0x2728}, {0x274c, 0x274c}, {0x2753, 0x2755}, {0x2757, 0x2757}, {0x2795, 0x2797},
|
|
{0x27b0, 0x27b0}, {0x27bf, 0x27bf}, {0x2b1b, 0x2b1c}, {0x2b50, 0x2b50}, {0x2b55, 0x2b55}, {0x2e80, 0x303e}, {0x3041, 0x33ff},
|
|
{0x3400, 0x4dbf}, {0x4e00, 0x9fff}, {0xa000, 0xa4cf}, {0xa960, 0xa97f}, {0xac00, 0xd7a3}, {0xf900, 0xfaff}, {0xfe10, 0xfe19},
|
|
{0xfe30, 0xfe6f}, {0xff00, 0xff60}, {0xffe0, 0xffe6}, {0x16fe0, 0x16fe4}, {0x17000, 0x18cff}, {0x1b000, 0x1b2ff},
|
|
{0x1f004, 0x1f004}, {0x1f0cf, 0x1f0cf}, {0x1f18e, 0x1f18e}, {0x1f191, 0x1f19a}, {0x1f200, 0x1f251}, {0x1f300, 0x1f320},
|
|
{0x1f32d, 0x1f335}, {0x1f337, 0x1f37c}, {0x1f37e, 0x1f393}, {0x1f3a0, 0x1f3ca}, {0x1f3cf, 0x1f3d3}, {0x1f3e0, 0x1f3f0},
|
|
{0x1f3f4, 0x1f3f4}, {0x1f3f8, 0x1f43e}, {0x1f440, 0x1f440}, {0x1f442, 0x1f4fc}, {0x1f4ff, 0x1f53d}, {0x1f54b, 0x1f54e},
|
|
{0x1f550, 0x1f567}, {0x1f57a, 0x1f57a}, {0x1f595, 0x1f596}, {0x1f5a4, 0x1f5a4}, {0x1f5fb, 0x1f64f}, {0x1f680, 0x1f6c5},
|
|
{0x1f6cc, 0x1f6cc}, {0x1f6d0, 0x1f6d2}, {0x1f6d5, 0x1f6d7}, {0x1f6eb, 0x1f6ec}, {0x1f6f4, 0x1f6fc}, {0x1f7e0, 0x1f7eb},
|
|
{0x1f90c, 0x1f93a}, {0x1f93c, 0x1f945}, {0x1f947, 0x1f9ff}, {0x1fa70, 0x1faff}, {0x20000, 0x2fffd}, {0x30000, 0x3fffd}};
|
|
const auto inside = [character](const auto &ranges) {
|
|
for (const auto &range : ranges)
|
|
if (character >= range.first && character <= range.last)
|
|
return true;
|
|
return false;
|
|
};
|
|
return inside(none) ? 0 : inside(two) ? 2 : 1;
|
|
}
|
|
|
|
void CursorTracker::resize(int16_t columns, int16_t rows)
|
|
{
|
|
width = std::max<int16_t>(columns, 1);
|
|
height = std::max<int16_t>(rows, 1);
|
|
clamp();
|
|
}
|
|
|
|
void CursorTracker::place(int16_t column, int16_t row)
|
|
{
|
|
x = column;
|
|
y = row;
|
|
wrap_pending = false;
|
|
clamp();
|
|
}
|
|
|
|
void CursorTracker::clamp()
|
|
{
|
|
x = std::clamp<int16_t>(x, 0, int16_t(width - 1));
|
|
y = std::clamp<int16_t>(y, 0, int16_t(height - 1));
|
|
saved_x = std::clamp<int16_t>(saved_x, 0, int16_t(width - 1));
|
|
saved_y = std::clamp<int16_t>(saved_y, 0, int16_t(height - 1));
|
|
}
|
|
|
|
// Down a row; at the bottom the screen scrolls and the cursor stays on the last row.
|
|
void CursorTracker::line_feed()
|
|
{
|
|
if (y < height - 1)
|
|
++y;
|
|
}
|
|
|
|
void CursorTracker::print(int cells)
|
|
{
|
|
if (!cells)
|
|
return;
|
|
if (wrap_pending || x + cells > width) {
|
|
x = 0;
|
|
line_feed();
|
|
}
|
|
wrap_pending = false;
|
|
x = int16_t(x + cells);
|
|
if (x >= width) {
|
|
x = int16_t(width - 1);
|
|
wrap_pending = true;
|
|
}
|
|
}
|
|
|
|
void CursorTracker::control_sequence(char final)
|
|
{
|
|
// A private marker (? > < =) makes a mode or report request, which moves nothing; DECSTBM (r) homes the cursor.
|
|
if (!parameters.empty() && parameters[0] >= '<' && parameters[0] <= '?')
|
|
return;
|
|
std::vector<int> numbers{0};
|
|
for (const char byte : parameters) {
|
|
if (byte == ';' || byte == ':')
|
|
numbers.push_back(0);
|
|
else if (byte >= '0' && byte <= '9')
|
|
numbers.back() = std::min(numbers.back() * 10 + (byte - '0'), 0x7fff);
|
|
}
|
|
const int count = std::max(numbers[0], 1);
|
|
const int second = numbers.size() > 1 ? std::max(numbers[1], 1) : 1;
|
|
wrap_pending = false;
|
|
switch (final) {
|
|
case 'A': y = int16_t(y - count); break;
|
|
case 'B': case 'e': y = int16_t(y + count); break;
|
|
case 'C': case 'a': x = int16_t(x + count); break;
|
|
case 'D': x = int16_t(x - count); break;
|
|
case 'E': x = 0; y = int16_t(y + count); break;
|
|
case 'F': x = 0; y = int16_t(y - count); break;
|
|
case 'G': case '`': x = int16_t(count - 1); break;
|
|
case 'd': y = int16_t(count - 1); break;
|
|
case 'H': case 'f': y = int16_t(count - 1); x = int16_t(second - 1); break;
|
|
case 's': if (numbers.size() == 1 && !numbers[0]) { saved_x = x; saved_y = y; } break;
|
|
case 'u': x = saved_x; y = saved_y; break;
|
|
case 'r': x = 0; y = 0; break;
|
|
default: break;
|
|
}
|
|
clamp();
|
|
}
|
|
|
|
void CursorTracker::feed(std::string_view bytes, bool return_on_newline)
|
|
{
|
|
for (const char byte : bytes) {
|
|
const auto value = uint8_t(byte);
|
|
switch (state) {
|
|
case State::text:
|
|
// A UTF-8 character takes its cells once its last byte is here; one broken off early takes a cell, as its replacement does.
|
|
if (!character.empty() && (value & 0xc0) != 0x80) {
|
|
print(1);
|
|
character.clear();
|
|
}
|
|
if (value >= 0x80) {
|
|
character += byte;
|
|
char32_t decoded;
|
|
const size_t used = utf8_character(character, 0, decoded);
|
|
if (used) {
|
|
print(character_cells(decoded));
|
|
character.clear();
|
|
}
|
|
break;
|
|
}
|
|
switch (value) {
|
|
case 0x1b: state = State::escape; break;
|
|
case '\r': x = 0; wrap_pending = false; break;
|
|
case '\n': case 0x0b: case 0x0c:
|
|
if (return_on_newline)
|
|
x = 0;
|
|
line_feed();
|
|
wrap_pending = false;
|
|
break;
|
|
case '\b': if (x > 0) --x; wrap_pending = false; break;
|
|
case '\t': x = int16_t(std::min<int>((x / 8 + 1) * 8, width - 1)); break;
|
|
default:
|
|
if (value >= 0x20 && value != 0x7f)
|
|
print(1);
|
|
}
|
|
break;
|
|
case State::escape:
|
|
state = State::text;
|
|
switch (byte) {
|
|
case '[': state = State::control; parameters.clear(); break;
|
|
case ']': case 'P': case 'X': case '^': case '_': state = State::string; break;
|
|
case '(': case ')': case '*': case '+': case '#': case '%': state = State::designate; break;
|
|
case '7': saved_x = x; saved_y = y; break;
|
|
case '8': x = saved_x; y = saved_y; wrap_pending = false; break;
|
|
case 'D': line_feed(); break;
|
|
case 'E': x = 0; line_feed(); break;
|
|
case 'M': if (y > 0) --y; break;
|
|
case 'c': x = y = 0; wrap_pending = false; break;
|
|
default: break;
|
|
}
|
|
break;
|
|
case State::control:
|
|
if (value >= 0x40 && value <= 0x7e) {
|
|
control_sequence(byte);
|
|
state = State::text;
|
|
} else if (value >= 0x20 && value < 0x40)
|
|
parameters += byte;
|
|
else
|
|
state = value == 0x1b ? State::escape : State::text;
|
|
break;
|
|
case State::string:
|
|
if (value == 0x07)
|
|
state = State::text;
|
|
else if (value == 0x1b)
|
|
state = State::string_escape;
|
|
break;
|
|
case State::string_escape:
|
|
state = byte == '\\' ? State::text : State::string;
|
|
break;
|
|
case State::designate:
|
|
state = State::text;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
size_t decode_terminal_keys(std::string_view bytes, bool flush, bool virtual_terminal, std::vector<ConsoleKey> &keys)
|
|
{
|
|
size_t at = 0;
|
|
while (at < bytes.size()) {
|
|
if (bytes[at] != '\x1b' || virtual_terminal) {
|
|
const size_t used = decode_character(bytes, at, 0, keys);
|
|
if (!used) {
|
|
if (!flush)
|
|
break;
|
|
press(keys, named(0, 0, 0xfffd));
|
|
at = bytes.size();
|
|
break;
|
|
}
|
|
at += used;
|
|
continue;
|
|
}
|
|
if (at + 1 >= bytes.size()) {
|
|
if (!flush)
|
|
break;
|
|
press(keys, control(0x1b));
|
|
++at;
|
|
continue;
|
|
}
|
|
const char kind = bytes[at + 1];
|
|
if (kind == '[') {
|
|
size_t end = at + 2;
|
|
while (end < bytes.size() && (uint8_t(bytes[end]) < 0x40 || uint8_t(bytes[end]) > 0x7e))
|
|
++end;
|
|
if (end >= bytes.size()) {
|
|
if (!flush)
|
|
break;
|
|
// A sequence that never finished: the Escape key, then what followed it as typed.
|
|
press(keys, control(0x1b));
|
|
++at;
|
|
continue;
|
|
}
|
|
std::vector<int> parameters{0};
|
|
for (size_t index = at + 2; index < end; ++index) {
|
|
if (bytes[index] == ';')
|
|
parameters.push_back(0);
|
|
else if (bytes[index] >= '0' && bytes[index] <= '9')
|
|
parameters.back() = parameters.back() * 10 + (bytes[index] - '0');
|
|
}
|
|
const char final = bytes[end];
|
|
std::optional<ConsoleKey> key;
|
|
if (final == '~')
|
|
key = tilde_key(parameters[0]);
|
|
else if (final == 'Z')
|
|
key = named(0x09, 0x0f, u'\t', shift_pressed);
|
|
else
|
|
key = final_key(final);
|
|
if (key) {
|
|
key->controls |= modifiers(parameters);
|
|
press(keys, *key);
|
|
}
|
|
// Sequences no key sends (focus reports, pasted-text brackets, mouse reports) are dropped.
|
|
at = end + 1;
|
|
continue;
|
|
}
|
|
if (kind == 'O') {
|
|
if (at + 2 >= bytes.size()) {
|
|
if (!flush)
|
|
break;
|
|
press(keys, control(0x1b));
|
|
++at;
|
|
continue;
|
|
}
|
|
if (const auto key = final_key(bytes[at + 2]))
|
|
press(keys, *key);
|
|
at += 3;
|
|
continue;
|
|
}
|
|
// ESC before a character is that character typed with Alt held.
|
|
const size_t used = decode_character(bytes, at + 1, left_alt_pressed, keys);
|
|
if (!used) {
|
|
if (!flush)
|
|
break;
|
|
press(keys, control(0x1b));
|
|
++at;
|
|
continue;
|
|
}
|
|
at += 1 + used;
|
|
}
|
|
return at;
|
|
}
|
|
|
|
std::string attribute_sequence(uint16_t attribute)
|
|
{
|
|
// Console colours are blue, green and red bits; ANSI colours number red, green and blue from the low bit.
|
|
const auto ansi = [](uint16_t bits) { return (bits & 4 ? 1 : 0) | (bits & 2 ? 2 : 0) | (bits & 1 ? 4 : 0); };
|
|
const uint16_t foreground = attribute & 0xf, background = (attribute >> 4) & 0xf;
|
|
std::string sequence = "\x1b[0";
|
|
if (foreground != 7)
|
|
sequence += ';' + std::to_string((foreground & 8 ? 90 : 30) + ansi(foreground));
|
|
if (background)
|
|
sequence += ';' + std::to_string((background & 8 ? 100 : 40) + ansi(background));
|
|
if (attribute & console_reverse_video)
|
|
sequence += ";7";
|
|
if (attribute & console_underscore)
|
|
sequence += ";4";
|
|
return sequence + 'm';
|
|
}
|
|
|
|
TerminalConsole &TerminalConsole::instance()
|
|
{
|
|
static TerminalConsole console;
|
|
return console;
|
|
}
|
|
|
|
TerminalConsole::~TerminalConsole()
|
|
{
|
|
restore();
|
|
if (original_descriptor >= 0)
|
|
::close(original_descriptor);
|
|
}
|
|
|
|
void TerminalConsole::remember(int descriptor)
|
|
{
|
|
if (original)
|
|
return;
|
|
termios settings{};
|
|
if (::tcgetattr(descriptor, &settings))
|
|
return;
|
|
original = settings;
|
|
original_descriptor = ::fcntl(descriptor, F_DUPFD_CLOEXEC, 3);
|
|
}
|
|
|
|
void TerminalConsole::restore()
|
|
{
|
|
if (original && original_descriptor >= 0)
|
|
::tcsetattr(original_descriptor, TCSADRAIN, &*original);
|
|
if (original_descriptor >= 0) {
|
|
if (colours != 7)
|
|
write_all(original_descriptor, "\x1b[0m");
|
|
if (!visible)
|
|
write_all(original_descriptor, "\x1b[?25h");
|
|
}
|
|
}
|
|
|
|
void TerminalConsole::restore_from_signal()
|
|
{
|
|
if (original && original_descriptor >= 0) {
|
|
::tcsetattr(original_descriptor, TCSANOW, &*original);
|
|
[[maybe_unused]] const auto written = ::write(original_descriptor, "\x1b[0m\x1b[?25h", 10);
|
|
}
|
|
}
|
|
|
|
uint32_t TerminalConsole::input_mode(int descriptor)
|
|
{
|
|
termios settings{};
|
|
if (::tcgetattr(descriptor, &settings))
|
|
return extra_input | console_processed_input | console_line_input | console_echo_input;
|
|
uint32_t mode = extra_input;
|
|
if (settings.c_lflag & ISIG)
|
|
mode |= console_processed_input;
|
|
if (settings.c_lflag & ICANON) {
|
|
mode |= console_line_input;
|
|
if (settings.c_lflag & ECHO)
|
|
mode |= console_echo_input;
|
|
}
|
|
return mode;
|
|
}
|
|
|
|
bool TerminalConsole::set_input_mode(int descriptor, uint32_t mode)
|
|
{
|
|
if ((mode & ~console_input_modes) || ((mode & console_echo_input) && !(mode & console_line_input)))
|
|
return false;
|
|
termios settings{};
|
|
if (::tcgetattr(descriptor, &settings))
|
|
return false;
|
|
remember(descriptor);
|
|
const auto flag = [](tcflag_t &flags, tcflag_t bits, bool on) { flags = on ? flags | bits : flags & ~bits; };
|
|
const bool line_input = mode & console_line_input;
|
|
flag(settings.c_lflag, ICANON, line_input);
|
|
flag(settings.c_lflag, ECHO | ECHOE | ECHOK, mode & console_echo_input);
|
|
flag(settings.c_lflag, ISIG, mode & console_processed_input);
|
|
// Enter is CR outside line mode; a line ends in the LF the terminal makes of it.
|
|
flag(settings.c_iflag, ICRNL, line_input);
|
|
if (!line_input) {
|
|
settings.c_cc[VMIN] = 1;
|
|
settings.c_cc[VTIME] = 0;
|
|
}
|
|
if (::tcsetattr(descriptor, TCSANOW, &settings))
|
|
return false;
|
|
extra_input = mode & ~(console_processed_input | console_line_input | console_echo_input);
|
|
return true;
|
|
}
|
|
|
|
void TerminalConsole::set_output_mode(int descriptor, uint32_t mode)
|
|
{
|
|
output = mode;
|
|
termios settings{};
|
|
if (::tcgetattr(descriptor, &settings))
|
|
return;
|
|
// A line feed returns the carriage too, unless a virtual terminal program asked that it not.
|
|
const bool return_carriage = !((mode & console_disable_newline_auto_return) && (mode & console_virtual_terminal_processing));
|
|
if (bool(settings.c_oflag & ONLCR) == return_carriage)
|
|
return;
|
|
remember(descriptor);
|
|
settings.c_oflag = return_carriage ? settings.c_oflag | ONLCR : settings.c_oflag & ~tcflag_t(ONLCR);
|
|
::tcsetattr(descriptor, TCSADRAIN, &settings);
|
|
}
|
|
|
|
bool TerminalConsole::write(int descriptor, std::string_view bytes)
|
|
{
|
|
const bool written = write_all(descriptor, bytes);
|
|
note_output(descriptor, bytes);
|
|
return written;
|
|
}
|
|
|
|
void TerminalConsole::follow_size(int descriptor)
|
|
{
|
|
const auto window = size(descriptor);
|
|
if (sized && window.columns == tracker.columns() && window.rows == tracker.rows())
|
|
return;
|
|
tracker.resize(window.columns, window.rows);
|
|
if (!sized)
|
|
// Until the terminal says otherwise, the cursor starts a line at the bottom, where a shell leaves it after a while.
|
|
tracker.place(0, int16_t(window.rows - 1));
|
|
else
|
|
stale = true;
|
|
sized = true;
|
|
}
|
|
|
|
void TerminalConsole::note_output(int descriptor, std::string_view bytes)
|
|
{
|
|
follow_size(descriptor);
|
|
termios settings{};
|
|
const bool return_on_newline = ::tcgetattr(descriptor, &settings) || ((settings.c_oflag & OPOST) && (settings.c_oflag & ONLCR));
|
|
tracker.feed(bytes, return_on_newline);
|
|
}
|
|
|
|
void TerminalConsole::gather(int descriptor, bool raw)
|
|
{
|
|
if (finished)
|
|
return;
|
|
termios settings{}, saved{};
|
|
bool changed = false;
|
|
const bool known = !::tcgetattr(descriptor, &settings);
|
|
const bool canonical = known && (settings.c_lflag & ICANON) && !raw;
|
|
if (raw && known && (settings.c_lflag & ICANON)) {
|
|
// Keys are read as they are typed, not when the line is done, and are not echoed, as ReadConsoleInput sees them.
|
|
saved = settings;
|
|
settings.c_lflag &= ~tcflag_t(ICANON | ECHO);
|
|
settings.c_cc[VMIN] = 0;
|
|
settings.c_cc[VTIME] = 0;
|
|
changed = !::tcsetattr(descriptor, TCSANOW, &settings);
|
|
}
|
|
for (;;) {
|
|
pollfd entry{descriptor, POLLIN, 0};
|
|
if (::poll(&entry, 1, 0) <= 0 || !(entry.revents & (POLLIN | POLLHUP | POLLERR)))
|
|
break;
|
|
char chunk[4096];
|
|
const auto count = ::read(descriptor, chunk, sizeof chunk);
|
|
if (count < 0) {
|
|
if (errno == EINTR)
|
|
continue;
|
|
if (errno != EAGAIN)
|
|
finished = true;
|
|
break;
|
|
}
|
|
if (!count) {
|
|
finished = true;
|
|
break;
|
|
}
|
|
pending.append(chunk, size_t(count));
|
|
if (canonical) {
|
|
// The terminal hands over one line per read.
|
|
line_complete = true;
|
|
break;
|
|
}
|
|
}
|
|
if (changed)
|
|
::tcsetattr(descriptor, TCSANOW, &saved);
|
|
}
|
|
|
|
bool TerminalConsole::line_ready(int descriptor)
|
|
{
|
|
if (!line.empty())
|
|
return true;
|
|
if (pending.find('\n') == pending.npos && !line_complete)
|
|
gather(descriptor, false);
|
|
const auto newline = pending.find('\n');
|
|
if (newline == pending.npos && !line_complete && !finished)
|
|
return false;
|
|
const size_t length = newline == pending.npos ? pending.size() : newline + 1;
|
|
line = from_utf8(std::string_view(pending).substr(0, length));
|
|
// The terminal echoed the line as it was typed, so the cursor moved over it and onto the next line.
|
|
termios settings{};
|
|
if (!::tcgetattr(descriptor, &settings) && (settings.c_lflag & ECHO))
|
|
note_output(descriptor, std::string_view(pending).substr(0, length));
|
|
pending.erase(0, length);
|
|
line_complete = false;
|
|
if (!line.empty() && line.back() == u'\n' && (line.size() < 2 || line[line.size() - 2] != u'\r'))
|
|
line.insert(line.size() - 1, 1, u'\r');
|
|
return !line.empty() || finished;
|
|
}
|
|
|
|
std::u16string TerminalConsole::take_line(size_t limit)
|
|
{
|
|
auto taken = line.substr(0, limit);
|
|
line.erase(0, taken.size());
|
|
return taken;
|
|
}
|
|
|
|
bool TerminalConsole::keys_ready(int descriptor, bool settle)
|
|
{
|
|
if (resized_flag) {
|
|
resized_flag = 0;
|
|
follow_size(descriptor);
|
|
stale = true;
|
|
if (extra_input & console_window_input) {
|
|
const auto window = size(descriptor);
|
|
ConsoleKey event;
|
|
event.event = 4;
|
|
event.key = uint16_t(window.columns);
|
|
event.scan = uint16_t(window.rows);
|
|
keys.push_back(event);
|
|
}
|
|
}
|
|
const size_t before = pending.size();
|
|
gather(descriptor, true);
|
|
// An escape sequence still cut off after a moment is not coming: its ESC was the Escape key.
|
|
const auto now = std::chrono::steady_clock::now();
|
|
if (pending.size() != before || pending.empty())
|
|
partial_since = now;
|
|
else if (now - partial_since >= std::chrono::milliseconds(30))
|
|
settle = true;
|
|
std::vector<ConsoleKey> decoded;
|
|
const size_t used = decode_terminal_keys(pending, settle, extra_input & console_virtual_terminal_input, decoded);
|
|
pending.erase(0, used);
|
|
keys.insert(keys.end(), decoded.begin(), decoded.end());
|
|
return !keys.empty();
|
|
}
|
|
|
|
std::vector<ConsoleKey> TerminalConsole::peek_keys(size_t limit, bool remove)
|
|
{
|
|
const size_t count = std::min(limit, keys.size());
|
|
std::vector<ConsoleKey> taken(keys.begin(), keys.begin() + long(count));
|
|
if (remove)
|
|
keys.erase(keys.begin(), keys.begin() + long(count));
|
|
return taken;
|
|
}
|
|
|
|
std::u16string TerminalConsole::take_characters(size_t limit)
|
|
{
|
|
std::u16string characters;
|
|
while (!keys.empty() && characters.size() < limit) {
|
|
const auto key = keys.front();
|
|
keys.pop_front();
|
|
if (key.event == 1 && key.down && key.character)
|
|
characters += key.character;
|
|
}
|
|
return characters;
|
|
}
|
|
|
|
void TerminalConsole::flush_input(int descriptor)
|
|
{
|
|
::tcflush(descriptor, TCIFLUSH);
|
|
pending.clear();
|
|
keys.clear();
|
|
line.clear();
|
|
line_complete = false;
|
|
}
|
|
|
|
TerminalConsole::Size TerminalConsole::size(int descriptor) const
|
|
{
|
|
winsize window{};
|
|
if (::ioctl(descriptor, TIOCGWINSZ, &window) || !window.ws_col || !window.ws_row)
|
|
return {80, 25};
|
|
return {int16_t(std::min<int>(window.ws_col, 0x7fff)), int16_t(std::min<int>(window.ws_row, 0x7fff))};
|
|
}
|
|
|
|
std::pair<int16_t, int16_t> TerminalConsole::cursor(int descriptor)
|
|
{
|
|
follow_size(descriptor);
|
|
if (stale && cursor_answers) {
|
|
if (const auto position = query_cursor(descriptor))
|
|
tracker.place(position->first, position->second);
|
|
}
|
|
stale = false;
|
|
return {tracker.column(), tracker.row()};
|
|
}
|
|
|
|
std::optional<std::pair<int16_t, int16_t>> TerminalConsole::query_cursor(int descriptor)
|
|
{
|
|
if (!cursor_answers)
|
|
return std::nullopt;
|
|
// The answer comes back on the terminal's input: the controlling terminal, else the output descriptor when it reads as well.
|
|
int input = ::open("/dev/tty", O_RDWR | O_NOCTTY | O_CLOEXEC);
|
|
const bool opened = input >= 0;
|
|
if (!opened)
|
|
input = descriptor;
|
|
termios settings{}, saved{};
|
|
bool changed = false;
|
|
if (!::tcgetattr(input, &settings)) {
|
|
saved = settings;
|
|
settings.c_lflag &= ~tcflag_t(ICANON | ECHO);
|
|
settings.c_cc[VMIN] = 0;
|
|
settings.c_cc[VTIME] = 0;
|
|
changed = !::tcsetattr(input, TCSANOW, &settings);
|
|
}
|
|
std::optional<std::pair<int16_t, int16_t>> position;
|
|
if (write_all(descriptor, "\x1b[6n")) {
|
|
std::string answer;
|
|
const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(200);
|
|
while (!position) {
|
|
const auto left = std::chrono::duration_cast<std::chrono::milliseconds>(deadline - std::chrono::steady_clock::now()).count();
|
|
pollfd entry{input, POLLIN, 0};
|
|
if (left <= 0 || ::poll(&entry, 1, int(left)) <= 0)
|
|
break;
|
|
char chunk[256];
|
|
const auto count = ::read(input, chunk, sizeof chunk);
|
|
if (count <= 0) {
|
|
if (count < 0 && errno == EINTR)
|
|
continue;
|
|
break;
|
|
}
|
|
answer.append(chunk, size_t(count));
|
|
// ESC [ row ; column R, among whatever was typed ahead; the typing stays for the next read.
|
|
for (size_t start = answer.find("\x1b["); start != answer.npos; start = answer.find("\x1b[", start + 1)) {
|
|
int row = 0, column = 0;
|
|
size_t at = start + 2;
|
|
while (at < answer.size() && answer[at] >= '0' && answer[at] <= '9')
|
|
row = row * 10 + (answer[at++] - '0');
|
|
if (at >= answer.size() || answer[at++] != ';')
|
|
continue;
|
|
while (at < answer.size() && answer[at] >= '0' && answer[at] <= '9')
|
|
column = column * 10 + (answer[at++] - '0');
|
|
if (at >= answer.size() || answer[at] != 'R' || !row || !column)
|
|
continue;
|
|
position = std::pair{int16_t(column - 1), int16_t(row - 1)};
|
|
answer.erase(start, at + 1 - start);
|
|
break;
|
|
}
|
|
}
|
|
pending += answer;
|
|
}
|
|
if (changed)
|
|
::tcsetattr(input, TCSANOW, &saved);
|
|
if (opened)
|
|
::close(input);
|
|
if (!position)
|
|
cursor_answers = false;
|
|
return position;
|
|
}
|
|
|
|
std::string TerminalConsole::motion(int16_t column, int16_t row) const
|
|
{
|
|
std::string sequence;
|
|
const int rows = row - tracker.row();
|
|
if (rows < 0)
|
|
sequence += "\x1b[" + std::to_string(-rows) + 'A';
|
|
else if (rows > 0)
|
|
sequence += "\x1b[" + std::to_string(rows) + 'B';
|
|
return sequence + "\x1b[" + std::to_string(column + 1) + 'G';
|
|
}
|
|
|
|
bool TerminalConsole::move_cursor(int descriptor, int16_t column, int16_t row)
|
|
{
|
|
cursor(descriptor);
|
|
return write(descriptor, motion(column, row));
|
|
}
|
|
|
|
bool TerminalConsole::write_characters(int descriptor, std::u16string_view text, int16_t column, int16_t row)
|
|
{
|
|
cursor(descriptor);
|
|
return write(descriptor, "\x1b" "7" + motion(column, row) + to_utf8(text) + "\x1b" "8");
|
|
}
|
|
|
|
bool TerminalConsole::scroll(int descriptor, int16_t top, int16_t bottom, int lines)
|
|
{
|
|
if (!lines)
|
|
return true;
|
|
// Inside a scrolling region of just those rows; setting the region homes the cursor, so it is saved and put back around it.
|
|
const auto amount = std::to_string(lines > 0 ? lines : -lines);
|
|
return write(descriptor, "\x1b" "7\x1b[" + std::to_string(top + 1) + ';' + std::to_string(bottom + 1) + 'r' + "\x1b[" + amount +
|
|
(lines > 0 ? 'S' : 'T') + "\x1b[r\x1b" "8");
|
|
}
|
|
|
|
bool TerminalConsole::set_attribute(int descriptor, uint16_t attribute)
|
|
{
|
|
colours = attribute;
|
|
return write_all(descriptor, attribute_sequence(attribute));
|
|
}
|
|
|
|
bool TerminalConsole::set_cursor_info(int descriptor, uint32_t percent, bool show)
|
|
{
|
|
if (percent < 1 || percent > 100)
|
|
return false;
|
|
cursor_percent = percent;
|
|
visible = show;
|
|
return write_all(descriptor, show ? "\x1b[?25h" : "\x1b[?25l");
|
|
}
|
|
|
|
bool TerminalConsole::fill_characters(int descriptor, char16_t character, uint32_t count, int16_t column, int16_t row)
|
|
{
|
|
const auto window = size(descriptor);
|
|
// Blanking the whole screen, as programs clear it, is one erase in the current colours.
|
|
if (character == u' ' && !column && !row && count >= uint32_t(window.columns) * uint32_t(window.rows))
|
|
return write(descriptor, "\x1b" "7\x1b[H\x1b[2J\x1b" "8");
|
|
const uint32_t cells = std::min<uint32_t>(count, uint32_t(window.columns) * uint32_t(window.rows));
|
|
cursor(descriptor);
|
|
std::string sequence = "\x1b" "7" + motion(column, row);
|
|
const auto glyph = utf8_of(character);
|
|
for (uint32_t index = 0; index < cells; ++index)
|
|
sequence += glyph;
|
|
return write(descriptor, sequence + "\x1b" "8");
|
|
}
|
|
|
|
bool TerminalConsole::set_title(int descriptor, std::u16string text)
|
|
{
|
|
caption = std::move(text);
|
|
if (descriptor < 0)
|
|
return true;
|
|
return write_all(descriptor, "\x1b]0;" + to_utf8(caption) + '\a');
|
|
}
|
|
|
|
}
|