368 lines
11 KiB
C++
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
|