Files
LLS/src/LineEditor.cpp
T
2026-10-17 03:29:11 -03:00

368 lines
11 KiB
C++

#include "LineEditor.h"
#include <cctype>
#include <fstream>
#include <iostream>
#include "StringUtil.h"
#include "Terminal.h"
#if defined(__unix__) || defined(__APPLE__)
#define TT_HAVE_TERMIOS 1
#include <poll.h>
#include <termios.h>
#include <unistd.h>
#include <cerrno>
#endif
namespace {
const std::size_t kMaxHistory = 500;
#ifdef TT_HAVE_TERMIOS
// Puts the terminal into raw-ish mode and restores it on destruction.
class RawMode {
public:
RawMode();
~RawMode();
bool active() const;
private:
termios saved_;
bool active_;
};
RawMode::RawMode() : saved_(), active_(false) {
if (tcgetattr(STDIN_FILENO, &saved_) != 0) {
return;
}
termios raw = saved_;
raw.c_lflag &= static_cast<tcflag_t>(~(ICANON | ECHO | ISIG | IEXTEN));
raw.c_cc[VMIN] = 1;
raw.c_cc[VTIME] = 0;
active_ = (tcsetattr(STDIN_FILENO, TCSADRAIN, &raw) == 0);
}
RawMode::~RawMode() {
if (active_) {
tcsetattr(STDIN_FILENO, TCSADRAIN, &saved_);
}
}
bool RawMode::active() const {
return active_;
}
int readByte() {
unsigned char c = 0;
for (;;) {
ssize_t n = ::read(STDIN_FILENO, &c, 1);
if (n == 1) {
return c;
}
if (n == 0 || errno != EINTR) {
return -1;
}
}
}
bool byteAvailable(int milliseconds) {
pollfd descriptor;
descriptor.fd = STDIN_FILENO;
descriptor.events = POLLIN;
descriptor.revents = 0;
return poll(&descriptor, 1, milliseconds) > 0;
}
std::size_t previousChar(const std::string& text, std::size_t position) {
if (position == 0) {
return 0;
}
--position;
while (position > 0 && (static_cast<unsigned char>(text[position]) & 0xC0) == 0x80) {
--position;
}
return position;
}
std::size_t nextChar(const std::string& text, std::size_t position) {
if (position >= text.size()) {
return text.size();
}
++position;
while (position < text.size() && (static_cast<unsigned char>(text[position]) & 0xC0) == 0x80) {
++position;
}
return position;
}
std::size_t previousWord(const std::string& text, std::size_t position) {
while (position > 0 && std::isspace(static_cast<unsigned char>(text[position - 1]))) {
--position;
}
while (position > 0 && !std::isspace(static_cast<unsigned char>(text[position - 1]))) {
--position;
}
return position;
}
std::size_t nextWord(const std::string& text, std::size_t position) {
while (position < text.size() && std::isspace(static_cast<unsigned char>(text[position]))) {
++position;
}
while (position < text.size() && !std::isspace(static_cast<unsigned char>(text[position]))) {
++position;
}
return position;
}
void redraw(const std::string& prompt, const std::string& buffer, std::size_t cursor) {
std::string out = "\r" + prompt + buffer + "\x1b[K";
const std::size_t back = StringUtil::displayWidth(buffer.substr(cursor));
if (back > 0) {
out += "\x1b[" + std::to_string(back) + "D";
}
std::cout << out << std::flush;
}
std::string commonPrefix(const std::vector<std::string>& values) {
std::string prefix = values.front();
for (const std::string& value : values) {
std::size_t n = 0;
while (n < prefix.size() && n < value.size() && prefix[n] == value[n]) {
++n;
}
prefix.resize(n);
}
// never cut a UTF-8 character in half
while (!prefix.empty() && prefix.size() < values.front().size() &&
(static_cast<unsigned char>(values.front()[prefix.size()]) & 0xC0) == 0x80) {
prefix.pop_back();
}
return prefix;
}
void printCandidates(const std::vector<std::string>& candidates) {
const std::size_t lineWidth = 78;
std::string line;
std::cout << "\n";
for (const std::string& candidate : candidates) {
if (!line.empty() && StringUtil::displayWidth(line) + 2 + StringUtil::displayWidth(candidate) > lineWidth) {
std::cout << line << "\n";
line.clear();
}
line += line.empty() ? candidate : " " + candidate;
}
std::cout << line << "\n";
}
#endif // TT_HAVE_TERMIOS
} // namespace
LineEditor::LineEditor() = default;
void LineEditor::setCompleter(const CompletionFn& completer) {
completer_ = completer;
}
void LineEditor::setHistoryFile(const std::string& path) {
historyFile_ = path;
history_.clear();
std::ifstream in(path);
std::string line;
while (std::getline(in, line)) {
if (!line.empty()) {
history_.push_back(line);
}
}
in.close();
if (history_.size() > kMaxHistory) {
history_.erase(history_.begin(), history_.end() - static_cast<std::ptrdiff_t>(kMaxHistory));
std::ofstream out(path, std::ios::trunc);
for (const std::string& entry : history_) {
out << entry << "\n";
}
}
}
void LineEditor::addHistory(const std::string& line) {
if (StringUtil::trim(line).empty() || (!history_.empty() && history_.back() == line)) {
return;
}
history_.push_back(line);
if (history_.size() > kMaxHistory) {
history_.erase(history_.begin());
}
if (!historyFile_.empty()) {
std::ofstream out(historyFile_, std::ios::app);
out << line << "\n";
}
}
bool LineEditor::readLine(const std::string& prompt, std::string& line) {
#ifdef TT_HAVE_TERMIOS
if (Terminal::stdinIsInteractive() && Terminal::stdoutIsInteractive()) {
return readInteractive(prompt, line);
}
#endif
return readPlain(prompt, line);
}
bool LineEditor::readPlain(const std::string& prompt, std::string& line) {
std::cout << prompt << std::flush;
return static_cast<bool>(std::getline(std::cin, line));
}
#ifdef TT_HAVE_TERMIOS
bool LineEditor::readInteractive(const std::string& prompt, std::string& line) {
RawMode raw;
if (!raw.active()) {
return readPlain(prompt, line);
}
std::string buffer;
std::size_t cursor = 0;
std::size_t historyIndex = history_.size();
std::string pending; // the line being typed while browsing history
redraw(prompt, buffer, cursor);
for (;;) {
const int c = readByte();
if (c < 0) {
std::cout << "\n";
return false;
}
if (c == '\n' || c == '\r') {
std::cout << "\n" << std::flush;
line = buffer;
addHistory(buffer);
return true;
}
if (c == 4) { // Ctrl-D
if (buffer.empty()) {
std::cout << "\n";
return false;
}
buffer.erase(cursor, nextChar(buffer, cursor) - cursor);
} else if (c == 3) { // Ctrl-C
std::cout << "^C\n" << std::flush;
line.clear();
return true;
} else if (c == 1) { // Ctrl-A
cursor = 0;
} else if (c == 5) { // Ctrl-E
cursor = buffer.size();
} else if (c == 2) { // Ctrl-B
cursor = previousChar(buffer, cursor);
} else if (c == 6) { // Ctrl-F
cursor = nextChar(buffer, cursor);
} else if (c == 11) { // Ctrl-K
buffer.erase(cursor);
} else if (c == 21) { // Ctrl-U
buffer.erase(0, cursor);
cursor = 0;
} else if (c == 23) { // Ctrl-W
const std::size_t start = previousWord(buffer, cursor);
buffer.erase(start, cursor - start);
cursor = start;
} else if (c == 12) { // Ctrl-L
Terminal::clearScreen();
} else if (c == 127 || c == 8) { // Backspace
const std::size_t start = previousChar(buffer, cursor);
buffer.erase(start, cursor - start);
cursor = start;
} else if (c == 9) { // Tab
if (!completer_) {
continue;
}
const Completion completion = completer_(buffer.substr(0, cursor));
if (completion.candidates.empty()) {
std::cout << "\a" << std::flush;
continue;
}
const std::string word = buffer.substr(completion.start, cursor - completion.start);
std::string replacement = commonPrefix(completion.candidates);
bool showList = false;
if (completion.candidates.size() == 1) {
if (replacement.empty() || replacement.back() != '/') {
replacement += ' ';
}
} else if (replacement.size() <= word.size()) {
showList = true;
}
buffer.replace(completion.start, cursor - completion.start, replacement);
cursor = completion.start + replacement.size();
if (showList) {
printCandidates(completion.candidates);
}
} else if (c == 27) { // Escape sequences (arrows, home, end, delete...)
if (!byteAvailable(30)) {
continue;
}
const int introducer = readByte();
if (introducer != '[' && introducer != 'O') {
continue;
}
std::string parameters;
int lastByte = readByte();
while (lastByte >= 0 && (std::isdigit(lastByte) || lastByte == ';')) {
parameters.push_back(static_cast<char>(lastByte));
lastByte = readByte();
}
const bool withModifier = parameters.find(';') != std::string::npos;
if (lastByte == '~') {
if (parameters == "3") {
buffer.erase(cursor, nextChar(buffer, cursor) - cursor);
} else if (parameters == "1" || parameters == "7") {
cursor = 0;
} else if (parameters == "4" || parameters == "8") {
cursor = buffer.size();
}
} else if (lastByte == 'A') { // Up
if (historyIndex > 0) {
if (historyIndex == history_.size()) {
pending = buffer;
}
--historyIndex;
buffer = history_[historyIndex];
cursor = buffer.size();
}
} else if (lastByte == 'B') { // Down
if (historyIndex < history_.size()) {
++historyIndex;
buffer = (historyIndex == history_.size()) ? pending : history_[historyIndex];
cursor = buffer.size();
}
} else if (lastByte == 'C') { // Right
cursor = withModifier ? nextWord(buffer, cursor) : nextChar(buffer, cursor);
} else if (lastByte == 'D') { // Left
cursor = withModifier ? previousWord(buffer, cursor) : previousChar(buffer, cursor);
} else if (lastByte == 'H') {
cursor = 0;
} else if (lastByte == 'F') {
cursor = buffer.size();
}
} else if (c >= 32) { // Printable (including UTF-8 bytes)
buffer.insert(cursor, 1, static_cast<char>(c));
++cursor;
}
redraw(prompt, buffer, cursor);
}
}
#endif // TT_HAVE_TERMIOS