#include "LineEditor.h" #include #include #include #include "StringUtil.h" #include "Terminal.h" #if defined(__unix__) || defined(__APPLE__) #define TT_HAVE_TERMIOS 1 #include #include #include #include #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(~(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(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(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(text[position - 1]))) { --position; } while (position > 0 && !std::isspace(static_cast(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(text[position]))) { ++position; } while (position < text.size() && !std::isspace(static_cast(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& 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(values.front()[prefix.size()]) & 0xC0) == 0x80) { prefix.pop_back(); } return prefix; } void printCandidates(const std::vector& 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(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(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(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(c)); ++cursor; } redraw(prompt, buffer, cursor); } } #endif // TT_HAVE_TERMIOS