262 lines
8.5 KiB
C++

#include <Tactility/app/terminal/KeyboardInput.h>
#include <Tactility/app/terminal/Scrollback.h>
#include <Tactility/app/terminal/Shell.h>
#include <Tactility/app/terminal/Terminal.h>
#include <Tactility/app/terminal/TerminalRenderer.h>
#include <Tactility/app/terminal/TouchInput.h>
#include <tactility/device.h>
#include <tactility/drivers/keyboard.h>
#include <tactility/log.h>
#include <tactility/freertos/freertos.h>
#include <tactility/freertos/semphr.h>
#include <tactility/freertos/task.h>
#include <atomic>
#ifdef ESP_PLATFORM
#include <esp_log.h>
#endif
extern "C" {
#include <Tactility/app/terminal/vterm/vterm.h>
}
constexpr auto* TAG = "terminal";
// Keyboard polling and redraw cadence. Shell output wakes the I/O task early, so this mostly bounds
// how long a keypress waits to be picked up.
constexpr uint32_t RENDER_INTERVAL_MS = 16;
// The I/O task polls the keyboards, paints the screen, and watches for the touch-to-exit gesture.
// It runs a step above this app's own task: input, drawing and the exit gesture must keep working
// while that task is blocked draining a shell that is itself blocked running something, which is
// the entire reason this is a separate task.
constexpr uint32_t IO_TASK_STACK = 8 * 1024;
constexpr UBaseType_t IO_TASK_PRIORITY = 6;
// Lines of scrollback kept, and how far one Ctrl+Up/Down moves the view. 500 lines of an 80-column
// terminal is about 80KB, which is nothing in PSRAM and covers any realistic burst of output.
constexpr int SCROLLBACK_LINES = 500;
constexpr int SCROLL_STEP_LINES = 5;
namespace {
/** Set once the shell should wind down (touch-to-exit in ioTask(), or APP_EVENT_CLOSE in runShell()). Read by runTerminal()'s own
* pump loop, which closes the shell app's stdin to unstick it (see that loop's own comment). */
volatile bool stopRequested = false;
/** Set once runShell() has returned. The I/O task keeps running until then, because runShell()
* notifies it about output until the very end. */
std::atomic<bool> shellFinished = false;
/**
* Translates a kernel keyboard event into the byte a terminal expects.
* Returns 0 for keys with no terminal representation.
*/
char translateKey(const KeyboardKeyData& data) {
// Ctrl chords first, so they win over the plain-letter reading of the same key code.
if (data.ctrl && data.key >= 'a' && data.key <= 'z') {
return static_cast<char>(data.key & 0x1F);
}
if (data.ctrl && data.key >= 'A' && data.key <= 'Z') {
return static_cast<char>(data.key & 0x1F);
}
switch (data.key) {
case CODEPOINT_ENTER: return '\r';
case CODEPOINT_BACKSPACE: return 0x7F; // DEL, which is what linenoise expects for backspace
case CODEPOINT_ESCAPE: return 0x1B;
case CODEPOINT_DELETE: return 0x7F;
default: break;
}
if (data.key == CODEPOINT_TAB && !data.ctrl) {
return '\t';
}
if (data.key >= 0x20 && data.key < 0x7F) {
return static_cast<char>(data.key);
}
return 0;
}
/**
* Feeds arrow keys to the terminal as ANSI escape sequences,
* which is how linenoise recognises history navigation and cursor movement.
*/
void feedArrowKey(uint32_t key) {
const char* sequence = nullptr;
switch (key) {
case CODEPOINT_ARROW_UP: sequence = "\x1B[A"; break;
case CODEPOINT_ARROW_DOWN: sequence = "\x1B[B"; break;
case CODEPOINT_ARROW_RIGHT: sequence = "\x1B[C"; break;
case CODEPOINT_ARROW_LEFT: sequence = "\x1B[D"; break;
default: return;
}
for (const char* c = sequence; *c != '\0'; c++) {
vterm_send_input(vterm_get_active(), *c);
}
}
/**
* Handles one key from a keyboard.
* @return true if the scrollback view moved
*/
bool handleKey(unsigned int key, bool ctrl, bool alt) {
(void)alt;
if (ctrl) {
if (key == CODEPOINT_ARROW_UP) {
return Scrollback::scroll(SCROLL_STEP_LINES);
}
if (key == CODEPOINT_ARROW_DOWN) {
return Scrollback::scroll(-SCROLL_STEP_LINES);
}
}
// Any other key returns to the live screen: someone typing wants to see the prompt they are
// typing at rather than whatever history was being reviewed.
const bool viewChanged = Scrollback::reset();
// Plain arrows become escape sequences; everything else maps to a single byte.
if (!ctrl && (key == CODEPOINT_ARROW_UP || key == CODEPOINT_ARROW_DOWN ||
key == CODEPOINT_ARROW_LEFT || key == CODEPOINT_ARROW_RIGHT)) {
feedArrowKey(key);
return viewChanged;
}
KeyboardKeyData data {};
data.key = key;
data.pressed = true;
data.ctrl = ctrl;
data.alt = alt;
const char c = translateKey(data);
if (c != 0) {
vterm_send_input(vterm_get_active(), c);
}
return viewChanged;
}
/**
* Parameters for the I/O task. Lives in runTerminal()'s frame, which outlives the task itself:
* runTerminal() does not return until the I/O task has acknowledged the stop request.
*/
struct IoTaskParams {
KeyboardInput* keyboards;
TouchInput* touch;
TerminalRenderer* renderer;
SemaphoreHandle_t doneSem;
};
/** Keyboard/touch input and display rendering. */
void ioTask(void* arg) {
auto* params = static_cast<IoTaskParams*>(arg);
while (!shellFinished) {
// Keys belong to whichever window is on top, so they are left alone while that is not this one.
const bool shown = params->renderer->isShown();
params->keyboards->setExclusive(shown);
// A scroll replaces every row at once, so the renderer is told to repaint rather than rely
// on its per-cell comparison.
const bool viewMoved = shown && params->keyboards->pump(handleKey);
params->renderer->render(viewMoved);
if (!stopRequested && params->touch != nullptr && params->touch->touched(!params->keyboards->empty())) {
LOG_I(TAG, "Touch detected - stopping");
stopRequested = true;
}
// runShell() notifies this task when output arrives, so it is drawn without waiting out the interval
ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(RENDER_INTERVAL_MS));
}
// The shell's last output can arrive after the final pass above, so draw once more
params->renderer->render(false);
xSemaphoreGive(params->doneSem);
vTaskDelete(nullptr);
}
} // namespace
void runTerminal(Device* display, TerminalRenderer& renderer, bool touchToExit) {
stopRequested = false;
shellFinished = false;
KeyboardInput keyboards;
TouchInput touch;
if (vterm_init() != ERROR_NONE) {
LOG_E(TAG, "vterm_init failed");
return;
}
if (!renderer.begin(display)) {
LOG_E(TAG, "Renderer failed to start");
vterm_deinit();
return;
}
// Tell vterm the real drawable size, which may be smaller than its compiled-in grid.
vterm_set_size_override(renderer.rows(), renderer.columns());
// History of lines that scroll off the top. vterm discards them, so the callback below catches
// each one while it is still readable. Failure is not fatal; the terminal simply has no scrollback.
if (Scrollback::begin(renderer.columns(), SCROLLBACK_LINES)) {
vterm_set_scroll_callback(scrollback_capture_top_line);
}
renderer.render(true);
// Input, drawing and the touch-to-exit gesture run on their own task, so that all three keep
// working while this one is blocked draining the shell app. See ioTask().
IoTaskParams ioParams {
.keyboards = &keyboards,
.touch = touchToExit ? &touch : nullptr,
.renderer = &renderer,
.doneSem = xSemaphoreCreateBinary(),
};
TaskHandle_t ioHandle = nullptr;
if (ioParams.doneSem != nullptr) {
if (xTaskCreate(ioTask, "terminal-io", IO_TASK_STACK, &ioParams,
IO_TASK_PRIORITY, &ioHandle) != pdPASS) {
ioHandle = nullptr;
}
}
if (ioHandle == nullptr) {
LOG_E(TAG, "I/O task failed to start - the terminal cannot run");
} else {
#ifdef ESP_PLATFORM
esp_log_level_set("ELF", ESP_LOG_WARN);
#endif
runShell(renderer.columns(), renderer.rows(), &stopRequested, ioHandle);
shellFinished = true;
xTaskNotifyGive(ioHandle);
#ifdef ESP_PLATFORM
esp_log_level_set("ELF", ESP_LOG_INFO);
#endif
}
if (ioHandle != nullptr) {
xSemaphoreTake(ioParams.doneSem, portMAX_DELAY);
}
if (ioParams.doneSem != nullptr) {
vSemaphoreDelete(ioParams.doneSem);
}
vterm_set_scroll_callback(nullptr);
Scrollback::end();
renderer.end();
vterm_deinit();
LOG_I(TAG, "Terminal stopped");
}