| // Copyright 2018 The Fuchsia Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include "src/lib/line_input/line_input.h" |
| |
| #include <lib/syslog/cpp/macros.h> |
| #include <stdio.h> |
| #include <sys/ioctl.h> |
| #include <termios.h> |
| #include <unistd.h> |
| |
| #include "src/lib/fxl/strings/split_string.h" |
| |
| namespace line_input { |
| |
| const char* SpecialCharacters::kTermBeginningOfLine = "\r"; |
| const char* SpecialCharacters::kTermClearToEnd = "\x1b[0K"; |
| const char* SpecialCharacters::kTermCursorToColFormat = "\r\x1b[%dC"; |
| |
| namespace { |
| |
| size_t GetTerminalMaxCols(int fileno) { |
| struct winsize ws; |
| if (ioctl(fileno, TIOCGWINSZ, &ws) != -1) |
| return ws.ws_col; |
| return 0; // 0 means disable scrolling. |
| } |
| |
| } // namespace |
| |
| LineInputEditor::LineInputEditor(AcceptCallback accept_cb, const std::string& prompt) |
| : accept_callback_(std::move(accept_cb)), prompt_(prompt) { |
| // Start with a blank item at [0] which is where editing will take place. |
| history_.emplace_front(); |
| } |
| |
| LineInputEditor::~LineInputEditor() { EnsureNoRawMode(); } |
| |
| void LineInputEditor::SetAutocompleteCallback(AutocompleteCallback cb) { |
| autocomplete_callback_ = std::move(cb); |
| } |
| |
| void LineInputEditor::SetChangeCallback(ChangeCallback cb) { change_callback_ = std::move(cb); } |
| |
| void LineInputEditor::SetCancelCallback(CancelCallback cb) { cancel_callback_ = std::move(cb); } |
| |
| void LineInputEditor::SetEofCallback(EofCallback cb) { eof_callback_ = std::move(cb); } |
| |
| void LineInputEditor::SetMaxCols(size_t max) { max_cols_ = max; } |
| |
| const std::string& LineInputEditor::GetLine() const { return history_[history_index_]; } |
| |
| const std::deque<std::string>& LineInputEditor::GetHistory() const { return history_; } |
| |
| void LineInputEditor::OnInput(char c) { |
| FX_DCHECK(visible_); // Don't call while hidden. |
| |
| // Reverse history mode does its own input handling. |
| if (reverse_history_mode_) { |
| HandleReverseHistory(c); |
| return; |
| } |
| |
| if (reading_escaped_input_) { |
| HandleEscapedInput(c); |
| return; |
| } |
| |
| if (completion_mode_) { |
| // Special keys for completion mode. |
| if (c == SpecialCharacters::kKeyTab) { |
| HandleTab(); |
| return; |
| } |
| // We don't handle escape here to cancel because that's ambiguous with |
| // escape sequences like arrow keys. |
| AcceptCompletion(); |
| // Fall through to normal key processing. |
| } |
| |
| switch (c) { |
| case SpecialCharacters::kKeyControlA: |
| MoveHome(); |
| break; |
| case SpecialCharacters::kKeyControlB: |
| MoveLeft(); |
| break; |
| case SpecialCharacters::kKeyControlC: |
| CancelCommand(); |
| break; |
| case SpecialCharacters::kKeyControlD: |
| if (cur_line().empty()) { |
| HandleEndOfFile(); |
| return; |
| } else { |
| HandleDelete(); |
| } |
| break; |
| case SpecialCharacters::kKeyControlE: |
| MoveEnd(); |
| break; |
| case SpecialCharacters::kKeyControlF: |
| MoveRight(); |
| break; |
| case SpecialCharacters::kKeyControlK: |
| DeleteToEnd(); |
| break; |
| case SpecialCharacters::kKeyFormFeed: |
| HandleFormFeed(); |
| break; |
| case SpecialCharacters::kKeyTab: |
| HandleTab(); |
| break; |
| case SpecialCharacters::kKeyNewline: // == Ctrl + J |
| case SpecialCharacters::kKeyEnter: // == Ctrl + M |
| HandleEnter(); |
| return; |
| case SpecialCharacters::kKeyControlN: |
| MoveDown(); |
| break; |
| case SpecialCharacters::kKeyControlP: |
| MoveUp(); |
| break; |
| case SpecialCharacters::kKeyControlR: |
| StartReverseHistoryMode(); |
| break; |
| case SpecialCharacters::kKeyControlT: |
| TransposeLastTwoCharacters(); |
| break; |
| case SpecialCharacters::kKeyControlU: |
| HandleNegAck(); |
| break; |
| case SpecialCharacters::kKeyControlW: |
| HandleEndOfTransimission(); |
| break; |
| case SpecialCharacters::kKeyEsc: |
| reading_escaped_input_ = true; |
| break; |
| case SpecialCharacters::kKeyControlH: |
| case SpecialCharacters::kKeyBackspace: |
| HandleBackspace(); |
| break; |
| default: |
| Insert(c); |
| break; |
| } |
| } |
| |
| void LineInputEditor::AddToHistory(const std::string& line) { |
| if (line.empty()) |
| return; |
| |
| if (history_.size() > 1 && history_[1] == line) |
| return; |
| |
| if (history_.size() == max_history_) |
| history_.pop_back(); |
| |
| // Editing takes place at history_[0], so this replaces it and pushes |
| // everything else back with a new blank line to edit. |
| history_[0] = line; |
| history_.emplace_front(); |
| } |
| |
| void LineInputEditor::Hide() { |
| if (!visible_) |
| return; // Already hidden. |
| visible_ = false; |
| |
| std::string cmd; |
| cmd += SpecialCharacters::kTermBeginningOfLine; |
| cmd += SpecialCharacters::kTermClearToEnd; |
| |
| Write(cmd); |
| EnsureNoRawMode(); |
| } |
| |
| void LineInputEditor::Show() { |
| if (visible_) |
| return; // Already shown. |
| visible_ = true; |
| RepaintLine(); |
| } |
| |
| void LineInputEditor::HandleEscapedInput(char c) { |
| // Escape sequences are two bytes, buffer until we have both. |
| escape_sequence_.push_back(c); |
| if (escape_sequence_.size() < 2) |
| return; |
| |
| if (escape_sequence_.size() < 3 && escape_sequence_[0] == '[' && escape_sequence_[1] >= '0' && |
| escape_sequence_[1] <= '9') { |
| // This is a three-character escape sequence but we've only received two. Wait for more. |
| return; |
| } |
| |
| // Clear the escaped state before running any functions. Some of them can change the input |
| // which can in turn issue callbacks which can cause other stuff to happen, and we want to be |
| // in a fresh state if it does. |
| reading_escaped_input_ = false; |
| std::string sequence = escape_sequence_; |
| escape_sequence_.clear(); |
| |
| // See https://en.wikipedia.org/wiki/ANSI_escape_code for escape codes. |
| if (sequence[0] == '[') { |
| if (sequence[1] >= '0' && sequence[1] <= '9') { |
| // 3-character extended sequence. |
| if (sequence.size() < 3) |
| return; // Wait for another character. |
| if (sequence[1] == '3' && sequence[2] == '~') { |
| HandleDelete(); |
| } else if (sequence[1] == '1' && sequence[2] == '~') { |
| MoveHome(); |
| } else if (sequence[1] == '4' && sequence[2] == '~') { |
| MoveEnd(); |
| } |
| } else { |
| // Two-character '[' sequence. |
| switch (sequence[1]) { |
| case 'A': |
| MoveUp(); |
| break; |
| case 'B': |
| MoveDown(); |
| break; |
| case 'C': |
| MoveRight(); |
| break; |
| case 'D': |
| MoveLeft(); |
| break; |
| case 'H': |
| MoveHome(); |
| break; |
| case 'F': |
| MoveEnd(); |
| break; |
| } |
| } |
| } else if (sequence[0] == '0') { |
| switch (sequence[1]) { |
| case 'H': |
| MoveHome(); |
| break; |
| case 'F': |
| MoveEnd(); |
| break; |
| } |
| } |
| } |
| |
| void LineInputEditor::HandleBackspace() { |
| if (pos_ == 0) |
| return; |
| pos_--; |
| cur_line().erase(pos_, 1); |
| LineChanged(); |
| } |
| |
| void LineInputEditor::HandleDelete() { |
| if (pos_ < cur_line().size()) { |
| cur_line().erase(pos_, 1); |
| LineChanged(); |
| } |
| } |
| |
| void LineInputEditor::HandleEnter() { |
| Write("\r\n"); |
| |
| if (history_.size() == max_history_) |
| history_.pop_back(); |
| std::string new_line = cur_line(); |
| history_[0] = new_line; |
| EnsureNoRawMode(); |
| |
| accept_callback_(GetLine()); |
| |
| ResetLineState(); |
| if (visible_) |
| RepaintLine(); |
| } |
| |
| void LineInputEditor::HandleTab() { |
| if (!autocomplete_callback_) |
| return; // Can't do completions. |
| |
| if (!completion_mode_) { |
| completions_ = autocomplete_callback_(cur_line()); |
| completion_index_ = 0; |
| if (completions_.empty()) |
| return; // No completions, don't enter completion mode. |
| |
| // Transition to tab completion mode. |
| completion_mode_ = true; |
| line_before_completion_ = cur_line(); |
| pos_before_completion_ = pos_; |
| |
| // Put the current line at the end of the completion stack so tabbing |
| // through wraps around to it. |
| completions_.push_back(line_before_completion_); |
| } else { |
| // Advance to the next completion, with wraparound. |
| completion_index_++; |
| if (completion_index_ == completions_.size()) |
| completion_index_ = 0; |
| } |
| |
| // Show the new completion. |
| cur_line() = completions_[completion_index_]; |
| pos_ = cur_line().size(); |
| LineChanged(); |
| } |
| |
| void LineInputEditor::HandleNegAck() { |
| cur_line() = cur_line().substr(pos_); |
| pos_ = 0; |
| LineChanged(); |
| } |
| |
| void LineInputEditor::HandleEndOfTransimission() { |
| const auto& line = cur_line(); |
| if (line.empty()) |
| return; |
| |
| // We search for the last space that's before the cursor. |
| size_t latest_space = 0; |
| for (size_t i = 0; i < line.size(); i++) { |
| if (i >= pos_) |
| break; |
| |
| if (line[i] == ' ') |
| latest_space = i; |
| } |
| |
| // Ctrl-w removes from the latest space until the cursor. |
| std::string new_line; |
| if (latest_space > 0) |
| new_line.append(line.substr(0, latest_space + 1)); |
| new_line.append(line.substr(pos_)); |
| |
| size_t diff = line.size() - new_line.size(); |
| pos_ -= diff; |
| cur_line() = std::move(new_line); |
| LineChanged(); |
| } |
| |
| void LineInputEditor::HandleEndOfFile() { |
| Write("\r\n"); |
| if (eof_callback_) |
| eof_callback_(); |
| |
| ResetLineState(); |
| if (visible_) |
| LineChanged(); |
| } |
| |
| void LineInputEditor::HandleReverseHistory(char c) { |
| if (reading_escaped_input_) { |
| // Escape sequences are two bytes, buffer until we have both. |
| escape_sequence_.push_back(c); |
| if (escape_sequence_.size() < 2) |
| return; |
| |
| if (escape_sequence_[0] == '[') { |
| if (escape_sequence_[1] >= '0' && escape_sequence_[1] <= '9') { |
| // 3-character extended sequence. |
| if (escape_sequence_.size() < 3) |
| return; // Wait for another character. |
| } |
| } |
| |
| // Any other escape sequence exists reverse history mode. |
| EndReverseHistoryMode(false); |
| } |
| |
| // Only a handful of operations are valid in reverse history mode. |
| switch (c) { |
| // Enters selects the current suggestion. |
| case SpecialCharacters::kKeyEnter: |
| case SpecialCharacters::kKeyNewline: |
| EndReverseHistoryMode(true); |
| break; |
| // ctrl-r again searches for the next match. |
| case SpecialCharacters::kKeyControlR: |
| SearchNextReverseHistory(false); |
| break; |
| // Deleting a character starts the search anew. |
| case SpecialCharacters::kKeyControlH: |
| case SpecialCharacters::kKeyBackspace: |
| if (!reverse_history_input_.empty()) |
| reverse_history_input_.resize(reverse_history_input_.size() - 1); |
| SearchNextReverseHistory(true); |
| break; |
| // Almost all special characters end history mode. This is what sh does. |
| case SpecialCharacters::kKeyControlA: |
| case SpecialCharacters::kKeyControlB: |
| case SpecialCharacters::kKeyControlD: |
| case SpecialCharacters::kKeyControlE: |
| case SpecialCharacters::kKeyControlF: |
| case SpecialCharacters::kKeyFormFeed: |
| case SpecialCharacters::kKeyTab: |
| case SpecialCharacters::kKeyControlN: |
| case SpecialCharacters::kKeyControlP: |
| case SpecialCharacters::kKeyControlU: |
| case SpecialCharacters::kKeyControlW: |
| case SpecialCharacters::kKeyEsc: |
| EndReverseHistoryMode(false); |
| break; |
| // Add the input to the current search string and do the lookup anew. |
| default: |
| reverse_history_input_.append(1, c); |
| SearchNextReverseHistory(true); |
| break; |
| } |
| |
| LineChanged(); |
| }; |
| |
| void LineInputEditor::StartReverseHistoryMode() { |
| FX_DCHECK(!reverse_history_mode_); |
| reverse_history_mode_ = true; |
| reverse_history_index_ = 0; |
| reverse_history_input_.clear(); |
| |
| LineChanged(); |
| } |
| |
| void LineInputEditor::EndReverseHistoryMode(bool accept_suggestion) { |
| FX_DCHECK(reverse_history_mode_); |
| reverse_history_mode_ = false; |
| |
| if (accept_suggestion) { |
| cur_line() = GetReverseHistorySuggestion(); |
| pos_ = cur_line().size(); |
| } else { |
| pos_ = 0; |
| } |
| } |
| |
| void LineInputEditor::SearchNextReverseHistory(bool restart) { |
| if (restart) { |
| reverse_history_index_ = 0; |
| } else { |
| // We want to find the *next* suggestion after the current one. |
| reverse_history_index_++; |
| } |
| |
| // No input, no search. |
| if (reverse_history_input_.empty()) { |
| pos_ = 0; |
| return; |
| } |
| |
| // Search for a history entry that has the input a a substring. |
| size_t index = reverse_history_index_ == 0 ? 1 : reverse_history_index_; |
| for (size_t i = index; i < history_.size(); i++) { |
| const std::string& line = history_[i]; |
| auto cursor_offset = line.find(reverse_history_input_); |
| if (cursor_offset == std::string::npos) |
| continue; |
| |
| // We found a suggestion. |
| reverse_history_index_ = i; |
| pos_ = cursor_offset; |
| return; |
| } |
| |
| // If we didn't find a suggestion, we reset the state and clear the state, to indicate to the user |
| // that it rolled over or it didn't find anything. |
| reverse_history_index_ = 0; |
| pos_ = 0; |
| } |
| |
| void LineInputEditor::HandleFormFeed() { |
| Write("\033c"); // Form feed. |
| LineChanged(); |
| } |
| |
| void LineInputEditor::Insert(char c) { |
| if (pos_ == cur_line().size() && |
| (max_cols_ == 0 || cur_line().size() + prompt_.size() < max_cols_ - 1)) { |
| // Append to end and no scrolling needed. Optimize output to avoid |
| // redrawing the entire line. |
| cur_line().push_back(c); |
| pos_++; |
| Write(std::string(1, c)); |
| if (change_callback_) |
| change_callback_(cur_line()); |
| } else { |
| // Insert in the middle. |
| cur_line().insert(pos_, 1, c); |
| pos_++; |
| LineChanged(); |
| } |
| } |
| |
| void LineInputEditor::MoveLeft() { |
| if (pos_ > 0) { |
| pos_--; |
| RepaintLine(); |
| } |
| } |
| |
| void LineInputEditor::MoveRight() { |
| if (pos_ < cur_line().size()) { |
| pos_++; |
| RepaintLine(); |
| } |
| } |
| |
| void LineInputEditor::MoveUp() { |
| if (history_index_ < history_.size() - 1) { |
| history_index_++; |
| pos_ = cur_line().size(); |
| RepaintLine(); |
| } |
| } |
| |
| void LineInputEditor::MoveDown() { |
| if (history_index_ > 0) { |
| history_index_--; |
| pos_ = cur_line().size(); |
| RepaintLine(); |
| } |
| } |
| |
| void LineInputEditor::MoveHome() { |
| pos_ = 0; |
| RepaintLine(); |
| } |
| |
| void LineInputEditor::MoveEnd() { |
| pos_ = cur_line().size(); |
| RepaintLine(); |
| } |
| |
| void LineInputEditor::TransposeLastTwoCharacters() { |
| if (pos_ >= 2) { |
| auto swap = cur_line()[pos_ - 1]; |
| cur_line()[pos_ - 1] = cur_line()[pos_ - 2]; |
| cur_line()[pos_ - 2] = swap; |
| LineChanged(); |
| } |
| } |
| |
| void LineInputEditor::CancelCommand() { |
| if (cancel_callback_) { |
| cancel_callback_(); |
| } else { |
| Write("^C\r\n"); |
| ResetLineState(); |
| LineChanged(); |
| } |
| } |
| |
| void LineInputEditor::DeleteToEnd() { |
| if (pos_ != cur_line().size()) { |
| cur_line().resize(pos_); |
| LineChanged(); |
| } |
| } |
| |
| void LineInputEditor::CancelCompletion() { |
| cur_line() = line_before_completion_; |
| pos_ = pos_before_completion_; |
| completion_mode_ = false; |
| completions_ = std::vector<std::string>(); |
| LineChanged(); |
| } |
| |
| void LineInputEditor::AcceptCompletion() { |
| completion_mode_ = false; |
| completions_ = std::vector<std::string>(); |
| // Line shouldn't need repainting since this doesn't update it. |
| } |
| |
| void LineInputEditor::LineChanged() { |
| RepaintLine(); |
| if (change_callback_) |
| change_callback_(cur_line()); |
| } |
| |
| void LineInputEditor::RepaintLine() { |
| std::string prompt, line_data; |
| if (!reverse_history_mode_) { |
| prompt = prompt_; |
| line_data = prompt + cur_line(); |
| } else { |
| prompt = GetReverseHistoryPrompt(); |
| line_data = prompt + GetReverseHistorySuggestion(); |
| } |
| |
| EnsureRawMode(); |
| |
| std::string buf; |
| buf.reserve(64); |
| buf += SpecialCharacters::kTermBeginningOfLine; |
| |
| // Only print up to max_cols_ - 1 to leave room for the cursor at the end. |
| size_t pos_in_cols = prompt.size() + pos_; |
| if (max_cols_ > 0 && line_data.size() >= max_cols_ - 1) { |
| // Needs scrolling. This code scrolls both the user entry and the prompt. |
| // This avoids some edge cases where the prompt is wider than the screen. |
| if (pos_in_cols < max_cols_) { |
| // Cursor is on the screen with no scrolling, just trim from the right. |
| line_data.resize(max_cols_); |
| } else { |
| // Cursor requires scrolling, position the cursor on the right. |
| line_data = line_data.substr(pos_in_cols - max_cols_ + 1, max_cols_); |
| pos_in_cols = max_cols_ - 1; |
| } |
| buf += line_data; |
| } else { |
| buf += line_data; |
| } |
| |
| buf += SpecialCharacters::kTermClearToEnd; |
| |
| char forward_buf[32]; |
| snprintf(forward_buf, sizeof(forward_buf), SpecialCharacters::kTermCursorToColFormat, |
| static_cast<int>(pos_in_cols)); |
| buf += forward_buf; |
| |
| Write(buf); |
| } |
| |
| std::string LineInputEditor::GetReverseHistoryPrompt() const { |
| std::string buf; |
| buf.reserve(64); |
| |
| buf += "(reverse-i-search)`"; |
| buf += reverse_history_input_; |
| buf += "': "; |
| |
| return buf; |
| } |
| |
| std::string LineInputEditor::GetReverseHistorySuggestion() const { |
| if (reverse_history_input_.empty()) |
| return {}; |
| |
| if (reverse_history_index_ == 0 || reverse_history_index_ >= history_.size()) |
| return {}; |
| |
| return history_[reverse_history_index_]; |
| } |
| |
| void LineInputEditor::ResetLineState() { |
| pos_ = 0; |
| history_index_ = 0; |
| completion_mode_ = false; |
| |
| cur_line() = std::string(); |
| } |
| |
| // LineInputStdout --------------------------------------------------------------------------------- |
| |
| LineInputStdout::LineInputStdout(AcceptCallback accept_cb, const std::string& prompt) |
| : LineInputEditor(std::move(accept_cb), prompt) { |
| SetMaxCols(GetTerminalMaxCols(STDIN_FILENO)); |
| } |
| LineInputStdout::~LineInputStdout() {} |
| |
| void LineInputStdout::Write(const std::string& data) { |
| write(STDOUT_FILENO, data.data(), data.size()); |
| } |
| |
| void LineInputStdout::EnsureRawMode() { |
| #if !defined(__Fuchsia__) |
| if (raw_mode_enabled_) |
| return; |
| |
| if (!raw_termios_) { |
| if (!isatty(STDOUT_FILENO)) |
| return; |
| |
| // Don't commit until everything succeeds. |
| original_termios_ = std::make_unique<termios>(); |
| if (tcgetattr(STDOUT_FILENO, original_termios_.get()) == -1) |
| return; |
| |
| // Always expect non-raw node to wrap lines for us. Without this, if |
| // somebody's terminal was left in raw mode when they started the debugger, |
| // the non-interactive input will be wrapped incorrectly. |
| original_termios_->c_oflag |= OPOST; |
| |
| raw_termios_ = std::make_unique<termios>(*original_termios_); |
| |
| raw_termios_->c_iflag &= ~(BRKINT | ICRNL | INPCK | ISTRIP | IXON); |
| raw_termios_->c_oflag &= ~(OPOST); |
| raw_termios_->c_oflag |= OCRNL; |
| raw_termios_->c_cflag |= CS8; |
| raw_termios_->c_lflag &= ~(ECHO | ICANON | IEXTEN | ISIG); |
| raw_termios_->c_cc[VMIN] = 1; |
| raw_termios_->c_cc[VTIME] = 0; |
| } |
| |
| fflush(stdout); // Synchronize with the buffered stdio stream. |
| if (tcsetattr(STDOUT_FILENO, TCSAFLUSH, raw_termios_.get()) < 0) |
| return; |
| |
| raw_mode_enabled_ = true; |
| #endif |
| } |
| |
| void LineInputStdout::EnsureNoRawMode() { |
| #if !defined(__Fuchsia__) |
| if (raw_mode_enabled_) { |
| fflush(stdout); // Synchronize with the buffered stdio stream. |
| tcsetattr(STDOUT_FILENO, TCSAFLUSH, original_termios_.get()); |
| raw_mode_enabled_ = false; |
| } |
| #endif |
| } |
| |
| } // namespace line_input |