diff options
author | Andreas Kling <kling@serenityos.org> | 2021-01-12 12:17:30 +0100 |
---|---|---|
committer | Andreas Kling <kling@serenityos.org> | 2021-01-12 12:17:46 +0100 |
commit | 13d7c09125f8eec703d0a43a9a87fc8aa08f7319 (patch) | |
tree | 70fd643c429cea5c1f9362c2674511d17a53f3b5 /Userland/Libraries/LibLine | |
parent | dc28c07fa526841e05e16161c74a6c23984f1dd5 (diff) | |
download | serenity-13d7c09125f8eec703d0a43a9a87fc8aa08f7319.zip |
Libraries: Move to Userland/Libraries/
Diffstat (limited to 'Userland/Libraries/LibLine')
-rw-r--r-- | Userland/Libraries/LibLine/CMakeLists.txt | 10 | ||||
-rw-r--r-- | Userland/Libraries/LibLine/Editor.cpp | 1769 | ||||
-rw-r--r-- | Userland/Libraries/LibLine/Editor.h | 489 | ||||
-rw-r--r-- | Userland/Libraries/LibLine/InternalFunctions.cpp | 496 | ||||
-rw-r--r-- | Userland/Libraries/LibLine/KeyCallbackMachine.cpp | 113 | ||||
-rw-r--r-- | Userland/Libraries/LibLine/KeyCallbackMachine.h | 112 | ||||
-rw-r--r-- | Userland/Libraries/LibLine/Span.h | 55 | ||||
-rw-r--r-- | Userland/Libraries/LibLine/StringMetrics.h | 72 | ||||
-rw-r--r-- | Userland/Libraries/LibLine/Style.h | 184 | ||||
-rw-r--r-- | Userland/Libraries/LibLine/SuggestionDisplay.h | 104 | ||||
-rw-r--r-- | Userland/Libraries/LibLine/SuggestionManager.cpp | 196 | ||||
-rw-r--r-- | Userland/Libraries/LibLine/SuggestionManager.h | 161 | ||||
-rw-r--r-- | Userland/Libraries/LibLine/VT.h | 44 | ||||
-rw-r--r-- | Userland/Libraries/LibLine/XtermSuggestionDisplay.cpp | 205 |
14 files changed, 4010 insertions, 0 deletions
diff --git a/Userland/Libraries/LibLine/CMakeLists.txt b/Userland/Libraries/LibLine/CMakeLists.txt new file mode 100644 index 0000000000..41da2dee0b --- /dev/null +++ b/Userland/Libraries/LibLine/CMakeLists.txt @@ -0,0 +1,10 @@ +set(SOURCES + Editor.cpp + InternalFunctions.cpp + KeyCallbackMachine.cpp + SuggestionManager.cpp + XtermSuggestionDisplay.cpp +) + +serenity_lib(LibLine line) +target_link_libraries(LibLine LibC LibCore) diff --git a/Userland/Libraries/LibLine/Editor.cpp b/Userland/Libraries/LibLine/Editor.cpp new file mode 100644 index 0000000000..0ac1fe100c --- /dev/null +++ b/Userland/Libraries/LibLine/Editor.cpp @@ -0,0 +1,1769 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * Copyright (c) 2021, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include "Editor.h" +#include <AK/GenericLexer.h> +#include <AK/JsonObject.h> +#include <AK/ScopeGuard.h> +#include <AK/StringBuilder.h> +#include <AK/Utf32View.h> +#include <AK/Utf8View.h> +#include <LibCore/ConfigFile.h> +#include <LibCore/Event.h> +#include <LibCore/EventLoop.h> +#include <LibCore/File.h> +#include <LibCore/Notifier.h> +#include <ctype.h> +#include <signal.h> +#include <stdio.h> +#include <sys/ioctl.h> +#include <sys/select.h> +#include <sys/time.h> +#include <unistd.h> + +// #define SUGGESTIONS_DEBUG + +namespace { +constexpr u32 ctrl(char c) { return c & 0x3f; } +} + +namespace Line { + +Configuration Configuration::from_config(const StringView& libname) +{ + Configuration configuration; + auto config_file = Core::ConfigFile::get_for_lib(libname); + + // Read behaviour options. + auto refresh = config_file->read_entry("behaviour", "refresh", "lazy"); + auto operation = config_file->read_entry("behaviour", "operation_mode"); + + if (refresh.equals_ignoring_case("lazy")) + configuration.set(Configuration::Lazy); + else if (refresh.equals_ignoring_case("eager")) + configuration.set(Configuration::Eager); + + if (operation.equals_ignoring_case("full")) + configuration.set(Configuration::OperationMode::Full); + else if (operation.equals_ignoring_case("noescapesequences")) + configuration.set(Configuration::OperationMode::NoEscapeSequences); + else if (operation.equals_ignoring_case("noninteractive")) + configuration.set(Configuration::OperationMode::NonInteractive); + else + configuration.set(Configuration::OperationMode::Unset); + + // Read keybinds. + + for (auto& binding_key : config_file->keys("keybinds")) { + GenericLexer key_lexer(binding_key); + auto has_ctrl = false; + auto alt = false; + auto escape = false; + Vector<Key> keys; + + while (!key_lexer.is_eof()) { + unsigned key; + if (escape) { + key = key_lexer.consume_escaped_character(); + escape = false; + } else { + if (key_lexer.next_is("alt+")) { + alt = key_lexer.consume_specific("alt+"); + continue; + } + if (key_lexer.next_is("^[")) { + alt = key_lexer.consume_specific("^["); + continue; + } + if (key_lexer.next_is("^")) { + has_ctrl = key_lexer.consume_specific("^"); + continue; + } + if (key_lexer.next_is("ctrl+")) { + has_ctrl = key_lexer.consume_specific("ctrl+"); + continue; + } + if (key_lexer.next_is("\\")) { + escape = true; + continue; + } + // FIXME: Support utf? + key = key_lexer.consume(); + } + if (has_ctrl) + key = ctrl(key); + + keys.append(Key { key, alt ? Key::Alt : Key::None }); + alt = false; + has_ctrl = false; + } + + GenericLexer value_lexer { config_file->read_entry("keybinds", binding_key) }; + StringBuilder value_builder; + while (!value_lexer.is_eof()) + value_builder.append(value_lexer.consume_escaped_character()); + auto value = value_builder.string_view(); + if (value.starts_with("internal:")) { + configuration.set(KeyBinding { + keys, + KeyBinding::Kind::InternalFunction, + value.substring_view(9, value.length() - 9) }); + } else { + configuration.set(KeyBinding { + keys, + KeyBinding::Kind::Insertion, + value }); + } + } + + return configuration; +} + +void Editor::set_default_keybinds() +{ + register_key_input_callback(ctrl('N'), EDITOR_INTERNAL_FUNCTION(search_forwards)); + register_key_input_callback(ctrl('P'), EDITOR_INTERNAL_FUNCTION(search_backwards)); + // Normally ^W. `stty werase \^n` can change it to ^N (or something else), but Serenity doesn't have `stty` yet. + register_key_input_callback(m_termios.c_cc[VWERASE], EDITOR_INTERNAL_FUNCTION(erase_word_backwards)); + // Normally ^U. `stty kill \^n` can change it to ^N (or something else), but Serenity doesn't have `stty` yet. + register_key_input_callback(m_termios.c_cc[VKILL], EDITOR_INTERNAL_FUNCTION(kill_line)); + register_key_input_callback(ctrl('A'), EDITOR_INTERNAL_FUNCTION(go_home)); + register_key_input_callback(ctrl('B'), EDITOR_INTERNAL_FUNCTION(cursor_left_character)); + register_key_input_callback(ctrl('D'), EDITOR_INTERNAL_FUNCTION(erase_character_forwards)); + register_key_input_callback(ctrl('E'), EDITOR_INTERNAL_FUNCTION(go_end)); + register_key_input_callback(ctrl('F'), EDITOR_INTERNAL_FUNCTION(cursor_right_character)); + // ^H: ctrl('H') == '\b' + register_key_input_callback(ctrl('H'), EDITOR_INTERNAL_FUNCTION(erase_character_backwards)); + register_key_input_callback(m_termios.c_cc[VERASE], EDITOR_INTERNAL_FUNCTION(erase_character_backwards)); + register_key_input_callback(ctrl('K'), EDITOR_INTERNAL_FUNCTION(erase_to_end)); + register_key_input_callback(ctrl('L'), EDITOR_INTERNAL_FUNCTION(clear_screen)); + register_key_input_callback(ctrl('R'), EDITOR_INTERNAL_FUNCTION(enter_search)); + register_key_input_callback(ctrl('T'), EDITOR_INTERNAL_FUNCTION(transpose_characters)); + register_key_input_callback('\n', EDITOR_INTERNAL_FUNCTION(finish)); + + // ^[.: alt-.: insert last arg of previous command (similar to `!$`) + register_key_input_callback(Key { '.', Key::Alt }, EDITOR_INTERNAL_FUNCTION(insert_last_words)); + register_key_input_callback(Key { 'b', Key::Alt }, EDITOR_INTERNAL_FUNCTION(cursor_left_word)); + register_key_input_callback(Key { 'f', Key::Alt }, EDITOR_INTERNAL_FUNCTION(cursor_right_word)); + // ^[^H: alt-backspace: backward delete word + register_key_input_callback(Key { '\b', Key::Alt }, EDITOR_INTERNAL_FUNCTION(erase_alnum_word_backwards)); + register_key_input_callback(Key { 'd', Key::Alt }, EDITOR_INTERNAL_FUNCTION(erase_alnum_word_forwards)); + register_key_input_callback(Key { 'c', Key::Alt }, EDITOR_INTERNAL_FUNCTION(capitalize_word)); + register_key_input_callback(Key { 'l', Key::Alt }, EDITOR_INTERNAL_FUNCTION(lowercase_word)); + register_key_input_callback(Key { 'u', Key::Alt }, EDITOR_INTERNAL_FUNCTION(uppercase_word)); + register_key_input_callback(Key { 't', Key::Alt }, EDITOR_INTERNAL_FUNCTION(transpose_words)); +} + +Editor::Editor(Configuration configuration) + : m_configuration(move(configuration)) +{ + m_always_refresh = m_configuration.refresh_behaviour == Configuration::RefreshBehaviour::Eager; + m_pending_chars = ByteBuffer::create_uninitialized(0); + get_terminal_size(); + m_suggestion_display = make<XtermSuggestionDisplay>(m_num_lines, m_num_columns); +} + +Editor::~Editor() +{ + if (m_initialized) + restore(); +} + +void Editor::get_terminal_size() +{ + struct winsize ws; + if (ioctl(STDERR_FILENO, TIOCGWINSZ, &ws) < 0) { + m_num_columns = 80; + m_num_lines = 25; + } else { + m_num_columns = ws.ws_col; + m_num_lines = ws.ws_row; + } +} + +void Editor::add_to_history(const String& line) +{ + if (line.is_empty()) + return; + String histcontrol = getenv("HISTCONTROL"); + auto ignoredups = histcontrol == "ignoredups" || histcontrol == "ignoreboth"; + auto ignorespace = histcontrol == "ignorespace" || histcontrol == "ignoreboth"; + if (ignoredups && !m_history.is_empty() && line == m_history.last().entry) + return; + if (ignorespace && line.starts_with(' ')) + return; + if ((m_history.size() + 1) > m_history_capacity) + m_history.take_first(); + struct timeval tv; + gettimeofday(&tv, nullptr); + m_history.append({ line, tv.tv_sec }); +} + +bool Editor::load_history(const String& path) +{ + auto history_file = Core::File::construct(path); + if (!history_file->open(Core::IODevice::ReadOnly)) + return false; + auto data = history_file->read_all(); + auto hist = StringView { data.data(), data.size() }; + for (auto& str : hist.split_view("\n\n")) { + auto it = str.find_first_of("::").value_or(0); + auto time = str.substring_view(0, it).to_uint<time_t>().value_or(0); + auto string = str.substring_view(it == 0 ? it : it + 2); + m_history.append({ string, time }); + } + return true; +} + +template<typename It0, typename It1, typename OutputT, typename MapperT, typename LessThan> +static void merge(It0&& begin0, const It0& end0, It1&& begin1, const It1& end1, OutputT& output, MapperT left_mapper, LessThan less_than) +{ + for (;;) { + if (begin0 == end0 && begin1 == end1) + return; + + if (begin0 == end0) { + auto&& right = *begin1; + if (output.last().entry != right.entry) + output.append(right); + ++begin1; + continue; + } + + auto&& left = left_mapper(*begin0); + if (left.entry.is_whitespace()) { + ++begin0; + continue; + } + if (begin1 == end1) { + if (output.last().entry != left.entry) + output.append(left); + ++begin0; + continue; + } + + auto&& right = *begin1; + if (less_than(left, right)) { + if (output.last().entry != left.entry) + output.append(left); + ++begin0; + } else { + if (output.last().entry != right.entry) + output.append(right); + ++begin1; + if (right.entry == left.entry) + ++begin0; + } + } +} + +bool Editor::save_history(const String& path) +{ + Vector<HistoryEntry> final_history { { "", 0 } }; + { + auto file_or_error = Core::File::open(path, Core::IODevice::ReadWrite, 0600); + if (file_or_error.is_error()) + return false; + auto file = file_or_error.release_value(); + merge( + file->line_begin(), file->line_end(), m_history.begin(), m_history.end(), final_history, + [](StringView str) { + auto it = str.find_first_of("::").value_or(0); + auto time = str.substring_view(0, it).to_uint<time_t>().value_or(0); + auto string = str.substring_view(it == 0 ? it : it + 2); + return HistoryEntry { string, time }; + }, + [](const HistoryEntry& left, const HistoryEntry& right) { return left.timestamp < right.timestamp; }); + } + + auto file_or_error = Core::File::open(path, Core::IODevice::WriteOnly, 0600); + if (file_or_error.is_error()) + return false; + auto file = file_or_error.release_value(); + final_history.take_first(); + for (const auto& entry : final_history) + file->write(String::formatted("{}::{}\n\n", entry.timestamp, entry.entry)); + + return true; +} + +void Editor::clear_line() +{ + for (size_t i = 0; i < m_cursor; ++i) + fputc(0x8, stderr); + fputs("\033[K", stderr); + fflush(stderr); + m_buffer.clear(); + m_cursor = 0; + m_inline_search_cursor = m_cursor; +} + +void Editor::insert(const Utf32View& string) +{ + for (size_t i = 0; i < string.length(); ++i) + insert(string.code_points()[i]); +} + +void Editor::insert(const String& string) +{ + for (auto ch : Utf8View { string }) + insert(ch); +} + +void Editor::insert(const StringView& string_view) +{ + for (auto ch : Utf8View { string_view }) + insert(ch); +} + +void Editor::insert(const u32 cp) +{ + StringBuilder builder; + builder.append(Utf32View(&cp, 1)); + auto str = builder.build(); + m_pending_chars.append(str.characters(), str.length()); + + readjust_anchored_styles(m_cursor, ModificationKind::Insertion); + + if (m_cursor == m_buffer.size()) { + m_buffer.append(cp); + m_cursor = m_buffer.size(); + m_inline_search_cursor = m_cursor; + return; + } + + m_buffer.insert(m_cursor, cp); + ++m_chars_inserted_in_the_middle; + ++m_cursor; + m_inline_search_cursor = m_cursor; +} + +void Editor::register_key_input_callback(const KeyBinding& binding) +{ + if (binding.kind == KeyBinding::Kind::InternalFunction) { + auto internal_function = find_internal_function(binding.binding); + if (!internal_function) { + dbg() << "LibLine: Unknown internal function '" << binding.binding << "'"; + return; + } + return register_key_input_callback(binding.keys, move(internal_function)); + } + + return register_key_input_callback(binding.keys, [binding = String(binding.binding)](auto& editor) { + editor.insert(binding); + return false; + }); +} + +static size_t code_point_length_in_utf8(u32 code_point) +{ + if (code_point <= 0x7f) + return 1; + if (code_point <= 0x07ff) + return 2; + if (code_point <= 0xffff) + return 3; + if (code_point <= 0x10ffff) + return 4; + return 3; +} + +// buffer [ 0 1 2 3 . . . A . . . B . . . M . . . N ] +// ^ ^ ^ ^ +// | | | +- end of buffer +// | | +- scan offset = M +// | +- range end = M - B +// +- range start = M - A +// This method converts a byte range defined by [start_byte_offset, end_byte_offset] to a code_point range [M - A, M - B] as shown in the diagram above. +// If `reverse' is true, A and B are before M, if not, A and B are after M. +Editor::CodepointRange Editor::byte_offset_range_to_code_point_offset_range(size_t start_byte_offset, size_t end_byte_offset, size_t scan_code_point_offset, bool reverse) const +{ + size_t byte_offset = 0; + size_t code_point_offset = scan_code_point_offset + (reverse ? 1 : 0); + CodepointRange range; + + for (;;) { + if (!reverse) { + if (code_point_offset >= m_buffer.size()) + break; + } else { + if (code_point_offset == 0) + break; + } + + if (byte_offset > end_byte_offset) + break; + + if (byte_offset < start_byte_offset) + ++range.start; + + if (byte_offset < end_byte_offset) + ++range.end; + + byte_offset += code_point_length_in_utf8(m_buffer[reverse ? --code_point_offset : code_point_offset++]); + } + + return range; +} + +void Editor::stylize(const Span& span, const Style& style) +{ + if (style.is_empty()) + return; + + auto start = span.beginning(); + auto end = span.end(); + + if (span.mode() == Span::ByteOriented) { + auto offsets = byte_offset_range_to_code_point_offset_range(start, end, 0); + + start = offsets.start; + end = offsets.end; + } + + auto& spans_starting = style.is_anchored() ? m_anchored_spans_starting : m_spans_starting; + auto& spans_ending = style.is_anchored() ? m_anchored_spans_ending : m_spans_ending; + + auto starting_map = spans_starting.get(start).value_or({}); + + if (!starting_map.contains(end)) + m_refresh_needed = true; + + starting_map.set(end, style); + + spans_starting.set(start, starting_map); + + auto ending_map = spans_ending.get(end).value_or({}); + + if (!ending_map.contains(start)) + m_refresh_needed = true; + ending_map.set(start, style); + + spans_ending.set(end, ending_map); +} + +void Editor::suggest(size_t invariant_offset, size_t static_offset, Span::Mode offset_mode) const +{ + auto internal_static_offset = static_offset; + auto internal_invariant_offset = invariant_offset; + if (offset_mode == Span::Mode::ByteOriented) { + // FIXME: We're assuming that invariant_offset points to the end of the available data + // this is not necessarily true, but is true in most cases. + auto offsets = byte_offset_range_to_code_point_offset_range(internal_static_offset, internal_invariant_offset + internal_static_offset, m_cursor - 1, true); + + internal_static_offset = offsets.start; + internal_invariant_offset = offsets.end - offsets.start; + } + m_suggestion_manager.set_suggestion_variants(internal_static_offset, internal_invariant_offset, 0); +} + +void Editor::initialize() +{ + if (m_initialized) + return; + + struct termios termios; + tcgetattr(0, &termios); + m_default_termios = termios; // grab a copy to restore + if (m_was_resized) + get_terminal_size(); + + if (m_configuration.operation_mode == Configuration::Unset) { + auto istty = isatty(STDIN_FILENO) && isatty(STDERR_FILENO); + if (!istty) { + m_configuration.set(Configuration::NonInteractive); + } else { + auto* term = getenv("TERM"); + if (StringView { term }.starts_with("xterm")) + m_configuration.set(Configuration::Full); + else + m_configuration.set(Configuration::NoEscapeSequences); + } + } + + // Because we use our own line discipline which includes echoing, + // we disable ICANON and ECHO. + if (m_configuration.operation_mode == Configuration::Full) { + termios.c_lflag &= ~(ECHO | ICANON); + tcsetattr(0, TCSANOW, &termios); + } + + m_termios = termios; + + set_default_keybinds(); + for (auto& keybind : m_configuration.keybindings) + register_key_input_callback(keybind); + + if (m_configuration.m_signal_mode == Configuration::WithSignalHandlers) { + m_signal_handlers.append(Core::EventLoop::register_signal(SIGINT, [this](int) { + interrupted(); + })); + + m_signal_handlers.append(Core::EventLoop::register_signal(SIGWINCH, [this](int) { + resized(); + })); + } + + m_initialized = true; +} + +void Editor::interrupted() +{ + if (m_is_searching) + return m_search_editor->interrupted(); + + if (!m_is_editing) + return; + + m_was_interrupted = true; + handle_interrupt_event(); + if (!m_finish) + return; + + m_finish = false; + reposition_cursor(true); + if (m_suggestion_display->cleanup()) + reposition_cursor(true); + fprintf(stderr, "\n"); + fflush(stderr); + m_buffer.clear(); + m_is_editing = false; + restore(); + m_notifier->set_enabled(false); + deferred_invoke([this](auto&) { + remove_child(*m_notifier); + m_notifier = nullptr; + Core::EventLoop::current().quit(Retry); + }); +} + +void Editor::really_quit_event_loop() +{ + m_finish = false; + reposition_cursor(true); + fprintf(stderr, "\n"); + fflush(stderr); + auto string = line(); + m_buffer.clear(); + m_is_editing = false; + restore(); + + m_returned_line = string; + + m_notifier->set_enabled(false); + deferred_invoke([this](auto&) { + remove_child(*m_notifier); + m_notifier = nullptr; + Core::EventLoop::current().quit(Exit); + }); +} + +auto Editor::get_line(const String& prompt) -> Result<String, Editor::Error> +{ + initialize(); + m_is_editing = true; + + if (m_configuration.operation_mode == Configuration::NoEscapeSequences || m_configuration.operation_mode == Configuration::NonInteractive) { + // Do not use escape sequences, instead, use LibC's getline. + size_t size = 0; + char* line = nullptr; + // Show the prompt only on interactive mode (NoEscapeSequences in this case). + if (m_configuration.operation_mode != Configuration::NonInteractive) + fputs(prompt.characters(), stderr); + auto line_length = getline(&line, &size, stdin); + // getline() returns -1 and sets errno=0 on EOF. + if (line_length == -1) { + if (line) + free(line); + if (errno == 0) + return Error::Eof; + + return Error::ReadFailure; + } + restore(); + if (line) { + String result { line, (size_t)line_length, Chomp }; + free(line); + return result; + } + + return Error::ReadFailure; + } + + set_prompt(prompt); + reset(); + strip_styles(true); + + auto prompt_lines = max(current_prompt_metrics().line_metrics.size(), 1ul) - 1; + for (size_t i = 0; i < prompt_lines; ++i) + putc('\n', stderr); + + VT::move_relative(-prompt_lines, 0); + + set_origin(); + + m_history_cursor = m_history.size(); + + refresh_display(); + + Core::EventLoop loop; + + m_notifier = Core::Notifier::construct(STDIN_FILENO, Core::Notifier::Read); + add_child(*m_notifier); + + m_notifier->on_ready_to_read = [&] { try_update_once(); }; + if (!m_incomplete_data.is_empty()) + deferred_invoke([&](auto&) { try_update_once(); }); + + if (loop.exec() == Retry) + return get_line(prompt); + + return m_input_error.has_value() ? Result<String, Editor::Error> { m_input_error.value() } : Result<String, Editor::Error> { m_returned_line }; +} + +void Editor::save_to(JsonObject& object) +{ + Core::Object::save_to(object); + object.set("is_searching", m_is_searching); + object.set("is_editing", m_is_editing); + object.set("cursor_offset", m_cursor); + object.set("needs_refresh", m_refresh_needed); + object.set("unprocessed_characters", m_incomplete_data.size()); + object.set("history_size", m_history.size()); + object.set("current_prompt", m_new_prompt); + object.set("was_interrupted", m_was_interrupted); + JsonObject display_area; + display_area.set("top_left_row", m_origin_row); + display_area.set("top_left_column", m_origin_column); + display_area.set("line_count", num_lines()); + object.set("used_display_area", move(display_area)); +} + +void Editor::try_update_once() +{ + if (m_was_interrupted) { + handle_interrupt_event(); + } + + handle_read_event(); + + if (m_always_refresh) + m_refresh_needed = true; + + refresh_display(); + + if (m_finish) + really_quit_event_loop(); +} + +void Editor::handle_interrupt_event() +{ + m_was_interrupted = false; + + m_callback_machine.interrupted(*this); + if (!m_callback_machine.should_process_last_pressed_key()) + return; + + fprintf(stderr, "^C"); + fflush(stderr); + + if (on_interrupt_handled) + on_interrupt_handled(); + + m_buffer.clear(); + m_cursor = 0; + + finish(); +} + +void Editor::handle_read_event() +{ + char keybuf[16]; + ssize_t nread = 0; + + if (!m_incomplete_data.size()) + nread = read(0, keybuf, sizeof(keybuf)); + + if (nread < 0) { + if (errno == EINTR) { + if (!m_was_interrupted) { + if (m_was_resized) + return; + + finish(); + return; + } + + handle_interrupt_event(); + return; + } + + ScopedValueRollback errno_restorer(errno); + perror("read failed"); + + m_input_error = Error::ReadFailure; + finish(); + return; + } + + m_incomplete_data.append(keybuf, nread); + nread = m_incomplete_data.size(); + + if (nread == 0) { + m_input_error = Error::Empty; + finish(); + return; + } + + auto reverse_tab = false; + + // Discard starting bytes until they make sense as utf-8. + size_t valid_bytes = 0; + while (nread) { + Utf8View { StringView { m_incomplete_data.data(), (size_t)nread } }.validate(valid_bytes); + if (valid_bytes) + break; + m_incomplete_data.take_first(); + --nread; + } + + Utf8View input_view { StringView { m_incomplete_data.data(), valid_bytes } }; + size_t consumed_code_points = 0; + + Vector<u8, 4> csi_parameter_bytes; + Vector<unsigned, 4> csi_parameters; + Vector<u8> csi_intermediate_bytes; + u8 csi_final; + enum CSIMod { + Shift = 1, + Alt = 2, + Ctrl = 4, + }; + + for (auto code_point : input_view) { + if (m_finish) + break; + + ++consumed_code_points; + + if (code_point == 0) + continue; + + switch (m_state) { + case InputState::GotEscape: + switch (code_point) { + case '[': + m_state = InputState::CSIExpectParameter; + continue; + default: { + m_callback_machine.key_pressed(*this, { code_point, Key::Alt }); + m_state = InputState::Free; + cleanup_suggestions(); + continue; + } + } + case InputState::CSIExpectParameter: + if (code_point >= 0x30 && code_point <= 0x3f) { // '0123456789:;<=>?' + csi_parameter_bytes.append(code_point); + continue; + } + m_state = InputState::CSIExpectIntermediate; + [[fallthrough]]; + case InputState::CSIExpectIntermediate: + if (code_point >= 0x20 && code_point <= 0x2f) { // ' !"#$%&\'()*+,-./' + csi_intermediate_bytes.append(code_point); + continue; + } + m_state = InputState::CSIExpectFinal; + [[fallthrough]]; + case InputState::CSIExpectFinal: { + m_state = InputState::Free; + if (!(code_point >= 0x40 && code_point <= 0x7f)) { + dbgln("LibLine: Invalid CSI: {:02x} ({:c})", code_point, code_point); + continue; + } + csi_final = code_point; + + for (auto& parameter : String::copy(csi_parameter_bytes).split(';')) { + if (auto value = parameter.to_uint(); value.has_value()) + csi_parameters.append(value.value()); + else + csi_parameters.append(0); + } + unsigned param1 = 0, param2 = 0; + if (csi_parameters.size() >= 1) + param1 = csi_parameters[0]; + if (csi_parameters.size() >= 2) + param2 = csi_parameters[1]; + unsigned modifiers = param2 ? param2 - 1 : 0; + + if (csi_final == 'Z') { + // 'reverse tab' + reverse_tab = true; + break; + } + cleanup_suggestions(); + + switch (csi_final) { + case 'A': // ^[[A: arrow up + search_backwards(); + continue; + case 'B': // ^[[B: arrow down + search_forwards(); + continue; + case 'D': // ^[[D: arrow left + if (modifiers == CSIMod::Alt || modifiers == CSIMod::Ctrl) + cursor_left_word(); + else + cursor_left_character(); + continue; + case 'C': // ^[[C: arrow right + if (modifiers == CSIMod::Alt || modifiers == CSIMod::Ctrl) + cursor_right_word(); + else + cursor_right_character(); + continue; + case 'H': // ^[[H: home + go_home(); + continue; + case 'F': // ^[[F: end + go_end(); + continue; + case '~': + if (param1 == 3) { // ^[[3~: delete + if (modifiers == CSIMod::Ctrl) + erase_alnum_word_forwards(); + else + erase_character_forwards(); + m_search_offset = 0; + continue; + } + // ^[[5~: page up + // ^[[6~: page down + dbgln("LibLine: Unhandled '~': {}", param1); + continue; + default: + dbgln("LibLine: Unhandled final: {:02x} ({:c})", code_point, code_point); + continue; + } + break; + } + case InputState::Verbatim: + m_state = InputState::Free; + // Verbatim mode will bypass all mechanisms and just insert the code point. + insert(code_point); + continue; + case InputState::Free: + if (code_point == 27) { + m_callback_machine.key_pressed(*this, code_point); + // Note that this should also deal with explicitly registered keys + // that would otherwise be interpreted as escapes. + if (m_callback_machine.should_process_last_pressed_key()) + m_state = InputState::GotEscape; + continue; + } + if (code_point == 22) { // ^v + m_callback_machine.key_pressed(*this, code_point); + if (m_callback_machine.should_process_last_pressed_key()) + m_state = InputState::Verbatim; + continue; + } + break; + } + + // There are no sequences past this point, so short of 'tab', we will want to cleanup the suggestions. + ArmedScopeGuard suggestion_cleanup { [this] { cleanup_suggestions(); } }; + + // Normally ^D. `stty eof \^n` can change it to ^N (or something else), but Serenity doesn't have `stty` yet. + // Process this here since the keybinds might override its behaviour. + // This only applies when the buffer is empty. at any other time, the behaviour should be configurable. + if (code_point == m_termios.c_cc[VEOF] && m_buffer.size() == 0) { + finish_edit(); + continue; + } + + m_callback_machine.key_pressed(*this, code_point); + if (!m_callback_machine.should_process_last_pressed_key()) + continue; + + m_search_offset = 0; // reset search offset on any key + + if (code_point == '\t' || reverse_tab) { + suggestion_cleanup.disarm(); + + if (!on_tab_complete) + continue; + + // Reverse tab can count as regular tab here. + m_times_tab_pressed++; + + int token_start = m_cursor; + + // Ask for completions only on the first tab + // and scan for the largest common prefix to display, + // further tabs simply show the cached completions. + if (m_times_tab_pressed == 1) { + m_suggestion_manager.set_suggestions(on_tab_complete(*this)); + m_prompt_lines_at_suggestion_initiation = num_lines(); + if (m_suggestion_manager.count() == 0) { + // There are no suggestions, beep. + fputc('\a', stderr); + fflush(stderr); + } + } + + // Adjust already incremented / decremented index when switching tab direction. + if (reverse_tab && m_tab_direction != TabDirection::Backward) { + m_suggestion_manager.previous(); + m_suggestion_manager.previous(); + m_tab_direction = TabDirection::Backward; + } + if (!reverse_tab && m_tab_direction != TabDirection::Forward) { + m_suggestion_manager.next(); + m_suggestion_manager.next(); + m_tab_direction = TabDirection::Forward; + } + reverse_tab = false; + + SuggestionManager::CompletionMode completion_mode; + switch (m_times_tab_pressed) { + case 1: + completion_mode = SuggestionManager::CompletePrefix; + break; + case 2: + completion_mode = SuggestionManager::ShowSuggestions; + break; + default: + completion_mode = SuggestionManager::CycleSuggestions; + break; + } + + auto completion_result = m_suggestion_manager.attempt_completion(completion_mode, token_start); + + auto new_cursor = m_cursor + completion_result.new_cursor_offset; + for (size_t i = completion_result.offset_region_to_remove.start; i < completion_result.offset_region_to_remove.end; ++i) + remove_at_index(new_cursor); + + m_cursor = new_cursor; + m_inline_search_cursor = new_cursor; + m_refresh_needed = true; + + for (auto& view : completion_result.insert) + insert(view); + + if (completion_result.style_to_apply.has_value()) { + // Apply the style of the last suggestion. + readjust_anchored_styles(m_suggestion_manager.current_suggestion().start_index, ModificationKind::ForcedOverlapRemoval); + stylize({ m_suggestion_manager.current_suggestion().start_index, m_cursor, Span::Mode::CodepointOriented }, completion_result.style_to_apply.value()); + } + + switch (completion_result.new_completion_mode) { + case SuggestionManager::DontComplete: + m_times_tab_pressed = 0; + break; + case SuggestionManager::CompletePrefix: + break; + default: + ++m_times_tab_pressed; + break; + } + + if (m_times_tab_pressed > 1) { + if (m_suggestion_manager.count() > 0) { + if (m_suggestion_display->cleanup()) + reposition_cursor(); + + m_suggestion_display->set_initial_prompt_lines(m_prompt_lines_at_suggestion_initiation); + + m_suggestion_display->display(m_suggestion_manager); + + m_origin_row = m_suggestion_display->origin_row(); + } + } + + if (m_times_tab_pressed > 2) { + if (m_tab_direction == TabDirection::Forward) + m_suggestion_manager.next(); + else + m_suggestion_manager.previous(); + } + + if (m_suggestion_manager.count() < 2) { + // We have none, or just one suggestion, + // we should just commit that and continue + // after it, as if it were auto-completed. + suggest(0, 0, Span::CodepointOriented); + m_times_tab_pressed = 0; + m_suggestion_manager.reset(); + m_suggestion_display->finish(); + } + continue; + } + + insert(code_point); + } + + if (consumed_code_points == m_incomplete_data.size()) { + m_incomplete_data.clear(); + } else { + for (size_t i = 0; i < consumed_code_points; ++i) + m_incomplete_data.take_first(); + } + + if (!m_incomplete_data.is_empty() && !m_finish) + deferred_invoke([&](auto&) { try_update_once(); }); +} + +void Editor::cleanup_suggestions() +{ + if (m_times_tab_pressed) { + // Apply the style of the last suggestion. + readjust_anchored_styles(m_suggestion_manager.current_suggestion().start_index, ModificationKind::ForcedOverlapRemoval); + stylize({ m_suggestion_manager.current_suggestion().start_index, m_cursor, Span::Mode::CodepointOriented }, m_suggestion_manager.current_suggestion().style); + // We probably have some suggestions drawn, + // let's clean them up. + if (m_suggestion_display->cleanup()) { + reposition_cursor(); + m_refresh_needed = true; + } + m_suggestion_manager.reset(); + suggest(0, 0, Span::CodepointOriented); + m_suggestion_display->finish(); + } + m_times_tab_pressed = 0; // Safe to say if we get here, the user didn't press TAB +} + +bool Editor::search(const StringView& phrase, bool allow_empty, bool from_beginning) +{ + + int last_matching_offset = -1; + bool found = false; + + // Do not search for empty strings. + if (allow_empty || phrase.length() > 0) { + size_t search_offset = m_search_offset; + for (size_t i = m_history_cursor; i > 0; --i) { + auto& entry = m_history[i - 1]; + auto contains = from_beginning ? entry.entry.starts_with(phrase) : entry.entry.contains(phrase); + if (contains) { + last_matching_offset = i - 1; + if (search_offset == 0) { + found = true; + break; + } + --search_offset; + } + } + + if (!found) { + fputc('\a', stderr); + fflush(stderr); + } + } + + if (found) { + m_buffer.clear(); + m_cursor = 0; + insert(m_history[last_matching_offset].entry); + // Always needed, as we have cleared the buffer above. + m_refresh_needed = true; + } + + return found; +} + +void Editor::recalculate_origin() +{ + // Changing the columns can affect our origin if + // the new size is smaller than our prompt, which would + // cause said prompt to take up more space, so we should + // compensate for that. + if (m_cached_prompt_metrics.max_line_length >= m_num_columns) { + auto added_lines = (m_cached_prompt_metrics.max_line_length + 1) / m_num_columns - 1; + m_origin_row += added_lines; + } + + // We also need to recalculate our cursor position, + // but that will be calculated and applied at the next + // refresh cycle. +} +void Editor::cleanup() +{ + auto current_buffer_metrics = actual_rendered_string_metrics(buffer_view()); + auto new_lines = current_prompt_metrics().lines_with_addition(current_buffer_metrics, m_num_columns); + auto shown_lines = num_lines(); + if (new_lines < shown_lines) + m_extra_forward_lines = max(shown_lines - new_lines, m_extra_forward_lines); + + reposition_cursor(true); + auto current_line = num_lines() - 1; + VT::clear_lines(current_line, m_extra_forward_lines); + m_extra_forward_lines = 0; + reposition_cursor(); +}; + +void Editor::refresh_display() +{ + auto has_cleaned_up = false; + // Someone changed the window size, figure it out + // and react to it, we might need to redraw. + if (m_was_resized) { + if (m_previous_num_columns != m_num_columns) { + // We need to cleanup and redo everything. + m_cached_prompt_valid = false; + m_refresh_needed = true; + swap(m_previous_num_columns, m_num_columns); + recalculate_origin(); + cleanup(); + swap(m_previous_num_columns, m_num_columns); + has_cleaned_up = true; + } + m_was_resized = false; + } + // We might be at the last line, and have more than one line; + // Refreshing the display will cause the terminal to scroll, + // so note that fact and bring origin up, making sure to + // reserve the space for however many lines we move it up. + auto current_num_lines = num_lines(); + if (m_origin_row + current_num_lines > m_num_lines) { + if (current_num_lines > m_num_lines) { + for (size_t i = 0; i < m_num_lines; ++i) + putc('\n', stderr); + m_origin_row = 0; + } else { + auto old_origin_row = m_origin_row; + m_origin_row = m_num_lines - current_num_lines + 1; + for (size_t i = 0; i < old_origin_row - m_origin_row; ++i) + putc('\n', stderr); + } + fflush(stderr); + } + // Do not call hook on pure cursor movement. + if (m_cached_prompt_valid && !m_refresh_needed && m_pending_chars.size() == 0) { + // Probably just moving around. + reposition_cursor(); + m_cached_buffer_metrics = actual_rendered_string_metrics(buffer_view()); + return; + } + + if (on_display_refresh) + on_display_refresh(*this); + + if (m_cached_prompt_valid) { + if (!m_refresh_needed && m_cursor == m_buffer.size()) { + // Just write the characters out and continue, + // no need to refresh the entire line. + char null = 0; + m_pending_chars.append(&null, 1); + fputs((char*)m_pending_chars.data(), stderr); + m_pending_chars.clear(); + m_drawn_cursor = m_cursor; + m_cached_buffer_metrics = actual_rendered_string_metrics(buffer_view()); + fflush(stderr); + return; + } + } + + // Ouch, reflow entire line. + if (!has_cleaned_up) { + cleanup(); + } + VT::move_absolute(m_origin_row, m_origin_column); + + fputs(m_new_prompt.characters(), stderr); + + VT::clear_to_end_of_line(); + HashMap<u32, Style> empty_styles {}; + StringBuilder builder; + for (size_t i = 0; i < m_buffer.size(); ++i) { + auto ends = m_spans_ending.get(i).value_or(empty_styles); + auto starts = m_spans_starting.get(i).value_or(empty_styles); + + auto anchored_ends = m_anchored_spans_ending.get(i).value_or(empty_styles); + auto anchored_starts = m_anchored_spans_starting.get(i).value_or(empty_styles); + + auto c = m_buffer[i]; + bool should_print_caret = iscntrl(c) && c != '\n'; + + if (ends.size() || anchored_ends.size()) { + Style style; + + for (auto& applicable_style : ends) + style.unify_with(applicable_style.value); + + for (auto& applicable_style : anchored_ends) + style.unify_with(applicable_style.value); + + // Disable any style that should be turned off. + VT::apply_style(style, false); + + // Reapply styles for overlapping spans that include this one. + style = find_applicable_style(i); + VT::apply_style(style, true); + } + if (starts.size() || anchored_starts.size()) { + Style style; + + for (auto& applicable_style : starts) + style.unify_with(applicable_style.value); + + for (auto& applicable_style : anchored_starts) + style.unify_with(applicable_style.value); + + // Set new styles. + VT::apply_style(style, true); + } + + builder.clear(); + if (should_print_caret) + builder.appendff("^{:c}", c + 64); + else + builder.append(Utf32View { &c, 1 }); + + if (should_print_caret) + fputs("\033[7m", stderr); + + fputs(builder.to_string().characters(), stderr); + + if (should_print_caret) + fputs("\033[27m", stderr); + } + + VT::apply_style(Style::reset_style()); // don't bleed to EOL + + m_pending_chars.clear(); + m_refresh_needed = false; + m_cached_buffer_metrics = actual_rendered_string_metrics(buffer_view()); + m_chars_inserted_in_the_middle = 0; + if (!m_cached_prompt_valid) { + m_cached_prompt_valid = true; + } + + reposition_cursor(); + fflush(stderr); +} + +void Editor::strip_styles(bool strip_anchored) +{ + m_spans_starting.clear(); + m_spans_ending.clear(); + + if (strip_anchored) { + m_anchored_spans_starting.clear(); + m_anchored_spans_ending.clear(); + } + + m_refresh_needed = true; +} + +void Editor::reposition_cursor(bool to_end) +{ + auto cursor = m_cursor; + auto saved_cursor = m_cursor; + if (to_end) + cursor = m_buffer.size(); + + m_cursor = cursor; + m_drawn_cursor = cursor; + + auto line = cursor_line() - 1; + auto column = offset_in_line(); + + ASSERT(column + m_origin_column <= m_num_columns); + VT::move_absolute(line + m_origin_row, column + m_origin_column); + + if (line + m_origin_row > m_num_lines) { + for (size_t i = m_num_lines; i < line + m_origin_row; ++i) + fputc('\n', stderr); + m_origin_row -= line + m_origin_row - m_num_lines; + VT::move_relative(0, column + m_origin_column); + } + + m_cursor = saved_cursor; +} + +void VT::move_absolute(u32 row, u32 col) +{ + fprintf(stderr, "\033[%d;%dH", row, col); + fflush(stderr); +} + +void VT::move_relative(int row, int col) +{ + char x_op = 'A', y_op = 'D'; + + if (row > 0) + x_op = 'B'; + else + row = -row; + if (col > 0) + y_op = 'C'; + else + col = -col; + + if (row > 0) + fprintf(stderr, "\033[%d%c", row, x_op); + if (col > 0) + fprintf(stderr, "\033[%d%c", col, y_op); +} + +Style Editor::find_applicable_style(size_t offset) const +{ + // Walk through our styles and merge all that fit in the offset. + auto style = Style::reset_style(); + auto unify = [&](auto& entry) { + if (entry.key >= offset) + return; + for (auto& style_value : entry.value) { + if (style_value.key <= offset) + return; + style.unify_with(style_value.value, true); + } + }; + + for (auto& entry : m_spans_starting) { + unify(entry); + } + + for (auto& entry : m_anchored_spans_starting) { + unify(entry); + } + + return style; +} + +String Style::Background::to_vt_escape() const +{ + if (is_default()) + return ""; + + if (m_is_rgb) { + return String::format("\033[48;2;%d;%d;%dm", m_rgb_color[0], m_rgb_color[1], m_rgb_color[2]); + } else { + return String::format("\033[%dm", (u8)m_xterm_color + 40); + } +} + +String Style::Foreground::to_vt_escape() const +{ + if (is_default()) + return ""; + + if (m_is_rgb) { + return String::format("\033[38;2;%d;%d;%dm", m_rgb_color[0], m_rgb_color[1], m_rgb_color[2]); + } else { + return String::format("\033[%dm", (u8)m_xterm_color + 30); + } +} + +String Style::Hyperlink::to_vt_escape(bool starting) const +{ + if (is_empty()) + return ""; + + return String::format("\033]8;;%s\033\\", starting ? m_link.characters() : ""); +} + +void Style::unify_with(const Style& other, bool prefer_other) +{ + // Unify colors. + if (prefer_other || m_background.is_default()) + m_background = other.background(); + + if (prefer_other || m_foreground.is_default()) + m_foreground = other.foreground(); + + // Unify graphic renditions. + if (other.bold()) + set(Bold); + + if (other.italic()) + set(Italic); + + if (other.underline()) + set(Underline); + + // Unify links. + if (prefer_other || m_hyperlink.is_empty()) + m_hyperlink = other.hyperlink(); +} + +String Style::to_string() const +{ + StringBuilder builder; + builder.append("Style { "); + + if (!m_foreground.is_default()) { + builder.append("Foreground("); + if (m_foreground.m_is_rgb) { + builder.join(", ", m_foreground.m_rgb_color); + } else { + builder.appendf("(XtermColor) %d", (int)m_foreground.m_xterm_color); + } + builder.append("), "); + } + + if (!m_background.is_default()) { + builder.append("Background("); + if (m_background.m_is_rgb) { + builder.join(' ', m_background.m_rgb_color); + } else { + builder.appendf("(XtermColor) %d", (int)m_background.m_xterm_color); + } + builder.append("), "); + } + + if (bold()) + builder.append("Bold, "); + + if (underline()) + builder.append("Underline, "); + + if (italic()) + builder.append("Italic, "); + + if (!m_hyperlink.is_empty()) + builder.appendf("Hyperlink(\"%s\"), ", m_hyperlink.m_link.characters()); + + builder.append("}"); + + return builder.build(); +} + +void VT::apply_style(const Style& style, bool is_starting) +{ + if (is_starting) { + fprintf(stderr, + "\033[%d;%d;%dm%s%s%s", + style.bold() ? 1 : 22, + style.underline() ? 4 : 24, + style.italic() ? 3 : 23, + style.background().to_vt_escape().characters(), + style.foreground().to_vt_escape().characters(), + style.hyperlink().to_vt_escape(true).characters()); + } else { + fprintf(stderr, "%s", style.hyperlink().to_vt_escape(false).characters()); + } +} + +void VT::clear_lines(size_t count_above, size_t count_below) +{ + // Go down count_below lines. + if (count_below > 0) + fprintf(stderr, "\033[%dB", (int)count_below); + // Then clear lines going upwards. + for (size_t i = count_below + count_above; i > 0; --i) + fputs(i == 1 ? "\033[2K" : "\033[2K\033[A", stderr); +} + +void VT::save_cursor() +{ + fputs("\033[s", stderr); + fflush(stderr); +} + +void VT::restore_cursor() +{ + fputs("\033[u", stderr); + fflush(stderr); +} + +void VT::clear_to_end_of_line() +{ + fputs("\033[K", stderr); + fflush(stderr); +} + +StringMetrics Editor::actual_rendered_string_metrics(const StringView& string) +{ + StringMetrics metrics; + StringMetrics::LineMetrics current_line; + VTState state { Free }; + Utf8View view { string }; + auto it = view.begin(); + + for (; it != view.end(); ++it) { + auto c = *it; + auto it_copy = it; + ++it_copy; + auto next_c = it_copy == view.end() ? 0 : *it_copy; + state = actual_rendered_string_length_step(metrics, view.iterator_offset(it), current_line, c, next_c, state); + } + + metrics.line_metrics.append(current_line); + + for (auto& line : metrics.line_metrics) + metrics.max_line_length = max(line.total_length(), metrics.max_line_length); + + return metrics; +} + +StringMetrics Editor::actual_rendered_string_metrics(const Utf32View& view) +{ + StringMetrics metrics; + StringMetrics::LineMetrics current_line; + VTState state { Free }; + + for (size_t i = 0; i < view.length(); ++i) { + auto c = view.code_points()[i]; + auto next_c = i + 1 < view.length() ? view.code_points()[i + 1] : 0; + state = actual_rendered_string_length_step(metrics, i, current_line, c, next_c, state); + } + + metrics.line_metrics.append(current_line); + + for (auto& line : metrics.line_metrics) + metrics.max_line_length = max(line.total_length(), metrics.max_line_length); + + return metrics; +} + +Editor::VTState Editor::actual_rendered_string_length_step(StringMetrics& metrics, size_t index, StringMetrics::LineMetrics& current_line, u32 c, u32 next_c, VTState state) +{ + switch (state) { + case Free: + if (c == '\x1b') { // escape + return Escape; + } + if (c == '\r') { // carriage return + current_line.masked_chars = {}; + current_line.length = 0; + if (!metrics.line_metrics.is_empty()) + metrics.line_metrics.last() = { {}, 0 }; + return state; + } + if (c == '\n') { // return + metrics.line_metrics.append(current_line); + current_line.masked_chars = {}; + current_line.length = 0; + return state; + } + if (iscntrl(c) && c != '\n') + current_line.masked_chars.append({ index, 1, 2 }); + // FIXME: This will not support anything sophisticated + ++current_line.length; + ++metrics.total_length; + return state; + case Escape: + if (c == ']') { + if (next_c == '0') + state = Title; + return state; + } + if (c == '[') { + return Bracket; + } + // FIXME: This does not support non-VT (aside from set-title) escapes + return state; + case Bracket: + if (isdigit(c)) { + return BracketArgsSemi; + } + return state; + case BracketArgsSemi: + if (c == ';') { + return Bracket; + } + if (!isdigit(c)) + state = Free; + return state; + case Title: + if (c == 7) + state = Free; + return state; + } + return state; +} + +Vector<size_t, 2> Editor::vt_dsr() +{ + char buf[16]; + u32 length { 0 }; + + // Read whatever junk there is before talking to the terminal + // and insert them later when we're reading user input. + bool more_junk_to_read { false }; + timeval timeout { 0, 0 }; + fd_set readfds; + FD_ZERO(&readfds); + FD_SET(0, &readfds); + + do { + more_junk_to_read = false; + [[maybe_unused]] auto rc = select(1, &readfds, nullptr, nullptr, &timeout); + if (FD_ISSET(0, &readfds)) { + auto nread = read(0, buf, 16); + if (nread < 0) { + m_input_error = Error::ReadFailure; + finish(); + break; + } + + if (nread == 0) + break; + + m_incomplete_data.append(buf, nread); + more_junk_to_read = true; + } + } while (more_junk_to_read); + + if (m_input_error.has_value()) + return { 1, 1 }; + + fputs("\033[6n", stderr); + fflush(stderr); + + do { + auto nread = read(0, buf + length, 16 - length); + if (nread < 0) { + if (errno == 0 || errno == EINTR) { + // ???? + continue; + } + dbg() << "Error while reading DSR: " << strerror(errno); + m_input_error = Error::ReadFailure; + finish(); + return { 1, 1 }; + } + if (nread == 0) { + m_input_error = Error::Empty; + finish(); + dbgln("Terminal DSR issue; received no response"); + return { 1, 1 }; + } + length += nread; + } while (buf[length - 1] != 'R' && length < 16); + size_t row { 1 }, col { 1 }; + + if (buf[0] == '\033' && buf[1] == '[') { + auto parts = StringView(buf + 2, length - 3).split_view(';'); + auto row_opt = parts[0].to_int(); + if (!row_opt.has_value()) { + dbgln("Terminal DSR issue; received garbage row"); + } else { + row = row_opt.value(); + } + auto col_opt = parts[1].to_int(); + if (!col_opt.has_value()) { + dbgln("Terminal DSR issue; received garbage col"); + } else { + col = col_opt.value(); + } + } + return { row, col }; +} + +String Editor::line(size_t up_to_index) const +{ + StringBuilder builder; + builder.append(Utf32View { m_buffer.data(), min(m_buffer.size(), up_to_index) }); + return builder.build(); +} + +void Editor::remove_at_index(size_t index) +{ + // See if we have any anchored styles, and reposition them if needed. + readjust_anchored_styles(index, ModificationKind::Removal); + auto cp = m_buffer[index]; + m_buffer.remove(index); + if (cp == '\n') + ++m_extra_forward_lines; +} + +void Editor::readjust_anchored_styles(size_t hint_index, ModificationKind modification) +{ + struct Anchor { + Span old_span; + Span new_span; + Style style; + }; + Vector<Anchor> anchors_to_relocate; + auto index_shift = modification == ModificationKind::Insertion ? 1 : -1; + auto forced_removal = modification == ModificationKind::ForcedOverlapRemoval; + + for (auto& start_entry : m_anchored_spans_starting) { + for (auto& end_entry : start_entry.value) { + if (forced_removal) { + if (start_entry.key <= hint_index && end_entry.key > hint_index) { + // Remove any overlapping regions. + continue; + } + } + if (start_entry.key >= hint_index) { + if (start_entry.key == hint_index && end_entry.key == hint_index + 1 && modification == ModificationKind::Removal) { + // Remove the anchor, as all its text was wiped. + continue; + } + // Shift everything. + anchors_to_relocate.append({ { start_entry.key, end_entry.key, Span::Mode::CodepointOriented }, { start_entry.key + index_shift, end_entry.key + index_shift, Span::Mode::CodepointOriented }, end_entry.value }); + continue; + } + if (end_entry.key > hint_index) { + // Shift just the end. + anchors_to_relocate.append({ { start_entry.key, end_entry.key, Span::Mode::CodepointOriented }, { start_entry.key, end_entry.key + index_shift, Span::Mode::CodepointOriented }, end_entry.value }); + continue; + } + anchors_to_relocate.append({ { start_entry.key, end_entry.key, Span::Mode::CodepointOriented }, { start_entry.key, end_entry.key, Span::Mode::CodepointOriented }, end_entry.value }); + } + } + + m_anchored_spans_ending.clear(); + m_anchored_spans_starting.clear(); + // Pass over the relocations and update the stale entries. + for (auto& relocation : anchors_to_relocate) { + stylize(relocation.new_span, relocation.style); + } +} + +size_t StringMetrics::lines_with_addition(const StringMetrics& offset, size_t column_width) const +{ + size_t lines = 0; + + for (size_t i = 0; i < line_metrics.size() - 1; ++i) + lines += (line_metrics[i].total_length() + column_width) / column_width; + + auto last = line_metrics.last().total_length(); + last += offset.line_metrics.first().total_length(); + lines += (last + column_width) / column_width; + + for (size_t i = 1; i < offset.line_metrics.size(); ++i) + lines += (offset.line_metrics[i].total_length() + column_width) / column_width; + + return lines; +} + +size_t StringMetrics::offset_with_addition(const StringMetrics& offset, size_t column_width) const +{ + if (offset.line_metrics.size() > 1) + return offset.line_metrics.last().total_length() % column_width; + + auto last = line_metrics.last().total_length(); + last += offset.line_metrics.first().total_length(); + return last % column_width; +} + +} diff --git a/Userland/Libraries/LibLine/Editor.h b/Userland/Libraries/LibLine/Editor.h new file mode 100644 index 0000000000..407b3fd9fa --- /dev/null +++ b/Userland/Libraries/LibLine/Editor.h @@ -0,0 +1,489 @@ +/* + * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org> + * Copyright (c) 2021, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <AK/BinarySearch.h> +#include <AK/ByteBuffer.h> +#include <AK/Function.h> +#include <AK/HashMap.h> +#include <AK/NonnullOwnPtr.h> +#include <AK/QuickSort.h> +#include <AK/Result.h> +#include <AK/String.h> +#include <AK/Traits.h> +#include <AK/Utf32View.h> +#include <AK/Utf8View.h> +#include <AK/Vector.h> +#include <LibCore/DirIterator.h> +#include <LibCore/EventLoop.h> +#include <LibCore/Notifier.h> +#include <LibCore/Object.h> +#include <LibLine/KeyCallbackMachine.h> +#include <LibLine/Span.h> +#include <LibLine/StringMetrics.h> +#include <LibLine/Style.h> +#include <LibLine/SuggestionDisplay.h> +#include <LibLine/SuggestionManager.h> +#include <LibLine/VT.h> +#include <sys/ioctl.h> +#include <sys/stat.h> +#include <termios.h> + +namespace Line { + +struct KeyBinding { + Vector<Key> keys; + enum class Kind { + InternalFunction, + Insertion, + } kind { Kind::InternalFunction }; + String binding; +}; + +struct Configuration { + enum RefreshBehaviour { + Lazy, + Eager, + }; + enum OperationMode { + Unset, + Full, + NoEscapeSequences, + NonInteractive, + }; + enum SignalHandler { + WithSignalHandlers, + NoSignalHandlers, + }; + + Configuration() + { + } + + template<typename Arg, typename... Rest> + Configuration(Arg arg, Rest... rest) + : Configuration(rest...) + { + set(arg); + } + + void set(RefreshBehaviour refresh) { refresh_behaviour = refresh; } + void set(OperationMode mode) { operation_mode = mode; } + void set(SignalHandler mode) { m_signal_mode = mode; } + void set(const KeyBinding& binding) { keybindings.append(binding); } + + static Configuration from_config(const StringView& libname = "line"); + + RefreshBehaviour refresh_behaviour { RefreshBehaviour::Lazy }; + SignalHandler m_signal_mode { SignalHandler::WithSignalHandlers }; + OperationMode operation_mode { OperationMode::Unset }; + Vector<KeyBinding> keybindings; +}; + +#define ENUMERATE_EDITOR_INTERNAL_FUNCTIONS(M) \ + M(clear_screen) \ + M(cursor_left_character) \ + M(cursor_left_word) \ + M(cursor_right_character) \ + M(cursor_right_word) \ + M(enter_search) \ + M(erase_character_backwards) \ + M(erase_character_forwards) \ + M(erase_to_beginning) \ + M(erase_to_end) \ + M(erase_word_backwards) \ + M(finish_edit) \ + M(go_end) \ + M(go_home) \ + M(kill_line) \ + M(search_backwards) \ + M(search_forwards) \ + M(transpose_characters) \ + M(transpose_words) \ + M(insert_last_words) \ + M(erase_alnum_word_backwards) \ + M(erase_alnum_word_forwards) \ + M(capitalize_word) \ + M(lowercase_word) \ + M(uppercase_word) + +#define EDITOR_INTERNAL_FUNCTION(name) \ + [](auto& editor) { editor.name(); return false; } + +class Editor : public Core::Object { + C_OBJECT(Editor); + +public: + enum class Error { + ReadFailure, + Empty, + Eof, + }; + + ~Editor(); + + Result<String, Error> get_line(const String& prompt); + + void initialize(); + + void add_to_history(const String& line); + bool load_history(const String& path); + bool save_history(const String& path); + const auto& history() const { return m_history; } + + void register_key_input_callback(const KeyBinding&); + void register_key_input_callback(Vector<Key> keys, Function<bool(Editor&)> callback) { m_callback_machine.register_key_input_callback(move(keys), move(callback)); } + void register_key_input_callback(Key key, Function<bool(Editor&)> callback) { register_key_input_callback(Vector<Key> { key }, move(callback)); } + + static StringMetrics actual_rendered_string_metrics(const StringView&); + static StringMetrics actual_rendered_string_metrics(const Utf32View&); + + Function<Vector<CompletionSuggestion>(const Editor&)> on_tab_complete; + Function<void()> on_interrupt_handled; + Function<void(Editor&)> on_display_refresh; + + static Function<bool(Editor&)> find_internal_function(const StringView& name); + enum class CaseChangeOp { + Lowercase, + Uppercase, + Capital, + }; + void case_change_word(CaseChangeOp); +#define __ENUMERATE_EDITOR_INTERNAL_FUNCTION(name) \ + void name(); + + ENUMERATE_EDITOR_INTERNAL_FUNCTIONS(__ENUMERATE_EDITOR_INTERNAL_FUNCTION) + +#undef __ENUMERATE_EDITOR_INTERNAL_FUNCTION + + void interrupted(); + void resized() + { + m_was_resized = true; + m_previous_num_columns = m_num_columns; + get_terminal_size(); + m_suggestion_display->set_vt_size(m_num_lines, m_num_columns); + if (m_is_searching) + m_search_editor->resized(); + } + + size_t cursor() const { return m_cursor; } + void set_cursor(size_t cursor) + { + if (cursor > m_buffer.size()) + cursor = m_buffer.size(); + m_cursor = cursor; + } + const Vector<u32, 1024>& buffer() const { return m_buffer; } + u32 buffer_at(size_t pos) const { return m_buffer.at(pos); } + String line() const { return line(m_buffer.size()); } + String line(size_t up_to_index) const; + + // Only makes sense inside a character_input callback or on_* callback. + void set_prompt(const String& prompt) + { + if (m_cached_prompt_valid) + m_old_prompt_metrics = m_cached_prompt_metrics; + m_cached_prompt_valid = false; + m_cached_prompt_metrics = actual_rendered_string_metrics(prompt); + m_new_prompt = prompt; + } + + void clear_line(); + void insert(const String&); + void insert(const StringView&); + void insert(const Utf32View&); + void insert(const u32); + void stylize(const Span&, const Style&); + void strip_styles(bool strip_anchored = false); + + // Invariant Offset is an offset into the suggested data, hinting the editor what parts of the suggestion will not change + // Static Offset is an offset into the token, signifying where the suggestions start + // e.g. + // foobar<suggestion initiated>, on_tab_complete returns "barx", "bary", "barz" + // ^ ^ + // +-|- static offset: the suggestions start here + // +- invariant offset: the suggestions do not change up to here + // + void suggest(size_t invariant_offset = 0, size_t static_offset = 0, Span::Mode offset_mode = Span::ByteOriented) const; + + const struct termios& termios() const { return m_termios; } + const struct termios& default_termios() const { return m_default_termios; } + struct winsize terminal_size() const + { + winsize ws { (u16)m_num_lines, (u16)m_num_columns, 0, 0 }; + return ws; + } + + void finish() + { + m_finish = true; + } + + bool is_editing() const { return m_is_editing; } + + const Utf32View buffer_view() const { return { m_buffer.data(), m_buffer.size() }; } + +private: + explicit Editor(Configuration configuration = Configuration::from_config()); + + void set_default_keybinds(); + + enum VTState { + Free = 1, + Escape = 3, + Bracket = 5, + BracketArgsSemi = 7, + Title = 9, + }; + + static VTState actual_rendered_string_length_step(StringMetrics&, size_t, StringMetrics::LineMetrics& current_line, u32, u32, VTState); + + enum LoopExitCode { + Exit = 0, + Retry + }; + + // FIXME: Port to Core::Property + void save_to(JsonObject&); + + void try_update_once(); + void handle_interrupt_event(); + void handle_read_event(); + + Vector<size_t, 2> vt_dsr(); + void remove_at_index(size_t); + + enum class ModificationKind { + Insertion, + Removal, + ForcedOverlapRemoval, + }; + void readjust_anchored_styles(size_t hint_index, ModificationKind); + + Style find_applicable_style(size_t offset) const; + + bool search(const StringView&, bool allow_empty = false, bool from_beginning = true); + inline void end_search() + { + m_is_searching = false; + m_refresh_needed = true; + m_search_offset = 0; + if (m_reset_buffer_on_search_end) { + m_buffer.clear(); + for (auto ch : m_pre_search_buffer) + m_buffer.append(ch); + m_cursor = m_pre_search_cursor; + } + m_reset_buffer_on_search_end = true; + m_search_editor = nullptr; + } + + void reset() + { + m_cached_buffer_metrics.reset(); + m_cached_prompt_valid = false; + m_cursor = 0; + m_drawn_cursor = 0; + m_inline_search_cursor = 0; + m_search_offset = 0; + m_search_offset_state = SearchOffsetState::Unbiased; + m_old_prompt_metrics = m_cached_prompt_metrics; + set_origin(0, 0); + m_prompt_lines_at_suggestion_initiation = 0; + m_refresh_needed = true; + m_input_error.clear(); + m_returned_line = String::empty(); + } + + void refresh_display(); + void cleanup(); + void cleanup_suggestions(); + void really_quit_event_loop(); + + void restore() + { + ASSERT(m_initialized); + tcsetattr(0, TCSANOW, &m_default_termios); + m_initialized = false; + for (auto id : m_signal_handlers) + Core::EventLoop::unregister_signal(id); + } + + const StringMetrics& current_prompt_metrics() const + { + return m_cached_prompt_valid ? m_cached_prompt_metrics : m_old_prompt_metrics; + } + + size_t num_lines() const + { + return current_prompt_metrics().lines_with_addition(m_cached_buffer_metrics, m_num_columns); + } + + size_t cursor_line() const + { + auto cursor = m_drawn_cursor; + if (cursor > m_cursor) + cursor = m_cursor; + return current_prompt_metrics().lines_with_addition( + actual_rendered_string_metrics(buffer_view().substring_view(0, cursor)), + m_num_columns); + } + + size_t offset_in_line() const + { + auto cursor = m_drawn_cursor; + if (cursor > m_cursor) + cursor = m_cursor; + auto buffer_metrics = actual_rendered_string_metrics(buffer_view().substring_view(0, cursor)); + return current_prompt_metrics().offset_with_addition(buffer_metrics, m_num_columns); + } + + void set_origin() + { + auto position = vt_dsr(); + set_origin(position[0], position[1]); + } + + void set_origin(int row, int col) + { + m_origin_row = row; + m_origin_column = col; + m_suggestion_display->set_origin(row, col, {}); + } + + void recalculate_origin(); + void reposition_cursor(bool to_end = false); + + struct CodepointRange { + size_t start { 0 }; + size_t end { 0 }; + }; + CodepointRange byte_offset_range_to_code_point_offset_range(size_t byte_start, size_t byte_end, size_t code_point_scan_offset, bool reverse = false) const; + + void get_terminal_size(); + + bool m_finish { false }; + + RefPtr<Editor> m_search_editor; + bool m_is_searching { false }; + bool m_reset_buffer_on_search_end { true }; + size_t m_search_offset { 0 }; + enum class SearchOffsetState { + Unbiased, + Backwards, + Forwards, + } m_search_offset_state { SearchOffsetState::Unbiased }; + size_t m_pre_search_cursor { 0 }; + Vector<u32, 1024> m_pre_search_buffer; + + Vector<u32, 1024> m_buffer; + ByteBuffer m_pending_chars; + Vector<char, 512> m_incomplete_data; + Optional<Error> m_input_error; + String m_returned_line; + + size_t m_cursor { 0 }; + size_t m_drawn_cursor { 0 }; + size_t m_inline_search_cursor { 0 }; + size_t m_chars_inserted_in_the_middle { 0 }; + size_t m_times_tab_pressed { 0 }; + size_t m_num_columns { 0 }; + size_t m_num_lines { 1 }; + size_t m_previous_num_columns { 0 }; + size_t m_extra_forward_lines { 0 }; + StringMetrics m_cached_prompt_metrics; + StringMetrics m_old_prompt_metrics; + StringMetrics m_cached_buffer_metrics; + size_t m_prompt_lines_at_suggestion_initiation { 0 }; + bool m_cached_prompt_valid { false }; + + // Exact position before our prompt in the terminal. + size_t m_origin_row { 0 }; + size_t m_origin_column { 0 }; + + OwnPtr<SuggestionDisplay> m_suggestion_display; + + String m_new_prompt; + + SuggestionManager m_suggestion_manager; + + bool m_always_refresh { false }; + + enum class TabDirection { + Forward, + Backward, + }; + TabDirection m_tab_direction { TabDirection::Forward }; + + KeyCallbackMachine m_callback_machine; + + struct termios m_termios { + }; + struct termios m_default_termios { + }; + bool m_was_interrupted { false }; + bool m_was_resized { false }; + + // FIXME: This should be something more take_first()-friendly. + struct HistoryEntry { + String entry; + time_t timestamp; + }; + Vector<HistoryEntry> m_history; + size_t m_history_cursor { 0 }; + size_t m_history_capacity { 1024 }; + + enum class InputState { + Free, + Verbatim, + GotEscape, + CSIExpectParameter, + CSIExpectIntermediate, + CSIExpectFinal, + }; + InputState m_state { InputState::Free }; + + HashMap<u32, HashMap<u32, Style>> m_spans_starting; + HashMap<u32, HashMap<u32, Style>> m_spans_ending; + + HashMap<u32, HashMap<u32, Style>> m_anchored_spans_starting; + HashMap<u32, HashMap<u32, Style>> m_anchored_spans_ending; + + RefPtr<Core::Notifier> m_notifier; + + bool m_initialized { false }; + bool m_refresh_needed { false }; + Vector<int, 2> m_signal_handlers; + + bool m_is_editing { false }; + + Configuration m_configuration; +}; + +} diff --git a/Userland/Libraries/LibLine/InternalFunctions.cpp b/Userland/Libraries/LibLine/InternalFunctions.cpp new file mode 100644 index 0000000000..87a7d03b55 --- /dev/null +++ b/Userland/Libraries/LibLine/InternalFunctions.cpp @@ -0,0 +1,496 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include <AK/StringBuilder.h> +#include <LibLine/Editor.h> +#include <ctype.h> +#include <stdio.h> + +namespace { +constexpr u32 ctrl(char c) { return c & 0x3f; } +} + +namespace Line { + +Function<bool(Editor&)> Editor::find_internal_function(const StringView& name) +{ +#define __ENUMERATE(internal_name) \ + if (name == #internal_name) \ + return EDITOR_INTERNAL_FUNCTION(internal_name); + + ENUMERATE_EDITOR_INTERNAL_FUNCTIONS(__ENUMERATE) + + return {}; +} + +void Editor::search_forwards() +{ + ScopedValueRollback inline_search_cursor_rollback { m_inline_search_cursor }; + StringBuilder builder; + builder.append(Utf32View { m_buffer.data(), m_inline_search_cursor }); + String search_phrase = builder.to_string(); + if (m_search_offset_state == SearchOffsetState::Backwards) + --m_search_offset; + if (m_search_offset > 0) { + ScopedValueRollback search_offset_rollback { m_search_offset }; + --m_search_offset; + if (search(search_phrase, true)) { + m_search_offset_state = SearchOffsetState::Forwards; + search_offset_rollback.set_override_rollback_value(m_search_offset); + } else { + m_search_offset_state = SearchOffsetState::Unbiased; + } + } else { + m_search_offset_state = SearchOffsetState::Unbiased; + m_cursor = 0; + m_buffer.clear(); + insert(search_phrase); + m_refresh_needed = true; + } +} + +void Editor::search_backwards() +{ + ScopedValueRollback inline_search_cursor_rollback { m_inline_search_cursor }; + StringBuilder builder; + builder.append(Utf32View { m_buffer.data(), m_inline_search_cursor }); + String search_phrase = builder.to_string(); + if (m_search_offset_state == SearchOffsetState::Forwards) + ++m_search_offset; + if (search(search_phrase, true)) { + m_search_offset_state = SearchOffsetState::Backwards; + ++m_search_offset; + } else { + m_search_offset_state = SearchOffsetState::Unbiased; + --m_search_offset; + } +} + +void Editor::cursor_left_word() +{ + if (m_cursor > 0) { + auto skipped_at_least_one_character = false; + for (;;) { + if (m_cursor == 0) + break; + if (skipped_at_least_one_character && !isalnum(m_buffer[m_cursor - 1])) // stop *after* a non-alnum, but only if it changes the position + break; + skipped_at_least_one_character = true; + --m_cursor; + } + } + m_inline_search_cursor = m_cursor; +} + +void Editor::cursor_left_character() +{ + if (m_cursor > 0) + --m_cursor; + m_inline_search_cursor = m_cursor; +} + +void Editor::cursor_right_word() +{ + if (m_cursor < m_buffer.size()) { + // Temporarily put a space at the end of our buffer, + // doing this greatly simplifies the logic below. + m_buffer.append(' '); + for (;;) { + if (m_cursor >= m_buffer.size()) + break; + if (!isalnum(m_buffer[++m_cursor])) + break; + } + m_buffer.take_last(); + } + m_inline_search_cursor = m_cursor; + m_search_offset = 0; +} + +void Editor::cursor_right_character() +{ + if (m_cursor < m_buffer.size()) { + ++m_cursor; + } + m_inline_search_cursor = m_cursor; + m_search_offset = 0; +} + +void Editor::erase_character_backwards() +{ + if (m_is_searching) { + return; + } + if (m_cursor == 0) { + fputc('\a', stderr); + fflush(stderr); + return; + } + remove_at_index(m_cursor - 1); + --m_cursor; + m_inline_search_cursor = m_cursor; + // We will have to redraw :( + m_refresh_needed = true; +} + +void Editor::erase_character_forwards() +{ + if (m_cursor == m_buffer.size()) { + fputc('\a', stderr); + fflush(stderr); + return; + } + remove_at_index(m_cursor); + m_refresh_needed = true; +} + +void Editor::finish_edit() +{ + fprintf(stderr, "<EOF>\n"); + if (!m_always_refresh) { + m_input_error = Error::Eof; + finish(); + really_quit_event_loop(); + } +} + +void Editor::kill_line() +{ + for (size_t i = 0; i < m_cursor; ++i) + remove_at_index(0); + m_cursor = 0; + m_refresh_needed = true; +} + +void Editor::erase_word_backwards() +{ + // A word here is space-separated. `foo=bar baz` is two words. + bool has_seen_nonspace = false; + while (m_cursor > 0) { + if (isspace(m_buffer[m_cursor - 1])) { + if (has_seen_nonspace) + break; + } else { + has_seen_nonspace = true; + } + erase_character_backwards(); + } +} + +void Editor::erase_to_end() +{ + while (m_cursor < m_buffer.size()) + erase_character_forwards(); +} + +void Editor::erase_to_beginning() +{ +} + +void Editor::transpose_characters() +{ + if (m_cursor > 0 && m_buffer.size() >= 2) { + if (m_cursor < m_buffer.size()) + ++m_cursor; + swap(m_buffer[m_cursor - 1], m_buffer[m_cursor - 2]); + // FIXME: Update anchored styles too. + m_refresh_needed = true; + } +} + +void Editor::enter_search() +{ + if (m_is_searching) { + // How did we get here? + ASSERT_NOT_REACHED(); + } else { + m_is_searching = true; + m_search_offset = 0; + m_pre_search_buffer.clear(); + for (auto code_point : m_buffer) + m_pre_search_buffer.append(code_point); + m_pre_search_cursor = m_cursor; + + // Disable our own notifier so as to avoid interfering with the search editor. + m_notifier->set_enabled(false); + + m_search_editor = Editor::construct(Configuration { Configuration::Eager, Configuration::NoSignalHandlers }); // Has anyone seen 'Inception'? + m_search_editor->initialize(); + add_child(*m_search_editor); + + m_search_editor->on_display_refresh = [this](Editor& search_editor) { + StringBuilder builder; + builder.append(Utf32View { search_editor.buffer().data(), search_editor.buffer().size() }); + if (!search(builder.build(), false, false)) { + m_buffer.clear(); + m_cursor = 0; + } + refresh_display(); + }; + + // Whenever the search editor gets a ^R, cycle between history entries. + m_search_editor->register_key_input_callback(ctrl('R'), [this](Editor& search_editor) { + ++m_search_offset; + search_editor.m_refresh_needed = true; + return false; // Do not process this key event + }); + + // Whenever the search editor gets a backspace, cycle back between history entries + // unless we're at the zeroth entry, in which case, allow the deletion. + m_search_editor->register_key_input_callback(m_termios.c_cc[VERASE], [this](Editor& search_editor) { + if (m_search_offset > 0) { + --m_search_offset; + search_editor.m_refresh_needed = true; + return false; // Do not process this key event + } + + search_editor.erase_character_backwards(); + return false; + }); + + // ^L - This is a source of issues, as the search editor refreshes first, + // and we end up with the wrong order of prompts, so we will first refresh + // ourselves, then refresh the search editor, and then tell him not to process + // this event. + m_search_editor->register_key_input_callback(ctrl('L'), [this](auto& search_editor) { + fprintf(stderr, "\033[3J\033[H\033[2J"); // Clear screen. + + // refresh our own prompt + set_origin(1, 1); + m_refresh_needed = true; + refresh_display(); + + // move the search prompt below ours + // and tell it to redraw itself + search_editor.set_origin(2, 1); + search_editor.m_refresh_needed = true; + + return false; + }); + + // quit without clearing the current buffer + m_search_editor->register_key_input_callback('\t', [this](Editor& search_editor) { + search_editor.finish(); + m_reset_buffer_on_search_end = false; + return false; + }); + + fprintf(stderr, "\n"); + fflush(stderr); + + auto search_prompt = "\x1b[32msearch:\x1b[0m "; + + // While the search editor is active, we do not want editing events. + m_is_editing = false; + + auto search_string_result = m_search_editor->get_line(search_prompt); + + // Grab where the search origin last was, anything up to this point will be cleared. + auto search_end_row = m_search_editor->m_origin_row; + + remove_child(*m_search_editor); + m_search_editor = nullptr; + m_is_searching = false; + m_is_editing = true; + m_search_offset = 0; + + // Re-enable the notifier after discarding the search editor. + m_notifier->set_enabled(true); + + if (search_string_result.is_error()) { + // Somethine broke, fail + m_input_error = search_string_result.error(); + finish(); + return; + } + + auto& search_string = search_string_result.value(); + + // Manually cleanup the search line. + reposition_cursor(); + auto search_metrics = actual_rendered_string_metrics(search_string); + auto metrics = actual_rendered_string_metrics(search_prompt); + VT::clear_lines(0, metrics.lines_with_addition(search_metrics, m_num_columns) + search_end_row - m_origin_row - 1); + + reposition_cursor(); + + if (!m_reset_buffer_on_search_end || search_metrics.total_length == 0) { + // If the entry was empty, or we purposely quit without a newline, + // do not return anything; instead, just end the search. + end_search(); + return; + } + + // Return the string, + finish(); + } +} + +void Editor::transpose_words() +{ + // A word here is contiguous alnums. `foo=bar baz` is three words. + + // 'abcd,.:efg...' should become 'efg...,.:abcd' if caret is after + // 'efg...'. If it's in 'efg', it should become 'efg,.:abcd...' + // with the caret after it, which then becomes 'abcd...,.:efg' + // when alt-t is pressed a second time. + + // Move to end of word under (or after) caret. + size_t cursor = m_cursor; + while (cursor < m_buffer.size() && !isalnum(m_buffer[cursor])) + ++cursor; + while (cursor < m_buffer.size() && isalnum(m_buffer[cursor])) + ++cursor; + + // Move left over second word and the space to its right. + size_t end = cursor; + size_t start = cursor; + while (start > 0 && !isalnum(m_buffer[start - 1])) + --start; + while (start > 0 && isalnum(m_buffer[start - 1])) + --start; + size_t start_second_word = start; + + // Move left over space between the two words. + while (start > 0 && !isalnum(m_buffer[start - 1])) + --start; + size_t start_gap = start; + + // Move left over first word. + while (start > 0 && isalnum(m_buffer[start - 1])) + --start; + + if (start != start_gap) { + // To swap the two words, swap each word (and the gap) individually, and then swap the whole range. + auto swap_range = [this](auto from, auto to) { + for (size_t i = 0; i < (to - from) / 2; ++i) + swap(m_buffer[from + i], m_buffer[to - 1 - i]); + }; + swap_range(start, start_gap); + swap_range(start_gap, start_second_word); + swap_range(start_second_word, end); + swap_range(start, end); + m_cursor = cursor; + // FIXME: Update anchored styles too. + m_refresh_needed = true; + } +} + +void Editor::go_home() +{ + m_cursor = 0; + m_inline_search_cursor = m_cursor; + m_search_offset = 0; +} + +void Editor::go_end() +{ + m_cursor = m_buffer.size(); + m_inline_search_cursor = m_cursor; + m_search_offset = 0; +} + +void Editor::clear_screen() +{ + fprintf(stderr, "\033[3J\033[H\033[2J"); // Clear screen. + VT::move_absolute(1, 1); + set_origin(1, 1); + m_refresh_needed = true; +} + +void Editor::insert_last_words() +{ + if (!m_history.is_empty()) { + // FIXME: This isn't quite right: if the last arg was `"foo bar"` or `foo\ bar` (but not `foo\\ bar`), we should insert that whole arg as last token. + if (auto last_words = m_history.last().entry.split_view(' '); !last_words.is_empty()) + insert(last_words.last()); + } +} + +void Editor::erase_alnum_word_backwards() +{ + // A word here is contiguous alnums. `foo=bar baz` is three words. + bool has_seen_alnum = false; + while (m_cursor > 0) { + if (!isalnum(m_buffer[m_cursor - 1])) { + if (has_seen_alnum) + break; + } else { + has_seen_alnum = true; + } + erase_character_backwards(); + } +} + +void Editor::erase_alnum_word_forwards() +{ + // A word here is contiguous alnums. `foo=bar baz` is three words. + bool has_seen_alnum = false; + while (m_cursor < m_buffer.size()) { + if (!isalnum(m_buffer[m_cursor])) { + if (has_seen_alnum) + break; + } else { + has_seen_alnum = true; + } + erase_character_forwards(); + } +} + +void Editor::case_change_word(Editor::CaseChangeOp change_op) +{ + // A word here is contiguous alnums. `foo=bar baz` is three words. + while (m_cursor < m_buffer.size() && !isalnum(m_buffer[m_cursor])) + ++m_cursor; + size_t start = m_cursor; + while (m_cursor < m_buffer.size() && isalnum(m_buffer[m_cursor])) { + if (change_op == CaseChangeOp::Uppercase || (change_op == CaseChangeOp::Capital && m_cursor == start)) { + m_buffer[m_cursor] = toupper(m_buffer[m_cursor]); + } else { + ASSERT(change_op == CaseChangeOp::Lowercase || (change_op == CaseChangeOp::Capital && m_cursor > start)); + m_buffer[m_cursor] = tolower(m_buffer[m_cursor]); + } + ++m_cursor; + m_refresh_needed = true; + } +} + +void Editor::capitalize_word() +{ + case_change_word(CaseChangeOp::Capital); +} + +void Editor::lowercase_word() +{ + case_change_word(CaseChangeOp::Lowercase); +} + +void Editor::uppercase_word() +{ + case_change_word(CaseChangeOp::Uppercase); +} + +} diff --git a/Userland/Libraries/LibLine/KeyCallbackMachine.cpp b/Userland/Libraries/LibLine/KeyCallbackMachine.cpp new file mode 100644 index 0000000000..8f4272810a --- /dev/null +++ b/Userland/Libraries/LibLine/KeyCallbackMachine.cpp @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include <LibLine/Editor.h> + +namespace { +constexpr u32 ctrl(char c) { return c & 0x3f; } +} + +namespace Line { + +void KeyCallbackMachine::register_key_input_callback(Vector<Key> keys, Function<bool(Editor&)> callback) +{ + m_key_callbacks.set(keys, make<KeyCallback>(move(callback))); +} + +void KeyCallbackMachine::key_pressed(Editor& editor, Key key) +{ +#ifdef CALLBACK_MACHINE_DEBUG + dbgln("Key<{}, {}> pressed, seq_length={}, {} things in the matching vector", key.key, key.modifiers, m_sequence_length, m_current_matching_keys.size()); +#endif + if (m_sequence_length == 0) { + ASSERT(m_current_matching_keys.is_empty()); + + for (auto& it : m_key_callbacks) { + if (it.key.first() == key) + m_current_matching_keys.append(it.key); + } + + if (m_current_matching_keys.is_empty()) { + m_should_process_this_key = true; + return; + } + } + + ++m_sequence_length; + Vector<Vector<Key>> old_macthing_keys; + swap(m_current_matching_keys, old_macthing_keys); + + for (auto& okey : old_macthing_keys) { + if (okey.size() < m_sequence_length) + continue; + + if (okey[m_sequence_length - 1] == key) + m_current_matching_keys.append(okey); + } + + if (m_current_matching_keys.is_empty()) { + // Insert any keys that were captured + if (!old_macthing_keys.is_empty()) { + auto& keys = old_macthing_keys.first(); + for (size_t i = 0; i < m_sequence_length - 1; ++i) + editor.insert(keys[i].key); + } + m_sequence_length = 0; + m_should_process_this_key = true; + return; + } + +#ifdef CALLBACK_MACHINE_DEBUG + dbgln("seq_length={}, matching vector:", m_sequence_length); + for (auto& key : m_current_matching_keys) { + for (auto& k : key) + dbgln(" {}, {}", k.key, k.modifiers); + dbgln(""); + } +#endif + + m_should_process_this_key = false; + for (auto& key : m_current_matching_keys) { + if (key.size() == m_sequence_length) { + m_should_process_this_key = m_key_callbacks.get(key).value()->callback(editor); + m_sequence_length = 0; + m_current_matching_keys.clear(); + return; + } + } +} + +void KeyCallbackMachine::interrupted(Editor& editor) +{ + m_sequence_length = 0; + m_current_matching_keys.clear(); + if (auto callback = m_key_callbacks.get({ ctrl('C') }); callback.has_value()) + m_should_process_this_key = callback.value()->callback(editor); + else + m_should_process_this_key = true; +} + +} diff --git a/Userland/Libraries/LibLine/KeyCallbackMachine.h b/Userland/Libraries/LibLine/KeyCallbackMachine.h new file mode 100644 index 0000000000..b0689904e8 --- /dev/null +++ b/Userland/Libraries/LibLine/KeyCallbackMachine.h @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2020, the SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <AK/Function.h> +#include <AK/HashMap.h> +#include <AK/String.h> +#include <AK/Vector.h> + +namespace Line { + +class Editor; + +struct Key { + enum Modifier : int { + None = 0, + Alt = 1, + }; + + int modifiers { None }; + unsigned key { 0 }; + + Key(unsigned c) + : modifiers(None) + , key(c) {}; + + Key(unsigned c, int modifiers) + : modifiers(modifiers) + , key(c) + { + } + + bool operator==(const Key& other) const + { + return other.key == key && other.modifiers == modifiers; + } + + bool operator!=(const Key& other) const + { + return !(*this == other); + } +}; + +struct KeyCallback { + KeyCallback(Function<bool(Editor&)> cb) + : callback(move(cb)) + { + } + Function<bool(Editor&)> callback; +}; + +class KeyCallbackMachine { +public: + void register_key_input_callback(Vector<Key>, Function<bool(Editor&)> callback); + void key_pressed(Editor&, Key); + void interrupted(Editor&); + bool should_process_last_pressed_key() const { return m_should_process_this_key; } + +private: + HashMap<Vector<Key>, NonnullOwnPtr<KeyCallback>> m_key_callbacks; + Vector<Vector<Key>> m_current_matching_keys; + size_t m_sequence_length { 0 }; + bool m_should_process_this_key { true }; +}; + +} + +namespace AK { + +template<> +struct Traits<Line::Key> : public GenericTraits<Line::Key> { + static constexpr bool is_trivial() { return true; } + static unsigned hash(Line::Key k) { return pair_int_hash(k.key, k.modifiers); } +}; + +template<> +struct Traits<Vector<Line::Key>> : public GenericTraits<Vector<Line::Key>> { + static constexpr bool is_trivial() { return false; } + static unsigned hash(const Vector<Line::Key>& ks) + { + unsigned h = 0; + for (auto& k : ks) + h ^= Traits<Line::Key>::hash(k); + return h; + } +}; + +} diff --git a/Userland/Libraries/LibLine/Span.h b/Userland/Libraries/LibLine/Span.h new file mode 100644 index 0000000000..0d1eb108f8 --- /dev/null +++ b/Userland/Libraries/LibLine/Span.h @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2020, The SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +namespace Line { + +class Span { +public: + enum Mode { + ByteOriented, + CodepointOriented, + }; + + Span(size_t start, size_t end, Mode mode = ByteOriented) + : m_beginning(start) + , m_end(end) + , m_mode(mode) + { + } + + size_t beginning() const { return m_beginning; } + size_t end() const { return m_end; } + Mode mode() const { return m_mode; } + +private: + size_t m_beginning { 0 }; + size_t m_end { 0 }; + Mode m_mode { CodepointOriented }; +}; + +} diff --git a/Userland/Libraries/LibLine/StringMetrics.h b/Userland/Libraries/LibLine/StringMetrics.h new file mode 100644 index 0000000000..5a23953976 --- /dev/null +++ b/Userland/Libraries/LibLine/StringMetrics.h @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2020-2021, The SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <AK/Types.h> +#include <AK/Vector.h> + +namespace Line { + +struct StringMetrics { + struct MaskedChar { + size_t position { 0 }; + size_t original_length { 0 }; + size_t masked_length { 0 }; + }; + struct LineMetrics { + Vector<MaskedChar> masked_chars; + size_t length { 0 }; + + size_t total_length(ssize_t offset = -1) const + { + size_t length = this->length; + for (auto& mask : masked_chars) { + if (offset < 0 || mask.position <= (size_t)offset) { + length -= mask.original_length; + length += mask.masked_length; + } + } + return length; + } + }; + + Vector<LineMetrics> line_metrics; + size_t total_length { 0 }; + size_t max_line_length { 0 }; + + size_t lines_with_addition(const StringMetrics& offset, size_t column_width) const; + size_t offset_with_addition(const StringMetrics& offset, size_t column_width) const; + void reset() + { + line_metrics.clear(); + total_length = 0; + max_line_length = 0; + line_metrics.append({ {}, 0 }); + } +}; + +} diff --git a/Userland/Libraries/LibLine/Style.h b/Userland/Libraries/LibLine/Style.h new file mode 100644 index 0000000000..00066af1df --- /dev/null +++ b/Userland/Libraries/LibLine/Style.h @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2020, The SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <AK/String.h> +#include <AK/Types.h> +#include <AK/Vector.h> +#include <stdlib.h> + +namespace Line { + +class Style { +public: + enum class XtermColor : int { + Default = 9, + Black = 0, + Red, + Green, + Yellow, + Blue, + Magenta, + Cyan, + White, + Unchanged, + }; + + struct AnchoredTag { + }; + struct UnderlineTag { + }; + struct BoldTag { + }; + struct ItalicTag { + }; + struct Color { + explicit Color(XtermColor color) + : m_xterm_color(color) + , m_is_rgb(false) + { + } + Color(u8 r, u8 g, u8 b) + : m_rgb_color({ r, g, b }) + , m_is_rgb(true) + { + } + + bool is_default() const + { + return !m_is_rgb && m_xterm_color == XtermColor::Unchanged; + } + + XtermColor m_xterm_color { XtermColor::Unchanged }; + Vector<int, 3> m_rgb_color; + bool m_is_rgb { false }; + }; + + struct Background : public Color { + explicit Background(XtermColor color) + : Color(color) + { + } + Background(u8 r, u8 g, u8 b) + : Color(r, g, b) + { + } + String to_vt_escape() const; + }; + + struct Foreground : public Color { + explicit Foreground(XtermColor color) + : Color(color) + { + } + Foreground(u8 r, u8 g, u8 b) + : Color(r, g, b) + { + } + + String to_vt_escape() const; + }; + + struct Hyperlink { + explicit Hyperlink(const StringView& link) + : m_link(link) + { + m_has_link = true; + } + + Hyperlink() { } + + String to_vt_escape(bool starting) const; + + bool is_empty() const { return !m_has_link; } + + String m_link; + bool m_has_link { false }; + }; + + static constexpr UnderlineTag Underline {}; + static constexpr BoldTag Bold {}; + static constexpr ItalicTag Italic {}; + static constexpr AnchoredTag Anchored {}; + + // Prepare for the horror of templates. + template<typename T, typename... Rest> + Style(const T& style_arg, Rest... rest) + : Style(rest...) + { + set(style_arg); + m_is_empty = false; + } + Style() { } + + static Style reset_style() + { + return { Foreground(XtermColor::Default), Background(XtermColor::Default), Hyperlink("") }; + } + + Style unified_with(const Style& other, bool prefer_other = true) const + { + Style style = *this; + style.unify_with(other, prefer_other); + return style; + } + + void unify_with(const Style&, bool prefer_other = false); + + bool underline() const { return m_underline; } + bool bold() const { return m_bold; } + bool italic() const { return m_italic; } + Background background() const { return m_background; } + Foreground foreground() const { return m_foreground; } + Hyperlink hyperlink() const { return m_hyperlink; } + + void set(const ItalicTag&) { m_italic = true; } + void set(const BoldTag&) { m_bold = true; } + void set(const UnderlineTag&) { m_underline = true; } + void set(const Background& bg) { m_background = bg; } + void set(const Foreground& fg) { m_foreground = fg; } + void set(const Hyperlink& link) { m_hyperlink = link; } + void set(const AnchoredTag&) { m_is_anchored = true; } + + bool is_anchored() const { return m_is_anchored; } + bool is_empty() const { return m_is_empty; } + + String to_string() const; + +private: + bool m_underline { false }; + bool m_bold { false }; + bool m_italic { false }; + Background m_background { XtermColor::Unchanged }; + Foreground m_foreground { XtermColor::Unchanged }; + Hyperlink m_hyperlink; + + bool m_is_anchored { false }; + + bool m_is_empty { true }; +}; +} diff --git a/Userland/Libraries/LibLine/SuggestionDisplay.h b/Userland/Libraries/LibLine/SuggestionDisplay.h new file mode 100644 index 0000000000..434b0ee3a2 --- /dev/null +++ b/Userland/Libraries/LibLine/SuggestionDisplay.h @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2020, The SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <AK/Forward.h> +#include <AK/String.h> +#include <LibLine/StringMetrics.h> +#include <LibLine/SuggestionManager.h> +#include <stdlib.h> + +namespace Line { + +class Editor; + +class SuggestionDisplay { +public: + virtual ~SuggestionDisplay() { } + virtual void display(const SuggestionManager&) = 0; + virtual bool cleanup() = 0; + virtual void finish() = 0; + virtual void set_initial_prompt_lines(size_t) = 0; + + virtual void set_vt_size(size_t lines, size_t columns) = 0; + + size_t origin_row() const { return m_origin_row; } + size_t origin_col() const { return m_origin_column; } + + void set_origin(int row, int col, Badge<Editor>) + { + m_origin_row = row; + m_origin_column = col; + } + +protected: + int m_origin_row { 0 }; + int m_origin_column { 0 }; +}; + +class XtermSuggestionDisplay : public SuggestionDisplay { +public: + XtermSuggestionDisplay(size_t lines, size_t columns) + : m_num_lines(lines) + , m_num_columns(columns) + { + } + virtual ~XtermSuggestionDisplay() override { } + virtual void display(const SuggestionManager&) override; + virtual bool cleanup() override; + virtual void finish() override + { + m_pages.clear(); + } + + virtual void set_initial_prompt_lines(size_t lines) override + { + m_prompt_lines_at_suggestion_initiation = lines; + } + + virtual void set_vt_size(size_t lines, size_t columns) override + { + m_num_lines = lines; + m_num_columns = columns; + m_pages.clear(); + } + +private: + size_t fit_to_page_boundary(size_t selection_index); + size_t m_lines_used_for_last_suggestions { 0 }; + size_t m_num_lines { 0 }; + size_t m_num_columns { 0 }; + size_t m_prompt_lines_at_suggestion_initiation { 0 }; + + struct PageRange { + size_t start; + size_t end; + }; + Vector<PageRange> m_pages; +}; + +} diff --git a/Userland/Libraries/LibLine/SuggestionManager.cpp b/Userland/Libraries/LibLine/SuggestionManager.cpp new file mode 100644 index 0000000000..f7c0667e49 --- /dev/null +++ b/Userland/Libraries/LibLine/SuggestionManager.cpp @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2020, The SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include <AK/Function.h> +#include <LibLine/SuggestionManager.h> + +namespace Line { + +CompletionSuggestion::CompletionSuggestion(const StringView& completion, const StringView& trailing_trivia, Style style) + : style(style) + , text_string(completion) + , is_valid(true) +{ + Utf8View text_u8 { completion }; + Utf8View trivia_u8 { trailing_trivia }; + + for (auto cp : text_u8) + text.append(cp); + + for (auto cp : trivia_u8) + this->trailing_trivia.append(cp); + + text_view = Utf32View { text.data(), text.size() }; + trivia_view = Utf32View { this->trailing_trivia.data(), this->trailing_trivia.size() }; +} + +void SuggestionManager::set_suggestions(Vector<CompletionSuggestion>&& suggestions) +{ + m_suggestions = move(suggestions); + + // make sure we were not given invalid suggestions + for (auto& suggestion : m_suggestions) + ASSERT(suggestion.is_valid); + + size_t common_suggestion_prefix { 0 }; + if (m_suggestions.size() == 1) { + m_largest_common_suggestion_prefix_length = m_suggestions[0].text_view.length(); + } else if (m_suggestions.size()) { + u32 last_valid_suggestion_code_point; + + for (;; ++common_suggestion_prefix) { + if (m_suggestions[0].text_view.length() <= common_suggestion_prefix) + goto no_more_commons; + + last_valid_suggestion_code_point = m_suggestions[0].text_view.code_points()[common_suggestion_prefix]; + + for (auto& suggestion : m_suggestions) { + if (suggestion.text_view.length() <= common_suggestion_prefix || suggestion.text_view.code_points()[common_suggestion_prefix] != last_valid_suggestion_code_point) { + goto no_more_commons; + } + } + } + no_more_commons:; + m_largest_common_suggestion_prefix_length = common_suggestion_prefix; + } else { + m_largest_common_suggestion_prefix_length = 0; + } +} + +void SuggestionManager::next() +{ + if (m_suggestions.size()) + m_next_suggestion_index = (m_next_suggestion_index + 1) % m_suggestions.size(); + else + m_next_suggestion_index = 0; +} + +void SuggestionManager::previous() +{ + if (m_next_suggestion_index == 0) + m_next_suggestion_index = m_suggestions.size(); + m_next_suggestion_index--; +} + +const CompletionSuggestion& SuggestionManager::suggest() +{ + m_last_shown_suggestion = m_suggestions[m_next_suggestion_index]; + m_selected_suggestion_index = m_next_suggestion_index; + return m_last_shown_suggestion; +} + +void SuggestionManager::set_current_suggestion_initiation_index(size_t index) +{ + + if (m_last_shown_suggestion_display_length) + m_last_shown_suggestion.start_index = index - m_next_suggestion_static_offset - m_last_shown_suggestion_display_length; + else + m_last_shown_suggestion.start_index = index - m_next_suggestion_static_offset - m_next_suggestion_invariant_offset; + + m_last_shown_suggestion_display_length = m_last_shown_suggestion.text_view.length(); + m_last_shown_suggestion_was_complete = true; +} + +SuggestionManager::CompletionAttemptResult SuggestionManager::attempt_completion(CompletionMode mode, size_t initiation_start_index) +{ + CompletionAttemptResult result { mode }; + + if (m_next_suggestion_index < m_suggestions.size()) { + auto can_complete = m_next_suggestion_invariant_offset <= m_largest_common_suggestion_prefix_length; + if (!m_last_shown_suggestion.text.is_null()) { + ssize_t actual_offset; + size_t shown_length = m_last_shown_suggestion_display_length; + switch (mode) { + case CompletePrefix: + actual_offset = 0; + break; + case ShowSuggestions: + actual_offset = 0 - m_largest_common_suggestion_prefix_length + m_next_suggestion_invariant_offset; + if (can_complete) + shown_length = m_largest_common_suggestion_prefix_length + m_last_shown_suggestion.trivia_view.length(); + break; + default: + if (m_last_shown_suggestion_display_length == 0) + actual_offset = 0; + else + actual_offset = 0 - m_last_shown_suggestion_display_length + m_next_suggestion_invariant_offset; + break; + } + + result.offset_region_to_remove = { m_next_suggestion_invariant_offset, shown_length }; + result.new_cursor_offset = actual_offset; + } + + auto& suggestion = suggest(); + set_current_suggestion_initiation_index(initiation_start_index); + + if (mode == CompletePrefix) { + // Only auto-complete *if possible*. + if (can_complete) { + result.insert.append(suggestion.text_view.substring_view(m_next_suggestion_invariant_offset, m_largest_common_suggestion_prefix_length - m_next_suggestion_invariant_offset)); + m_last_shown_suggestion_display_length = m_largest_common_suggestion_prefix_length; + // Do not increment the suggestion index, as the first tab should only be a *peek*. + if (m_suggestions.size() == 1) { + // If there's one suggestion, commit and forget. + result.new_completion_mode = DontComplete; + // Add in the trivia of the last selected suggestion. + result.insert.append(suggestion.trivia_view); + m_last_shown_suggestion_display_length = 0; + result.style_to_apply = suggestion.style; + m_last_shown_suggestion_was_complete = true; + return result; + } + } else { + m_last_shown_suggestion_display_length = 0; + } + result.new_completion_mode = CompletionMode::ShowSuggestions; + m_last_shown_suggestion_was_complete = false; + m_last_shown_suggestion = String::empty(); + } else { + result.insert.append(suggestion.text_view.substring_view(m_next_suggestion_invariant_offset, suggestion.text_view.length() - m_next_suggestion_invariant_offset)); + // Add in the trivia of the last selected suggestion. + result.insert.append(suggestion.trivia_view); + m_last_shown_suggestion_display_length += suggestion.trivia_view.length(); + } + } else { + m_next_suggestion_index = 0; + } + return result; +} + +size_t SuggestionManager::for_each_suggestion(Function<IterationDecision(const CompletionSuggestion&, size_t)> callback) const +{ + size_t start_index { 0 }; + for (auto& suggestion : m_suggestions) { + if (start_index++ < m_last_displayed_suggestion_index) + continue; + if (callback(suggestion, start_index - 1) == IterationDecision::Break) + break; + } + return start_index; +} + +} diff --git a/Userland/Libraries/LibLine/SuggestionManager.h b/Userland/Libraries/LibLine/SuggestionManager.h new file mode 100644 index 0000000000..0b804fef08 --- /dev/null +++ b/Userland/Libraries/LibLine/SuggestionManager.h @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2020, The SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <AK/Forward.h> +#include <AK/String.h> +#include <AK/Utf32View.h> +#include <AK/Utf8View.h> +#include <LibLine/Style.h> +#include <stdlib.h> + +namespace Line { + +// FIXME: These objects are pretty heavy since they store two copies of text +// somehow get rid of one. +struct CompletionSuggestion { +private: + struct ForSearchTag { + }; + +public: + static constexpr ForSearchTag ForSearch {}; + + // Intentionally not explicit. (To allow suggesting bare strings) + CompletionSuggestion(const String& completion) + : CompletionSuggestion(completion, "", {}) + { + } + + CompletionSuggestion(const String& completion, ForSearchTag) + : text_string(completion) + { + } + + CompletionSuggestion(const StringView& completion, const StringView& trailing_trivia) + : CompletionSuggestion(completion, trailing_trivia, {}) + { + } + + CompletionSuggestion(const StringView& completion, const StringView& trailing_trivia, Style style); + + bool operator==(const CompletionSuggestion& suggestion) const + { + return suggestion.text_string == text_string; + } + + Vector<u32> text; + Vector<u32> trailing_trivia; + Style style; + size_t start_index { 0 }; + size_t input_offset { 0 }; + + Utf32View text_view; + Utf32View trivia_view; + String text_string; + bool is_valid { false }; +}; + +class SuggestionManager { + friend class Editor; + +public: + void set_suggestions(Vector<CompletionSuggestion>&& suggestions); + void set_current_suggestion_initiation_index(size_t start_index); + + size_t count() const { return m_suggestions.size(); } + size_t display_length() const { return m_last_shown_suggestion_display_length; } + size_t start_index() const { return m_last_displayed_suggestion_index; } + size_t next_index() const { return m_next_suggestion_index; } + void set_start_index(size_t index) const { m_last_displayed_suggestion_index = index; } + + size_t for_each_suggestion(Function<IterationDecision(const CompletionSuggestion&, size_t)>) const; + + enum CompletionMode { + DontComplete, + CompletePrefix, + ShowSuggestions, + CycleSuggestions, + }; + + class CompletionAttemptResult { + public: + CompletionMode new_completion_mode; + + ssize_t new_cursor_offset { 0 }; + + struct { + size_t start; + size_t end; + } offset_region_to_remove { 0, 0 }; // The region to remove as defined by [start, end) translated by (old_cursor + new_cursor_offset) + + Vector<Utf32View> insert {}; + + Optional<Style> style_to_apply {}; + }; + + CompletionAttemptResult attempt_completion(CompletionMode, size_t initiation_start_index); + + void next(); + void previous(); + void set_suggestion_variants(size_t static_offset, size_t invariant_offset, size_t suggestion_index) const + { + m_next_suggestion_index = suggestion_index; + m_next_suggestion_static_offset = static_offset; + m_next_suggestion_invariant_offset = invariant_offset; + } + + const CompletionSuggestion& suggest(); + const CompletionSuggestion& current_suggestion() const { return m_last_shown_suggestion; } + bool is_current_suggestion_complete() const { return m_last_shown_suggestion_was_complete; } + + void reset() + { + m_last_shown_suggestion = String::empty(); + m_last_shown_suggestion_display_length = 0; + m_suggestions.clear(); + m_last_displayed_suggestion_index = 0; + } + +private: + SuggestionManager() + { + } + + Vector<CompletionSuggestion> m_suggestions; + CompletionSuggestion m_last_shown_suggestion { String::empty() }; + size_t m_last_shown_suggestion_display_length { 0 }; + bool m_last_shown_suggestion_was_complete { false }; + mutable size_t m_next_suggestion_index { 0 }; + mutable size_t m_next_suggestion_invariant_offset { 0 }; + mutable size_t m_next_suggestion_static_offset { 0 }; + size_t m_largest_common_suggestion_prefix_length { 0 }; + mutable size_t m_last_displayed_suggestion_index { 0 }; + size_t m_selected_suggestion_index { 0 }; +}; + +} diff --git a/Userland/Libraries/LibLine/VT.h b/Userland/Libraries/LibLine/VT.h new file mode 100644 index 0000000000..cb93393c22 --- /dev/null +++ b/Userland/Libraries/LibLine/VT.h @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2020, The SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include <AK/Types.h> +#include <LibLine/Style.h> + +namespace Line { +namespace VT { + +void save_cursor(); +void restore_cursor(); +void clear_to_end_of_line(); +void clear_lines(size_t count_above, size_t count_below = 0); +void move_relative(int x, int y); +void move_absolute(u32 x, u32 y); +void apply_style(const Style&, bool is_starting = true); + +} +} diff --git a/Userland/Libraries/LibLine/XtermSuggestionDisplay.cpp b/Userland/Libraries/LibLine/XtermSuggestionDisplay.cpp new file mode 100644 index 0000000000..7e341854ee --- /dev/null +++ b/Userland/Libraries/LibLine/XtermSuggestionDisplay.cpp @@ -0,0 +1,205 @@ +/* + * Copyright (c) 2020-2021, The SerenityOS developers. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include <AK/BinarySearch.h> +#include <AK/Function.h> +#include <AK/StringBuilder.h> +#include <LibLine/SuggestionDisplay.h> +#include <LibLine/VT.h> +#include <stdio.h> + +namespace Line { + +void XtermSuggestionDisplay::display(const SuggestionManager& manager) +{ + size_t longest_suggestion_length = 0; + size_t longest_suggestion_byte_length = 0; + + manager.for_each_suggestion([&](auto& suggestion, auto) { + longest_suggestion_length = max(longest_suggestion_length, suggestion.text_view.length()); + longest_suggestion_byte_length = max(longest_suggestion_byte_length, suggestion.text_string.length()); + return IterationDecision::Continue; + }); + + size_t num_printed = 0; + size_t lines_used = 1; + + VT::save_cursor(); + VT::clear_lines(0, m_lines_used_for_last_suggestions); + VT::restore_cursor(); + + auto spans_entire_line { false }; + Vector<StringMetrics::LineMetrics> lines; + for (size_t i = 0; i < m_prompt_lines_at_suggestion_initiation - 1; ++i) + lines.append({ {}, 0 }); + lines.append({ {}, longest_suggestion_length }); + auto max_line_count = StringMetrics { move(lines) }.lines_with_addition({ { { {}, 0 } } }, m_num_columns); + if (longest_suggestion_length >= m_num_columns - 2) { + spans_entire_line = true; + // We should make enough space for the biggest entry in + // the suggestion list to fit in the prompt line. + auto start = max_line_count - m_prompt_lines_at_suggestion_initiation; + for (size_t i = start; i < max_line_count; ++i) { + fputc('\n', stderr); + } + lines_used += max_line_count; + longest_suggestion_length = 0; + } + + VT::move_absolute(max_line_count + m_origin_row, 1); + + if (m_pages.is_empty()) { + size_t num_printed = 0; + size_t lines_used = 1; + // Cache the pages. + manager.set_start_index(0); + size_t page_start = 0; + manager.for_each_suggestion([&](auto& suggestion, auto index) { + size_t next_column = num_printed + suggestion.text_view.length() + longest_suggestion_length + 2; + if (next_column > m_num_columns) { + auto lines = (suggestion.text_view.length() + m_num_columns - 1) / m_num_columns; + lines_used += lines; + num_printed = 0; + } + + if (lines_used + m_prompt_lines_at_suggestion_initiation >= m_num_lines) { + m_pages.append({ page_start, index }); + page_start = index; + lines_used = 1; + num_printed = 0; + } + + if (spans_entire_line) + num_printed += m_num_columns; + else + num_printed += longest_suggestion_length + 2; + + return IterationDecision::Continue; + }); + // Append the last page. + m_pages.append({ page_start, manager.count() }); + } + + auto page_index = fit_to_page_boundary(manager.next_index()); + + manager.set_start_index(m_pages[page_index].start); + + manager.for_each_suggestion([&](auto& suggestion, auto index) { + size_t next_column = num_printed + suggestion.text_view.length() + longest_suggestion_length + 2; + + if (next_column > m_num_columns) { + auto lines = (suggestion.text_view.length() + m_num_columns - 1) / m_num_columns; + lines_used += lines; + fputc('\n', stderr); + num_printed = 0; + } + + // Show just enough suggestions to fill up the screen + // without moving the prompt out of view. + if (lines_used + m_prompt_lines_at_suggestion_initiation >= m_num_lines) + return IterationDecision::Break; + + // Only apply color to the selection if something is *actually* added to the buffer. + if (manager.is_current_suggestion_complete() && index == manager.next_index()) { + VT::apply_style({ Style::Foreground(Style::XtermColor::Blue) }); + fflush(stderr); + } + + if (spans_entire_line) { + num_printed += m_num_columns; + fprintf(stderr, "%s", suggestion.text_string.characters()); + } else { + fprintf(stderr, "%-*s", static_cast<int>(longest_suggestion_byte_length) + 2, suggestion.text_string.characters()); + num_printed += longest_suggestion_length + 2; + } + + if (manager.is_current_suggestion_complete() && index == manager.next_index()) { + VT::apply_style(Style::reset_style()); + fflush(stderr); + } + return IterationDecision::Continue; + }); + + m_lines_used_for_last_suggestions = lines_used; + + // If we filled the screen, move back the origin. + if (m_origin_row + lines_used >= m_num_lines) { + m_origin_row = m_num_lines - lines_used; + } + + if (m_pages.size() > 1) { + auto left_arrow = page_index > 0 ? '<' : ' '; + auto right_arrow = page_index < m_pages.size() - 1 ? '>' : ' '; + auto string = String::format("%c page %zu of %zu %c", left_arrow, page_index + 1, m_pages.size(), right_arrow); + + if (string.length() > m_num_columns - 1) { + // This would overflow into the next line, so just don't print an indicator. + fflush(stderr); + return; + } + + VT::move_absolute(m_origin_row + lines_used, m_num_columns - string.length() - 1); + VT::apply_style({ Style::Background(Style::XtermColor::Green) }); + fputs(string.characters(), stderr); + VT::apply_style(Style::reset_style()); + } + + fflush(stderr); +} + +bool XtermSuggestionDisplay::cleanup() +{ + if (m_lines_used_for_last_suggestions) { + VT::clear_lines(0, m_lines_used_for_last_suggestions); + m_lines_used_for_last_suggestions = 0; + return true; + } + + return false; +} + +size_t XtermSuggestionDisplay::fit_to_page_boundary(size_t selection_index) +{ + ASSERT(m_pages.size() > 0); + size_t index = 0; + + auto* match = binary_search( + m_pages.span(), + PageRange { selection_index, selection_index }, + &index, + [](auto& a, auto& b) -> int { + if (a.start >= b.start && a.start < b.end) + return 0; + return a.start - b.start; + }); + + if (!match) + return m_pages.size() - 1; + + return index; +} + +} |