SSH client: a terminal on another machine (#2)
CI / build (pull_request) Successful in 2m22s
Site / build (pull_request) Successful in 10s

The SSH App opens one session to a shell, over libssh (LibSSH-ESP32 5.10.0)
on mbedTLS. A server is trusted the first time on its fingerprint, and a
changed key is a warning with Cancel selected. The password is typed each
time and kept nowhere; or the device makes itself an Ed25519 key, whose
public half is shown, written to /ssh/id_ed25519.pub and printed by
`ssh status`.

lib/term is the terminal: what a shell, less, top, nano and vim send, with
sixteen colours, scroll regions, the alternate screen and 100 lines of
scrollback. Five text sizes with Ctrl and + or -, from 60x20 to 26x8, told
to the far end. The session goes on when the App is left; SSH shows in the
Status Bar. `ssh user@host` in the Shell opens the App.

Also:
- Keys that aren't characters carry Shift, Ctrl and Alt. The terminal needs
  it, and it makes Ctrl+Fn+up/down in a note and Shift+Tab in Gemini work
  from the real keyboard.
- IRC doesn't try to connect under 60 KB free: started with a session open,
  its TLS handshake took the heap down to 236 bytes.
- libssh's own curve25519 is left out of the build (scripts/libssh_filter.py):
  libsodium's has the same names.

Costs 292 KB of flash and about 50 KB of heap while a session is open; not
started under 75 KB free.

Docs: guide page, how-to, FAQ, home page, Status Bar, SD card folders, the
memory how-to, README, glossary, N1 notes with Q254 to Q266 and the checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-08 04:38:36 +02:00
co-authored by Claude Opus 5.5
parent 7223147f26
commit 6b71aace4f
47 changed files with 2801 additions and 20 deletions
+28
View File
@@ -313,6 +313,34 @@ inline constexpr KeyHelp kVpn[] = {
{"; .", "up, down"},
};
// ssh: SSH, the hosts
inline constexpr KeyHelp kSsh[] = {
{"Enter", "connect, open"},
{"; .", "up, down"},
{"n", "a new connection"},
{"d", "forget this host"},
};
// ssh-terminal: SSH, the terminal
inline constexpr KeyHelp kSshTerminal[] = {
{"`", "Esc"},
{"Alt `", "a backtick"},
{"Fn ; . , /", "the arrows"},
{"Fn Shift ; .", "Page Up, Page Down"},
{"Ctrl a..z", "Ctrl+C and the rest"},
{"Alt ; .", "scroll back, forward"},
{"Ctrl + -", "larger, smaller text"},
{"Ctrl Alt q", "disconnect"},
{"Fn `", "leave it running"},
};
// ssh-key: SSH, this device's key
inline constexpr KeyHelp kSshKey[] = {
{"Enter", "make a key, or a new one"},
{"w", "write the public half to the card"},
{"`", "back"},
};
// notes: Notes, the list
inline constexpr KeyHelp kNotes[] = {
{"; .", "up, down"},
+17 -8
View File
@@ -43,6 +43,15 @@ KeyEvent charEvent(uint32_t cp, const RawKeys& keys) {
return e;
}
// A key that isn't a character, with what was held: a terminal tells Alt+Enter from Enter (issue #2).
KeyEvent keyEvent(Key key, const RawKeys& keys) {
KeyEvent e = KeyEvent::of(key);
e.shift = keys.shift;
e.ctrl = keys.ctrl;
e.alt = keys.alt;
return e;
}
} // namespace
std::vector<KeyEvent> KeyMapper::update(const RawKeys& keys) {
@@ -51,9 +60,9 @@ std::vector<KeyEvent> KeyMapper::update(const RawKeys& keys) {
if (keys.opt && !previous_.opt && keys.chars.empty()) {
compose_ = compose_ ? 0 : kArmed; // a second opt cancels
}
if (keys.enter && !previous_.enter) out.push_back(KeyEvent::of(Key::Select));
if (keys.del && !previous_.del) out.push_back(KeyEvent::of(Key::Delete));
if (keys.tab && !previous_.tab) out.push_back(KeyEvent::of(Key::Tab));
if (keys.enter && !previous_.enter) out.push_back(keyEvent(Key::Select, keys));
if (keys.del && !previous_.del) out.push_back(keyEvent(Key::Delete, keys));
if (keys.tab && !previous_.tab) out.push_back(keyEvent(Key::Tab, keys));
for (char c : keys.chars) {
bool wasHeld = std::find(previous_.chars.begin(), previous_.chars.end(), c) != previous_.chars.end();
@@ -89,10 +98,10 @@ void KeyMapper::onChar(char c, const RawKeys& keys, std::vector<KeyEvent>& out)
if (keys.fn || !textEntry_) {
switch (c) {
case ';': out.push_back(KeyEvent::of(Key::Up)); return;
case '.': out.push_back(KeyEvent::of(Key::Down)); return;
case ',': out.push_back(KeyEvent::of(Key::Left)); return;
case '/': out.push_back(KeyEvent::of(Key::Right)); return;
case ';': out.push_back(keyEvent(Key::Up, keys)); return;
case '.': out.push_back(keyEvent(Key::Down, keys)); return;
case ',': out.push_back(keyEvent(Key::Left, keys)); return;
case '/': out.push_back(keyEvent(Key::Right, keys)); return;
case '`':
if (keys.fn) {
out.push_back(KeyEvent::of(Key::Home));
@@ -126,7 +135,7 @@ void KeyMapper::onChar(char c, const RawKeys& keys, std::vector<KeyEvent>& out)
}
}
if (c == '`') {
out.push_back(KeyEvent::of(Key::Back));
out.push_back(keyEvent(Key::Back, keys));
return;
}
out.push_back(charEvent(static_cast<unsigned char>(c), keys));
+5
View File
@@ -48,6 +48,11 @@ const Definition kDefinitions[] = {
{"help_told", Kind::Bool, 0, nullptr, 0, 1},
{"vpn_config", Kind::String, 0, "", 0, 900}, // empty, or a .conf that parses
{"vpn_auto", Kind::Bool, 0, nullptr, 0, 1},
{"ssh_hosts", Kind::String, 0, "", 0, 800},
{"ssh_known", Kind::String, 0, "", 0, 2400},
{"ssh_key", Kind::String, 0, "", 0, 800},
{"ssh_public", Kind::String, 0, "", 0, 200},
{"ssh_font", Kind::Int, 1, nullptr, 0, 4},
};
static_assert(sizeof(kDefinitions) / sizeof(kDefinitions[0]) == static_cast<size_t>(Setting::Count),
"every Setting needs a definition");
+5
View File
@@ -36,6 +36,11 @@ enum class Setting : uint8_t {
HelpTold, // bool: this device has been told about the help key once (issue #69, Q201)
VpnConfig, // string: the WireGuard tunnel as a .conf (wg_config.h), private key included: never shown (issue #8)
VpnAuto, // bool: the tunnel starts whenever Wi-Fi is connected (Q247: off unless switched on)
SshHosts, // string: the SSH App's saved hosts, one user@host[:port] a line (issue #2, ssh_hosts.h)
SshKnown, // string: the fingerprint each server showed first, one "host:port fingerprint" a line
SshKey, // string: this device's own SSH private key, as OpenSSH writes one: never shown
SshPublic, // string: its public half, the line to put in a server's authorized_keys
SshFont, // int: the terminal's font, 0 (smallest) to 4
Count
};
+108
View File
@@ -0,0 +1,108 @@
#include "ssh_hosts.h"
#include <algorithm>
namespace roro::term {
namespace {
std::vector<std::string> linesOf(const std::string& text) {
std::vector<std::string> out;
for (size_t at = 0; at < text.size();) {
size_t end = text.find('\n', at);
if (end == std::string::npos) end = text.size();
if (end > at) out.push_back(text.substr(at, end - at));
at = end + 1;
}
return out;
}
bool hostChars(const std::string& s) {
if (s.empty() || s.size() > 253) return false;
for (char c : s)
if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '.' || c == '-')) return false;
return s.front() != '.' && s.front() != '-';
}
} // namespace
std::string SshTarget::text() const { return user + "@" + host + (port == 22 ? "" : ":" + std::to_string(port)); }
std::string SshTarget::hostPort() const { return host + ":" + std::to_string(port); }
std::string parseSshTarget(const std::string& text, SshTarget& out) {
size_t at = text.find('@');
if (at == std::string::npos || at == 0) return "user@host, please";
SshTarget t;
t.user = text.substr(0, at);
std::string rest = text.substr(at + 1);
if (t.user.size() > 32 || t.user.find_first_of(" @:/") != std::string::npos) return "that isn't a user name";
size_t colon = rest.rfind(':');
if (colon != std::string::npos) {
std::string port = rest.substr(colon + 1);
long n = 0;
if (port.empty() || port.size() > 5) return "a port from 1 to 65535";
for (char c : port) {
if (c < '0' || c > '9') return "a port from 1 to 65535";
n = n * 10 + (c - '0');
}
if (n < 1 || n > 65535) return "a port from 1 to 65535";
t.port = static_cast<uint16_t>(n);
rest.resize(colon);
}
if (!hostChars(rest)) return "that isn't a host";
t.host = rest;
out = t;
return "";
}
SshHosts::SshHosts(const std::string& stored) {
for (auto& line : linesOf(stored)) {
SshTarget t;
if (hosts_.size() < kMax && parseSshTarget(line, t).empty()) hosts_.push_back(t.text());
}
}
void SshHosts::used(const SshTarget& target) {
std::string text = target.text();
hosts_.erase(std::remove(hosts_.begin(), hosts_.end(), text), hosts_.end());
hosts_.insert(hosts_.begin(), text);
if (hosts_.size() > kMax) hosts_.resize(kMax);
}
void SshHosts::remove(size_t index) {
if (index < hosts_.size()) hosts_.erase(hosts_.begin() + static_cast<long>(index));
}
std::string SshHosts::stored() const {
std::string s;
for (auto& h : hosts_) s += h + "\n";
return s;
}
SshKnownHosts::SshKnownHosts(const std::string& stored) {
for (auto& line : linesOf(stored)) {
size_t space = line.find(' ');
if (space != std::string::npos && space > 0 && space + 1 < line.size() && known_.size() < kMax) known_.emplace_back(line.substr(0, space), line.substr(space + 1));
}
}
std::string SshKnownHosts::fingerprintOf(const std::string& hostPort) const {
for (auto& k : known_)
if (k.first == hostPort) return k.second;
return "";
}
void SshKnownHosts::remember(const std::string& hostPort, const std::string& fingerprint) {
known_.erase(std::remove_if(known_.begin(), known_.end(), [&](const std::pair<std::string, std::string>& k) { return k.first == hostPort; }), known_.end());
known_.emplace_back(hostPort, fingerprint);
if (known_.size() > kMax) known_.erase(known_.begin());
}
void SshKnownHosts::forget(const std::string& hostPort) {
known_.erase(std::remove_if(known_.begin(), known_.end(), [&](const std::pair<std::string, std::string>& k) { return k.first == hostPort; }), known_.end());
}
std::string SshKnownHosts::stored() const {
std::string s;
for (auto& k : known_) s += k.first + " " + k.second + "\n";
return s;
}
} // namespace roro::term
+49
View File
@@ -0,0 +1,49 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
// Who the SSH App connects to, and which servers it has met (issue #2, N1 Q262 and Q263): kept
// in the device's settings as a few lines of text.
namespace roro::term {
struct SshTarget {
std::string user, host;
uint16_t port = 22;
std::string text() const; // user@host, with :port when it isn't 22
std::string hostPort() const; // host:port, always: what a host key is remembered under
};
// "user@host", "user@host:2222". "" or what is wrong with it.
std::string parseSshTarget(const std::string& text, SshTarget& out);
// Up to eight, the last used first; one a line.
class SshHosts {
public:
static constexpr size_t kMax = 8;
explicit SshHosts(const std::string& stored = "");
const std::vector<std::string>& list() const { return hosts_; }
void used(const SshTarget& target); // to the front, added if it's new
void remove(size_t index);
std::string stored() const;
private:
std::vector<std::string> hosts_;
};
// The fingerprint each server showed the first time: "host:port SHA256:...", one a line, sixteen
// at most (the oldest goes).
class SshKnownHosts {
public:
static constexpr size_t kMax = 16;
explicit SshKnownHosts(const std::string& stored = "");
std::string fingerprintOf(const std::string& hostPort) const; // "" if never met
void remember(const std::string& hostPort, const std::string& fingerprint);
void forget(const std::string& hostPort);
std::string stored() const;
private:
std::vector<std::pair<std::string, std::string>> known_;
};
} // namespace roro::term
+495
View File
@@ -0,0 +1,495 @@
#include "terminal.h"
#include <algorithm>
namespace roro::term {
namespace {
// A character the screen's font has, for one it may not.
uint8_t glyphFor(uint32_t cp) {
if (cp >= 0x20 && cp <= 0x7E) return static_cast<uint8_t>(cp);
if (cp >= 0xA0 && cp <= 0xFF) return static_cast<uint8_t>(cp);
if (cp >= 0x2500 && cp <= 0x257F) { // box drawing
switch (cp) {
case 0x2500: case 0x2501: case 0x2504: case 0x2505: case 0x2508: case 0x2509: case 0x254C: case 0x254D: case 0x2550: return '-';
case 0x2502: case 0x2503: case 0x2506: case 0x2507: case 0x250A: case 0x250B: case 0x254E: case 0x254F: case 0x2551: return '|';
default: return '+';
}
}
switch (cp) {
case 0x2018: case 0x2019: return '\'';
case 0x201C: case 0x201D: return '"';
case 0x2010: case 0x2011: case 0x2012: case 0x2013: case 0x2014: return '-';
case 0x2022: case 0x25CF: return '*';
case 0x2026: return '.';
case 0x2190: return '<';
case 0x2192: return '>';
case 0x2191: return '^';
case 0x2193: return 'v';
case 0x2588: case 0x2593: case 0x2592: case 0x2591: return '#';
default: return '?';
}
}
// The DEC "special graphics" set: lines drawn with the letters j to x.
uint8_t lineGlyph(uint8_t c) {
switch (c) {
case 'q': return '-';
case 'x': return '|';
case 'j': case 'k': case 'l': case 'm': case 'n': case 't': case 'u': case 'v': case 'w': return '+';
case '`': return '*';
case 'a': return '#';
case '~': return '*';
default: return c;
}
}
// One of 256 colours, or a colour given as red, green and blue, as the nearest of the sixteen.
int nearest16(int r, int g, int b) {
int most = std::max(r, std::max(g, b));
if (most < 48) return 0;
int half = most / 2;
int colour = (r > half ? 1 : 0) | (g > half ? 2 : 0) | (b > half ? 4 : 0);
if (colour == 7 && most < 200) return most < 110 ? 8 : 7;
return most > 170 ? colour | 8 : colour;
}
int from256(int n) {
if (n < 16) return n;
if (n >= 232) return nearest16(8 + (n - 232) * 10, 8 + (n - 232) * 10, 8 + (n - 232) * 10);
n -= 16;
static const int kSteps[6] = {0, 95, 135, 175, 215, 255};
return nearest16(kSteps[n / 36], kSteps[(n / 6) % 6], kSteps[n % 6]);
}
} // namespace
void colourRgb(int index, uint8_t& r, uint8_t& g, uint8_t& b) {
static const uint8_t kTable[16][3] = {{0, 0, 0}, {205, 49, 49}, {13, 188, 121}, {229, 229, 16}, {36, 114, 200}, {188, 63, 188},
{17, 168, 205}, {204, 204, 204}, {102, 102, 102}, {241, 76, 76}, {35, 209, 139}, {245, 245, 67},
{59, 142, 234}, {214, 112, 214}, {41, 184, 219}, {255, 255, 255}};
r = kTable[index & 15][0];
g = kTable[index & 15][1];
b = kTable[index & 15][2];
}
Terminal::Terminal(int cols, int rows, int historyLines)
: cols_(std::max(2, cols)), rows_(std::max(2, rows)), historyMax_(std::max(0, historyLines)), mainGrid_(static_cast<size_t>(cols_ * rows_)),
altGrid_(static_cast<size_t>(cols_ * rows_)), bottom_(rows_ - 1) {}
Cell Terminal::blank() const {
Cell c;
c.attr = static_cast<uint8_t>((bg_ << 4) | 7);
return c;
}
bool Terminal::takeBell() {
bool b = bell_;
bell_ = false;
return b;
}
std::string Terminal::rowText(int row) const {
std::string s;
for (int c = 0; c < cols_; c++) s += static_cast<char>(cell(row, c).ch);
size_t end = s.find_last_not_of(' ');
s.resize(end == std::string::npos ? 0 : end + 1);
return s;
}
int Terminal::param(size_t i, int fallback) const { return i < params_.size() && params_[i] > 0 ? params_[i] : fallback; }
void Terminal::moveTo(int row, int col) {
row_ = std::clamp(row, 0, rows_ - 1);
col_ = std::clamp(col, 0, cols_ - 1);
wrapPending_ = false;
}
void Terminal::eraseCells(int row, int from, int to) {
Cell b = blank();
for (int c = std::max(0, from); c <= std::min(cols_ - 1, to); c++) grid()[static_cast<size_t>(row * cols_ + c)] = b;
}
void Terminal::scrollUp(int top, int bottom, int n, bool toHistory) {
n = std::min(n, bottom - top + 1);
auto& g = grid();
for (int i = 0; i < n; i++) {
if (toHistory && !alt_ && top == 0 && historyMax_ > 0) { // off the top of the real screen: kept, as text
history_.push_back(rowText(0));
if (static_cast<int>(history_.size()) > historyMax_) history_.pop_front();
}
std::move(g.begin() + (top + 1) * cols_, g.begin() + (bottom + 1) * cols_, g.begin() + top * cols_);
eraseCells(bottom, 0, cols_ - 1);
}
}
void Terminal::scrollDown(int top, int bottom, int n) {
n = std::min(n, bottom - top + 1);
auto& g = grid();
for (int i = 0; i < n; i++) {
std::move_backward(g.begin() + top * cols_, g.begin() + bottom * cols_, g.begin() + (bottom + 1) * cols_);
eraseCells(top, 0, cols_ - 1);
}
}
void Terminal::lineFeed() {
wrapPending_ = false;
if (row_ == bottom_) scrollUp(top_, bottom_, 1);
else if (row_ < rows_ - 1) row_++;
}
void Terminal::reverseIndex() {
wrapPending_ = false;
if (row_ == top_) scrollDown(top_, bottom_, 1);
else if (row_ > 0) row_--;
}
void Terminal::put(uint32_t cp) {
uint8_t ch = lineDrawing_ && cp < 0x80 ? lineGlyph(static_cast<uint8_t>(cp)) : glyphFor(cp);
if (wrapPending_) {
col_ = 0;
lineFeed();
}
uint8_t fg = static_cast<uint8_t>(bold_ && fg_ < 8 ? fg_ | 8 : fg_), bg = bg_;
if (inverse_) std::swap(fg, bg);
Cell& c = grid()[static_cast<size_t>(row_ * cols_ + col_)];
c.ch = ch;
c.attr = static_cast<uint8_t>((bg << 4) | (fg & 15));
if (col_ == cols_ - 1) wrapPending_ = autoWrap_;
else col_++;
}
void Terminal::control(uint8_t c) {
switch (c) {
case 0x07: bell_ = true; break;
case 0x08:
if (col_ > 0) col_--;
wrapPending_ = false;
break;
case 0x09: moveTo(row_, std::min(cols_ - 1, (col_ / 8 + 1) * 8)); break;
case 0x0A: case 0x0B: case 0x0C: lineFeed(); break;
case 0x0D:
col_ = 0;
wrapPending_ = false;
break;
default: break;
}
}
void Terminal::reset() {
alt_ = false;
Cell b;
std::fill(mainGrid_.begin(), mainGrid_.end(), b);
std::fill(altGrid_.begin(), altGrid_.end(), b);
row_ = col_ = top_ = 0;
bottom_ = rows_ - 1;
fg_ = 7;
bg_ = 0;
bold_ = inverse_ = wrapPending_ = appCursor_ = lineDrawing_ = false;
autoWrap_ = cursorShown_ = true;
}
void Terminal::escape(uint8_t c) {
state_ = State::Ground;
switch (c) {
case '[':
state_ = State::Csi;
params_.clear();
paramStarted_ = private_ = false;
break;
case ']': case 'P': case '^': case '_': state_ = State::Osc; break; // a title, or something this doesn't read: skipped to its end
case '(': case ')': case '*': case '+': state_ = State::Charset; break;
case '7':
savedRow_ = row_;
savedCol_ = col_;
savedAttr_ = static_cast<uint8_t>((bg_ << 4) | fg_);
break;
case '8':
moveTo(savedRow_, savedCol_);
fg_ = savedAttr_ & 15;
bg_ = savedAttr_ >> 4;
break;
case 'D': lineFeed(); break;
case 'E':
col_ = 0;
lineFeed();
break;
case 'M': reverseIndex(); break;
case 'c': reset(); break;
default: break; // = and >, the keypad's modes, among others
}
}
void Terminal::sgr() {
if (params_.empty()) params_.push_back(0);
for (size_t i = 0; i < params_.size(); i++) {
int p = params_[i];
if (p == 0) {
fg_ = 7;
bg_ = 0;
bold_ = inverse_ = false;
} else if (p == 1) bold_ = true;
else if (p == 7) inverse_ = true;
else if (p == 22) bold_ = false;
else if (p == 27) inverse_ = false;
else if (p >= 30 && p <= 37) fg_ = static_cast<uint8_t>(p - 30);
else if (p == 39) fg_ = 7;
else if (p >= 40 && p <= 47) bg_ = static_cast<uint8_t>(p - 40);
else if (p == 49) bg_ = 0;
else if (p >= 90 && p <= 97) fg_ = static_cast<uint8_t>(p - 90 + 8);
else if (p >= 100 && p <= 107) bg_ = static_cast<uint8_t>(p - 100 + 8);
else if ((p == 38 || p == 48) && i + 1 < params_.size()) {
int colour = -1;
if (params_[i + 1] == 5 && i + 2 < params_.size()) {
colour = from256(params_[i + 2] & 255);
i += 2;
} else if (params_[i + 1] == 2 && i + 4 < params_.size()) {
colour = nearest16(params_[i + 2] & 255, params_[i + 3] & 255, params_[i + 4] & 255);
i += 4;
} else {
break;
}
(p == 38 ? fg_ : bg_) = static_cast<uint8_t>(colour);
}
}
}
void Terminal::mode(bool on) {
for (int p : params_) {
if (!private_) continue;
switch (p) {
case 1: appCursor_ = on; break;
case 7: autoWrap_ = on; break;
case 25: cursorShown_ = on; break;
case 47: case 1047: case 1049:
if (on == alt_) break;
if (on && p == 1049) {
savedRow_ = row_;
savedCol_ = col_;
}
alt_ = on;
if (on) std::fill(altGrid_.begin(), altGrid_.end(), Cell());
top_ = 0;
bottom_ = rows_ - 1;
if (!on && p == 1049) moveTo(savedRow_, savedCol_);
else if (on) moveTo(0, 0);
break;
default: break; // the mouse, bracketed paste and the rest
}
}
}
void Terminal::csi(uint8_t final) {
int n = param(0, 1);
switch (final) {
case 'A': moveTo(std::max(row_ - n, row_ >= top_ ? top_ : 0), col_); break;
case 'B': case 'e': moveTo(std::min(row_ + n, row_ <= bottom_ ? bottom_ : rows_ - 1), col_); break;
case 'C': case 'a': moveTo(row_, col_ + n); break;
case 'D': moveTo(row_, col_ - n); break;
case 'E': moveTo(row_ + n, 0); break;
case 'F': moveTo(row_ - n, 0); break;
case 'G': case '`': moveTo(row_, n - 1); break;
case 'd': moveTo(n - 1, col_); break;
case 'H': case 'f': moveTo(param(0, 1) - 1, param(1, 1) - 1); break;
case 'J': {
int what = params_.empty() ? 0 : params_[0];
if (what == 0) {
eraseCells(row_, col_, cols_ - 1);
for (int r = row_ + 1; r < rows_; r++) eraseCells(r, 0, cols_ - 1);
} else if (what == 1) {
for (int r = 0; r < row_; r++) eraseCells(r, 0, cols_ - 1);
eraseCells(row_, 0, col_);
} else {
for (int r = 0; r < rows_; r++) eraseCells(r, 0, cols_ - 1);
}
break;
}
case 'K': {
int what = params_.empty() ? 0 : params_[0];
eraseCells(row_, what == 0 ? col_ : 0, what == 1 ? col_ : cols_ - 1);
break;
}
case 'L':
if (row_ >= top_ && row_ <= bottom_) scrollDown(row_, bottom_, n);
break;
case 'M':
if (row_ >= top_ && row_ <= bottom_) scrollUp(row_, bottom_, n, false); // deleted, not scrolled away: not history
break;
case 'P': {
n = std::min(n, cols_ - col_);
auto row = grid().begin() + row_ * cols_;
std::move(row + col_ + n, row + cols_, row + col_);
eraseCells(row_, cols_ - n, cols_ - 1);
break;
}
case '@': {
n = std::min(n, cols_ - col_);
auto row = grid().begin() + row_ * cols_;
std::move_backward(row + col_, row + cols_ - n, row + cols_);
eraseCells(row_, col_, col_ + n - 1);
break;
}
case 'X': eraseCells(row_, col_, col_ + n - 1); break;
case 'S': scrollUp(top_, bottom_, n); break;
case 'T': scrollDown(top_, bottom_, n); break;
case 'm': sgr(); break;
case 'r': {
int top = param(0, 1) - 1, bottom = param(1, rows_) - 1;
if (top < bottom && bottom < rows_) {
top_ = top;
bottom_ = bottom;
} else {
top_ = 0;
bottom_ = rows_ - 1;
}
moveTo(0, 0);
break;
}
case 's':
savedRow_ = row_;
savedCol_ = col_;
break;
case 'u': moveTo(savedRow_, savedCol_); break;
case 'h': mode(true); break;
case 'l': mode(false); break;
case 'n':
if (!reply) break;
if (param(0, 0) == 6) reply("\x1b[" + std::to_string(row_ + 1) + ";" + std::to_string(col_ + 1) + "R");
else if (param(0, 0) == 5) reply("\x1b[0n");
break;
case 'c':
if (reply && !private_) reply("\x1b[?6c"); // "a VT102"
break;
default: break;
}
}
void Terminal::feed(const uint8_t* data, size_t len) {
for (size_t i = 0; i < len; i++) {
uint8_t c = data[i];
if (state_ == State::Osc || state_ == State::OscEsc) { // skipped: to a bell, or to ESC backslash
if (c == 0x07 || (state_ == State::OscEsc && c == '\\')) state_ = State::Ground;
else state_ = c == 0x1B ? State::OscEsc : State::Osc;
continue;
}
if (c == 0x1B) {
state_ = State::Esc;
utf8Left_ = 0;
continue;
}
if (c < 0x20) { // these act wherever they come, in the middle of a sequence too
if (c == 0x0E) lineDrawing_ = true;
else if (c == 0x0F) lineDrawing_ = false;
else control(c);
continue;
}
switch (state_) {
case State::Esc: escape(c); break;
case State::Charset:
lineDrawing_ = c == '0';
state_ = State::Ground;
break;
case State::Csi:
if (c >= '0' && c <= '9') {
if (!paramStarted_) {
if (params_.size() < 16) params_.push_back(0);
paramStarted_ = true;
}
if (!params_.empty() && params_.back() < 10000) params_.back() = params_.back() * 10 + (c - '0');
} else if (c == ';' || c == ':') {
if (!paramStarted_ && params_.size() < 16) params_.push_back(0);
paramStarted_ = false;
} else if (c == '?' || c == '>' || c == '=' || c == '<') {
private_ = true;
} else if (c >= 0x40 && c <= 0x7E) {
state_ = State::Ground;
csi(c);
} // anything else is an in-between byte: passed over
break;
default:
if (c == 0x7F) break;
if (c < 0x80) {
utf8Left_ = 0;
put(c);
} else if (c >= 0xC0) { // the first byte of a longer character
utf8Left_ = c >= 0xF0 ? 3 : c >= 0xE0 ? 2 : 1;
utf8_ = c & (c >= 0xF0 ? 0x07 : c >= 0xE0 ? 0x0F : 0x1F);
} else if (utf8Left_ > 0) {
utf8_ = (utf8_ << 6) | (c & 0x3F);
if (--utf8Left_ == 0) put(utf8_);
} else {
put('?'); // a byte that belongs to nothing
}
break;
}
}
revision_++;
}
void Terminal::resize(int cols, int rows) {
cols = std::max(2, cols);
rows = std::max(2, rows);
if (cols == cols_ && rows == rows_) return;
// The cursor's line stays on screen: what is above it goes to the history if it has to.
int shift = alt_ ? 0 : std::max(0, row_ - (rows - 1));
if (shift) {
int oldTop = top_, oldBottom = bottom_;
top_ = 0;
bottom_ = rows_ - 1;
scrollUp(0, rows_ - 1, shift);
top_ = oldTop;
bottom_ = oldBottom;
}
auto copy = [&](std::vector<Cell>& g) {
std::vector<Cell> fresh(static_cast<size_t>(cols * rows));
for (int r = 0; r < std::min(rows, rows_); r++)
for (int c = 0; c < std::min(cols, cols_); c++) fresh[static_cast<size_t>(r * cols + c)] = g[static_cast<size_t>(r * cols_ + c)];
g.swap(fresh);
};
copy(mainGrid_);
copy(altGrid_);
cols_ = cols;
rows_ = rows;
top_ = 0;
bottom_ = rows_ - 1;
moveTo(row_ - shift, col_);
savedRow_ = std::min(savedRow_, rows_ - 1);
savedCol_ = std::min(savedCol_, cols_ - 1);
revision_++;
}
std::string encodeKey(TermKey key, uint32_t ch, bool ctrl, bool alt, bool appCursorKeys) {
std::string out;
auto arrow = [&](char letter) { return std::string(appCursorKeys ? "\x1bO" : "\x1b[") + letter; };
switch (key) {
case TermKey::Up: out = arrow('A'); break;
case TermKey::Down: out = arrow('B'); break;
case TermKey::Right: out = arrow('C'); break;
case TermKey::Left: out = arrow('D'); break;
case TermKey::Enter: out = "\r"; break;
case TermKey::Backspace: out = "\x7f"; break;
case TermKey::Tab: out = "\t"; break;
case TermKey::Escape: out = "\x1b"; break;
case TermKey::PageUp: out = "\x1b[5~"; break;
case TermKey::PageDown: out = "\x1b[6~"; break;
case TermKey::Char:
if (ctrl) {
uint32_t c = ch >= 'a' && ch <= 'z' ? ch - 32 : ch;
if (c >= '@' && c <= '_') out = std::string(1, static_cast<char>(c - '@'));
else if (c == ' ' || c == '2') out = std::string(1, '\0');
else if (c == '?' || c == '8') out = "\x7f";
else if (c == '3') out = "\x1b";
else if (c == '4') out = "\x1c";
else if (c == '5') out = "\x1d";
else if (c == '6') out = "\x1e";
else if (c == '7' || c == '/') out = "\x1f";
break;
}
if (ch < 0x80) out = std::string(1, static_cast<char>(ch));
else if (ch < 0x800) out = {static_cast<char>(0xC0 | (ch >> 6)), static_cast<char>(0x80 | (ch & 0x3F))};
else if (ch < 0x10000) out = {static_cast<char>(0xE0 | (ch >> 12)), static_cast<char>(0x80 | ((ch >> 6) & 0x3F)), static_cast<char>(0x80 | (ch & 0x3F))};
else out = {static_cast<char>(0xF0 | (ch >> 18)), static_cast<char>(0x80 | ((ch >> 12) & 0x3F)), static_cast<char>(0x80 | ((ch >> 6) & 0x3F)),
static_cast<char>(0x80 | (ch & 0x3F))};
break;
}
if (alt && !out.empty() && key != TermKey::Escape) out.insert(0, 1, '\x1b');
return out;
}
} // namespace roro::term
+96
View File
@@ -0,0 +1,96 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <deque>
#include <functional>
#include <string>
#include <vector>
// A terminal's screen (issue #2, N1 Q256): what a remote program's output makes of a grid of
// characters. It understands what a shell, `less`, `top`, `nano` and plain `vim` send: the cursor,
// erasing, sixteen colours, scroll regions, the alternate screen. No mouse. Characters are kept
// as the screen's font has them (Latin-1); what it lacks becomes `?`, and box-drawing lines
// become + - |.
namespace roro::term {
struct Cell {
uint8_t ch = ' ';
uint8_t attr = 0x07; // low four bits: the colour of the character, high four: of what is behind it
};
class Terminal {
public:
Terminal(int cols, int rows, int historyLines);
void feed(const uint8_t* data, size_t len);
// A new size: what is on screen stays where it is, from the top left; if the cursor would fall
// off the bottom, the top lines go to the history.
void resize(int cols, int rows);
// What the program asked to be told (where the cursor is, what kind of terminal this is).
std::function<void(const std::string&)> reply;
int cols() const { return cols_; }
int rows() const { return rows_; }
const Cell& cell(int row, int col) const { return grid()[static_cast<size_t>(row * cols_ + col)]; }
int cursorRow() const { return row_; }
int cursorCol() const { return col_; }
bool cursorVisible() const { return cursorShown_; }
bool appCursorKeys() const { return appCursor_; } // the arrows are sent another way (vim, less)
bool altScreen() const { return alt_; }
uint32_t revision() const { return revision_; } // changes whenever the screen may have
bool takeBell();
// Lines that scrolled off the top of the main screen, oldest first; their text only.
int historyCount() const { return static_cast<int>(history_.size()); }
const std::string& historyLine(int i) const { return history_[static_cast<size_t>(i)]; }
std::string rowText(int row) const; // without trailing spaces
private:
enum class State { Ground, Esc, Csi, Osc, OscEsc, Charset };
std::vector<Cell>& grid() { return alt_ ? altGrid_ : mainGrid_; }
const std::vector<Cell>& grid() const { return alt_ ? altGrid_ : mainGrid_; }
Cell blank() const;
void put(uint32_t codePoint);
void control(uint8_t c);
void escape(uint8_t c);
void csi(uint8_t final);
void sgr();
void mode(bool on);
void lineFeed();
void reverseIndex();
void scrollUp(int top, int bottom, int n, bool toHistory = true);
void scrollDown(int top, int bottom, int n);
void eraseCells(int row, int from, int to);
void moveTo(int row, int col);
void reset();
int param(size_t i, int fallback) const;
int cols_, rows_, historyMax_;
std::vector<Cell> mainGrid_, altGrid_;
std::deque<std::string> history_;
bool alt_ = false;
int row_ = 0, col_ = 0, top_ = 0, bottom_ = 0;
int savedRow_ = 0, savedCol_ = 0;
uint8_t savedAttr_ = 0x07;
bool wrapPending_ = false, autoWrap_ = true, cursorShown_ = true, appCursor_ = false, lineDrawing_ = false, bell_ = false;
uint8_t fg_ = 7, bg_ = 0;
bool bold_ = false, inverse_ = false;
State state_ = State::Ground;
std::vector<int> params_;
bool paramStarted_ = false, private_ = false;
uint32_t utf8_ = 0;
int utf8Left_ = 0;
uint32_t revision_ = 0;
};
// What a key sends to the remote program.
enum class TermKey { Char, Up, Down, Left, Right, Enter, Backspace, Tab, Escape, PageUp, PageDown };
std::string encodeKey(TermKey key, uint32_t ch, bool ctrl, bool alt, bool appCursorKeys);
// One of the terminal's sixteen colours as red, green and blue.
void colourRgb(int index, uint8_t& r, uint8_t& g, uint8_t& b);
} // namespace roro::term