diff --git a/Userland/Libraries/LibGUI/EditingEngine.h b/Userland/Libraries/LibGUI/EditingEngine.h index c718bfe6a14..83c89ca1242 100644 --- a/Userland/Libraries/LibGUI/EditingEngine.h +++ b/Userland/Libraries/LibGUI/EditingEngine.h @@ -29,6 +29,12 @@ public: void attach(TextEditor& editor); void detach(); + TextEditor& editor() + { + VERIFY(!m_editor.is_null()); + return *m_editor.unsafe_ptr(); + } + virtual bool on_key(const KeyEvent& event); protected: diff --git a/Userland/Libraries/LibGUI/VimEditingEngine.cpp b/Userland/Libraries/LibGUI/VimEditingEngine.cpp index cef0a059b67..9e3734e2645 100644 --- a/Userland/Libraries/LibGUI/VimEditingEngine.cpp +++ b/Userland/Libraries/LibGUI/VimEditingEngine.cpp @@ -1,15 +1,731 @@ /* - * Copyright (c) 2020, the SerenityOS developers. + * Copyright (c) 2020-2021, the SerenityOS developers. * * SPDX-License-Identifier: BSD-2-Clause */ +#include +#include #include #include #include +#include namespace GUI { +void VimCursor::move() +{ + if (m_forwards) + move_forwards(); + else + move_backwards(); +} + +void VimCursor::move_reverse() +{ + if (m_forwards) + move_backwards(); + else + move_forwards(); +} + +u32 VimCursor::peek() +{ + TextPosition saved_position = m_position; + + move(); + u32 peeked = current_char(); + + m_position = saved_position; + return peeked; +} + +u32 VimCursor::peek_reverse() +{ + TextPosition saved_position = m_position; + + move_reverse(); + u32 peeked = current_char(); + + m_position = saved_position; + return peeked; +} + +TextDocumentLine& VimCursor::current_line() +{ + return m_editor.line(m_position.line()); +} + +u32 VimCursor::current_char() +{ + if (on_empty_line()) { + // Fails all of isspace, ispunct, isalnum so should be good. + return 0; + } else { + return current_line().view().code_points()[m_position.column()]; + } +} + +bool VimCursor::on_empty_line() +{ + return current_line().length() == 0; +} + +bool VimCursor::will_cross_line_boundary() +{ + if (on_empty_line()) + return true; + else if (m_forwards && m_position.column() == current_line().length() - 1) + return true; + else if (!m_forwards && m_position.column() == 0) + return true; + else + return false; +} + +void VimCursor::move_forwards() +{ + if (on_empty_line() || m_position.column() == current_line().length() - 1) { + if (m_position.line() == m_editor.line_count() - 1) { + // We have reached the end of the document, so any other + // forward movements are no-ops. + m_hit_edge = true; + } else { + m_position.set_column(0); + m_position.set_line(m_position.line() + 1); + m_crossed_line_boundary = true; + } + } else { + m_position.set_column(m_position.column() + 1); + m_crossed_line_boundary = false; + } +} + +void VimCursor::move_backwards() +{ + if (m_position.column() == 0) { + if (m_position.line() == 0) { + // We have reached the start of the document, so any other + // backward movements are no-ops. + m_hit_edge = true; + } else { + m_position.set_line(m_position.line() - 1); + if (!on_empty_line()) + m_position.set_column(current_line().length() - 1); + else + m_position.set_column(0); + m_crossed_line_boundary = true; + } + } else { + m_position.set_column(m_position.column() - 1); + m_crossed_line_boundary = false; + } +} + +void VimMotion::add_key_code(KeyCode key, [[maybe_unused]] bool ctrl, bool shift, [[maybe_unused]] bool alt) +{ + + if (is_complete()) + return; + + if (m_find_mode != FindMode::None) { + // We need to consume the next character because we are going to find + // until that character. + + // HACK: there is no good way to obtain whether a character is alphanumeric + // from the keycode itself. + char const* keycode_str = key_code_to_string(key); + + if (strlen(keycode_str) == 1 && (isalpha(keycode_str[0]) || isspace(keycode_str[0]))) { + m_next_character = tolower(keycode_str[0]); + m_unit = Unit::Find; + } else { + m_unit = Unit::Unknown; + } + + m_is_complete = true; + m_should_consume_next_character = false; + return; + } + + bool should_use_guirky = m_guirky_mode; + + switch (key) { +#define DIGIT(n) \ + case KeyCode::Key_##n: \ + m_amount = (m_amount * 10) + n; \ + break + + // Digits add digits to the amount. + DIGIT(1); + DIGIT(2); + DIGIT(3); + DIGIT(4); + DIGIT(5); + DIGIT(6); + DIGIT(7); + DIGIT(8); + DIGIT(9); + +#undef DIGIT + + // If 0 appears while amount is 0, then it means beginning of line. + // Otherwise, it adds 0 to the amount. + case KeyCode::Key_0: + if (m_amount == 0) { + m_unit = Unit::Character; + m_amount = START_OF_LINE; + m_is_complete = true; + } else { + m_amount = m_amount * 10; + } + break; + + // $ means end of line. + // TODO: d2$ in vim deletes to the end of the line and then the next line. + case KeyCode::Key_Dollar: + m_unit = Unit::Character; + m_amount = END_OF_LINE; + m_is_complete = true; + break; + + // ^ means the first non-whitespace character for this line. + // It deletes backwards if you're in front of it, and forwards if you're behind. + case KeyCode::Key_Circumflex: + m_unit = Unit::Character; + m_amount = START_OF_NON_WHITESPACE; + m_is_complete = true; + break; + + // j, up or + operates on this line and amount line(s) after. + case KeyCode::Key_J: + case KeyCode::Key_Up: + case KeyCode::Key_Plus: + m_unit = Unit::Line; + + if (m_amount == 0) + m_amount = 1; + + m_is_complete = true; + break; + + // k, down or - operates on this line and amount line(s) before. + case KeyCode::Key_K: + case KeyCode::Key_Down: + case KeyCode::Key_Minus: + m_unit = Unit::Line; + + if (m_amount == 0) + m_amount = -1; + else + m_amount = -m_amount; + + m_is_complete = true; + break; + + // BS, h or left operates on this character and amount character(s) before. + case KeyCode::Key_Backspace: + case KeyCode::Key_H: + case KeyCode::Key_Left: + m_unit = Unit::Character; + + if (m_amount == 0) + m_amount = -1; + else + m_amount = -m_amount; + + m_is_complete = true; + break; + + // l or right operates on this character and amount character(s) after. + case KeyCode::Key_L: + case KeyCode::Key_Right: + m_unit = Unit::Character; + + if (m_amount > 0) + m_amount--; + + m_is_complete = true; + break; + + // w operates on amount word(s) after. + // W operates on amount WORD(s) after. + case KeyCode::Key_W: + if (shift) + m_unit = Unit::WORD; + else + m_unit = Unit::Word; + + if (m_amount == 0) + m_amount = 1; + + m_is_complete = true; + break; + + // b operates on amount word(s) before. + // B operates on amount WORD(s) before. + case KeyCode::Key_B: + if (shift) + m_unit = Unit::WORD; + else + m_unit = Unit::Word; + + if (m_amount == 0) + m_amount = -1; + else + m_amount = -m_amount; + + m_is_complete = true; + break; + + // e operates on amount of word(s) after, till the end of the last word. + // E operates on amount of WORD(s) after, till the end of the last WORD. + // ge operates on amount of word(s) before, till the end of the last word. + // gE operates on amount of WORD(s) before, till the end of the last WORD. + case KeyCode::Key_E: + if (shift) + m_unit = Unit::EndOfWORD; + else + m_unit = Unit::EndOfWord; + + if (m_guirky_mode) { + if (m_amount == 0) + m_amount = -1; + else + m_amount = -m_amount; + + m_guirky_mode = false; + } else { + if (m_amount == 0) + m_amount = 1; + } + + m_is_complete = true; + break; + + // g enables guirky (g-prefix commands) mode. + // gg operates from the start of the document to the cursor. + // G operates from the cursor to the end of the document. + case KeyCode::Key_G: + if (m_guirky_mode) { + if (shift) { + // gG is not a valid command in vim. + m_guirky_mode = false; + m_unit = Unit::Unknown; + m_is_complete = true; + } else { + m_guirky_mode = false; + m_unit = Unit::Document; + m_amount = -1; + m_is_complete = true; + } + } else { + if (shift) { + m_unit = Unit::Document; + m_amount = 1; + m_is_complete = true; + } else { + m_guirky_mode = true; + } + } + break; + + // t operates until the given character. + case KeyCode::Key_T: + m_find_mode = FindMode::To; + m_should_consume_next_character = true; + + if (m_amount == 0) + m_amount = 1; + break; + + // f operates through the given character. + case KeyCode::Key_F: + m_find_mode = FindMode::Find; + m_should_consume_next_character = true; + + if (m_amount == 0) + m_amount = 1; + break; + + default: + m_unit = Unit::Unknown; + m_is_complete = true; + break; + } + + if (should_use_guirky && m_guirky_mode) { + // If we didn't use the g then we cancel the motion. + m_guirky_mode = false; + m_unit = Unit::Unknown; + m_is_complete = true; + } +} + +Optional VimMotion::get_range(VimEditingEngine& engine, bool normalize_for_position) +{ + if (!is_complete() || is_cancelled()) + return {}; + + TextEditor& editor = engine.editor(); + + auto position = editor.cursor(); + int amount = abs(m_amount); + bool forwards = m_amount >= 0; + VimCursor cursor { editor, position, forwards }; + + m_start_line = m_end_line = position.line(); + m_start_column = m_end_column = position.column(); + + switch (m_unit) { + case Unit::Unknown: + VERIFY_NOT_REACHED(); + case Unit::Document: { + calculate_document_range(editor); + break; + } + case Unit::Line: { + calculate_line_range(editor, normalize_for_position); + break; + } + case Unit::EndOfWord: + case Unit::Word: + case Unit::EndOfWORD: + case Unit::WORD: { + calculate_word_range(cursor, amount, normalize_for_position); + break; + } + case Unit::Character: { + calculate_character_range(cursor, amount, normalize_for_position); + break; + } + case Unit::Find: { + calculate_find_range(cursor, amount); + break; + } + } + + return { TextRange { { m_start_line, m_start_column }, { m_end_line, m_end_column } } }; +} + +void VimMotion::calculate_document_range(TextEditor& editor) +{ + if (m_amount >= 0) { + m_end_line = editor.line_count() - 1; + auto& last_line = editor.line(m_end_line); + m_end_column = last_line.length(); + } else { + m_start_line = 0; + m_start_column = 0; + } +} + +void VimMotion::calculate_line_range(TextEditor& editor, bool normalize_for_position) +{ + // Use this line +/- m_amount lines. + m_start_column = 0; + m_end_column = 0; + + if (m_amount >= 0) { + m_end_line = min(m_end_line + !normalize_for_position + m_amount, editor.line_count()); + + // We can't delete to "last line + 1", so if we're on the last line, + // delete until the end. + if (m_end_line == editor.line_count()) { + m_end_line--; + m_end_column = editor.line(m_end_line).length(); + } + } else { + // Can't write it as max(start_line + m_amount, 0) because of unsigned + // shenanigans. + if (m_start_line <= (unsigned)-m_amount) + m_start_line = 0; + else + m_start_line += m_amount; + + if (m_end_line == editor.line_count() - 1) + m_end_column = editor.line(m_end_line).length(); + else + m_end_line++; + } +} + +void VimMotion::calculate_word_range(VimCursor& cursor, int amount, bool normalize_for_position) +{ + enum { + Whitespace, + Word, + Punctuation, + Unknown + }; + // Word is defined as a-zA-Z0-9_. + auto part_of_word = [](u32 ch) { return ch == '_' || isalnum(ch); }; + auto part_of_punctuation = [](u32 ch) { return ch != '_' && ispunct(ch); }; + auto classify = [&](u32 ch) { + if (isspace(ch)) + return Whitespace; + else if (part_of_word(ch)) + return Word; + else if (part_of_punctuation(ch)) + return Punctuation; + else + return Unknown; + }; + + // A small explanation for the code below: Because the direction of the + // movement for this motion determines what the "start" and "end" of a word + // is, the code below treats the motions like so: + // - Start of word: w/W/ge/gE + // - End of word: e/E/b/B + + while (amount > 0) { + if (cursor.hit_edge()) + break; + + if ((!cursor.forwards() && (m_unit == Unit::Word || m_unit == Unit::WORD)) + || (cursor.forwards() && (m_unit == Unit::EndOfWord || m_unit == Unit::EndOfWORD))) { + // End-of-word motions peek at the "next" character and if its class + // is not the same as ours, they move over one character (to end up + // at the new character class). This is required because we don't + // want to exit the word with end-of-word motions. + + if (m_unit == Unit::Word || m_unit == Unit::EndOfWord) { + // Word-style peeking + int current_class = classify(cursor.current_char()); + int peeked_class = classify(cursor.peek()); + if (current_class != peeked_class) { + cursor.move(); + } + } else { + // WORD-style peeking, much simpler + if (isspace(cursor.peek())) { + cursor.move(); + } + } + } else { + // Start-of-word motions want to exit the word no matter which part + // of it we're in. + if (m_unit == Unit::Word || m_unit == Unit::EndOfWord) { + // Word-style consumption + if (part_of_word(cursor.current_char())) { + do { + cursor.move(); + if (cursor.hit_edge() || cursor.crossed_line_boundary()) + break; + } while (part_of_word(cursor.current_char())); + } else if (part_of_punctuation(cursor.current_char())) { + do { + cursor.move(); + if (cursor.hit_edge() || cursor.crossed_line_boundary()) + break; + } while (part_of_punctuation(cursor.current_char())); + } else if (cursor.on_empty_line()) { + cursor.move(); + } + } else { + // WORD-style consumption + if (!isspace(cursor.current_char())) { + do { + cursor.move(); + if (cursor.hit_edge() || cursor.crossed_line_boundary()) + break; + } while (!isspace(cursor.current_char())); + } else if (cursor.on_empty_line()) { + cursor.move(); + } + } + } + + // Now consume any space if it exists. + if (isspace(cursor.current_char())) { + do { + cursor.move(); + if (cursor.hit_edge()) + break; + } while (isspace(cursor.current_char())); + } + + if ((!cursor.forwards() && (m_unit == Unit::Word || m_unit == Unit::WORD)) + || (cursor.forwards() && (m_unit == Unit::EndOfWord || m_unit == Unit::EndOfWORD))) { + // End-of-word motions consume until the class doesn't match. + + if (m_unit == Unit::Word || m_unit == Unit::EndOfWord) { + // Word-style consumption + int current_class = classify(cursor.current_char()); + while (classify(cursor.current_char()) == current_class) { + cursor.move(); + if (cursor.hit_edge() || cursor.crossed_line_boundary()) + break; + } + } else { + // WORD-style consumption + while (!isspace(cursor.current_char())) { + cursor.move(); + if (cursor.hit_edge() || cursor.crossed_line_boundary()) + break; + } + } + } + + amount--; + } + + // If we need to normalize for position then we do a move_reverse for + // end-of-word motions, because vim acts on end-of-word ranges through the + // character your cursor is placed on but acts on start-of-words *until* the + // character your cursor is placed on. + if (normalize_for_position) { + if ((!cursor.forwards() && (m_unit == Unit::Word || m_unit == Unit::WORD)) + || (cursor.forwards() && (m_unit == Unit::EndOfWord || m_unit == Unit::EndOfWORD))) { + if (!cursor.hit_edge()) + cursor.move_reverse(); + } + } + + if (cursor.forwards()) { + m_end_line = cursor.current_position().line(); + m_end_column = cursor.current_position().column() + normalize_for_position; + } else { + m_start_line = cursor.current_position().line(); + m_start_column = cursor.current_position().column(); + } +} + +void VimMotion::calculate_character_range(VimCursor& cursor, int amount, bool normalize_for_position) +{ + if (m_amount == START_OF_LINE) { + m_start_column = 0; + } else if (m_amount == END_OF_LINE) { + m_end_column = cursor.current_line().length(); + } else if (m_amount == START_OF_NON_WHITESPACE) { + // Find the first non-whitespace character and set the range from current + // position to it. + TextPosition cursor_copy = cursor.current_position(); + cursor.current_position().set_column(0); + + while (isspace(cursor.current_char())) { + if (cursor.will_cross_line_boundary()) + break; + + cursor.move_forwards(); + } + + if (cursor_copy < cursor.current_position()) + m_end_column = cursor.current_position().column() + 1; + else + m_start_column = cursor.current_position().column(); + } else { + while (amount > 0) { + if (cursor.hit_edge() || cursor.will_cross_line_boundary()) + break; + + cursor.move(); + amount--; + } + + if (cursor.forwards()) { + m_end_column = cursor.current_position().column() + 1 + normalize_for_position; + } else { + m_start_column = cursor.current_position().column(); + } + } +} + +void VimMotion::calculate_find_range(VimCursor& cursor, int amount) +{ + // Find the searched character (case-insensitive). + while (amount > 0) { + cursor.move_forwards(); + + while ((unsigned)tolower(cursor.current_char()) != m_next_character) { + if (cursor.will_cross_line_boundary()) + break; + + cursor.move_forwards(); + } + + amount--; + } + + // If we didn't find our character before reaching the end of the line, then + // we want the range to be invalid so no operation is performed. + if ((unsigned)tolower(cursor.current_char()) == m_next_character) { + // We found our character. + bool in_find_mode = m_find_mode == FindMode::Find; + m_end_column = cursor.current_position().column() + in_find_mode; + } + + m_find_mode = FindMode::None; +} + +Optional VimMotion::get_position(VimEditingEngine& engine) +{ + auto range_optional = get_range(engine, true); + if (!range_optional.has_value()) + return {}; + + auto range = range_optional.value(); + if (!range.is_valid()) + return {}; + + TextEditor& editor = engine.editor(); + auto cursor_position = editor.cursor(); + + switch (m_unit) { + case Unit::Document: { + if (range.start().line() < cursor_position.line()) { + cursor_position.set_line(range.start().line()); + } else { + cursor_position.set_line(range.end().line()); + } + cursor_position.set_column(0); + + return { cursor_position }; + } + case Unit::Line: { + size_t line_number; + // Because we select lines from start to end, we can't use that + // to get the new position, so we do some correction here. + if (range.start().line() < cursor_position.line() || m_amount < 0) { + line_number = range.start().line(); + } else { + line_number = range.end().line(); + } + + auto& line = editor.line(line_number); + + cursor_position.set_line(line_number); + if (line.length() <= cursor_position.column()) { + cursor_position.set_column(line.length() - 1); + } + + return { cursor_position }; + } + default: { + if (range.start() < cursor_position) { + return { range.start() }; + } else { + // Ranges are end-exclusive. The normalize_for_position argument we pass + // above in get_range normalizes some values which shouldn't be + // end-exclusive during normal operations. + bool is_at_start = range.end().column() == 0; + size_t column = is_at_start ? 0 : range.end().column() - 1; + // Need to not go beyond the last character, as standard in vim. + auto& line = editor.line(range.end().line()); + + return { TextPosition { range.end().line(), min(column, line.length() - 1) } }; + } + } + } +} + +void VimMotion::reset() +{ + m_unit = Unit::Unknown; + m_amount = 0; + m_is_complete = false; +} + CursorWidth VimEditingEngine::cursor_width() const { return m_vim_mode == VimMode::Insert ? CursorWidth::NARROW : CursorWidth::WIDE; @@ -37,6 +753,8 @@ bool VimEditingEngine::on_key(const KeyEvent& event) bool VimEditingEngine::on_key_in_insert_mode(const KeyEvent& event) { if (event.key() == KeyCode::Key_Escape || (event.ctrl() && event.key() == KeyCode::Key_LeftBracket) || (event.ctrl() && event.key() == KeyCode::Key_C)) { + if (m_editor->cursor().column() > 0) + move_one_left(event); switch_to_normal_mode(); return true; } @@ -45,57 +763,61 @@ bool VimEditingEngine::on_key_in_insert_mode(const KeyEvent& event) bool VimEditingEngine::on_key_in_normal_mode(const KeyEvent& event) { - // FIXME: changing or deleting word methods don't correctly support 1 letter words. - // For example, in the line 'return 0;' with the cursor on the '0', - // keys 'cw' will move to the delete '0;' rather than just the '0'. + // Ignore auxiliary keypress events. + if (event.key() == KeyCode::Key_LeftShift + || event.key() == KeyCode::Key_RightShift + || event.key() == KeyCode::Key_Control + || event.key() == KeyCode::Key_Alt) { + return false; + } - // FIXME: Changing or deleting the last word on a line will bring the next line - // up to the cursor. if (m_previous_key == KeyCode::Key_D) { - if (event.key() == KeyCode::Key_D) { + if (event.key() == KeyCode::Key_D && !m_motion.should_consume_next_character()) { yank(Line); delete_line(); - } else if (event.key() == KeyCode::Key_W) { - // If the current char is an alnum or punct, delete from said char, to the - // beginning of the next word including any whitespace in between the two words. - // If the current char is whitespace, delete from the cursor to the beginning of the next world. - u32 current_char = m_editor->current_line().view().code_points()[m_editor->cursor().column()]; - TextPosition delete_to; - if (isspace(current_char)) { - delete_to = find_beginning_of_next_word(); - } else { - delete_to = find_end_of_next_word(); - delete_to.set_column(delete_to.column() + 1); + m_motion.reset(); + m_previous_key = {}; + } else { + m_motion.add_key_code(event.key(), event.ctrl(), event.shift(), event.alt()); + if (m_motion.is_complete()) { + if (!m_motion.is_cancelled()) { + auto range = m_motion.get_range(*this); + VERIFY(range.has_value()); + + if (range->is_valid()) { + m_editor->delete_text_range(*range); + } + } + + m_motion.reset(); + m_previous_key = {}; } - m_editor->delete_text_range(TextRange(m_editor->cursor(), delete_to).normalized()); - } else if (event.key() == KeyCode::Key_B) { - // Will delete from the current char to the beginning of the previous word regardless of whitespace. - // Does not delete the starting char, see note below. - TextPosition delete_to = find_beginning_of_previous_word(); - // NOTE: Intentionally don't adjust m_editor->cursor() for the wide cursor's column - // because in the original vim... they don't. - m_editor->delete_text_range(TextRange(delete_to, m_editor->cursor()).normalized()); - } else if (event.key() == KeyCode::Key_E) { - // Delete from the current char to the end of the next word regardless of whitespace. - TextPosition delete_to = find_end_of_next_word(); - delete_to.set_column(delete_to.column() + 1); - m_editor->delete_text_range(TextRange(m_editor->cursor(), delete_to).normalized()); } - m_previous_key = {}; - } else if (m_previous_key == KeyCode::Key_G) { - if (event.key() == KeyCode::Key_G) { - move_to_first_line(); - } else if (event.key() == KeyCode::Key_E) { - move_to_end_of_previous_word(); - } - m_previous_key = {}; } else if (m_previous_key == KeyCode::Key_Y) { - if (event.key() == KeyCode::Key_Y) { + if (event.key() == KeyCode::Key_Y && !m_motion.should_consume_next_character()) { yank(Line); + m_motion.reset(); + m_previous_key = {}; + } else { + m_motion.add_key_code(event.key(), event.ctrl(), event.shift(), event.alt()); + if (m_motion.is_complete()) { + if (!m_motion.is_cancelled()) { + auto range = m_motion.get_range(*this); + VERIFY(range.has_value()); + + if (range->is_valid()) { + m_editor->set_selection(*range); + yank(Selection); + m_editor->clear_selection(); + } + } + + m_motion.reset(); + m_previous_key = {}; + } } - m_previous_key = {}; } else if (m_previous_key == KeyCode::Key_C) { - if (event.key() == KeyCode::Key_C) { + if (event.key() == KeyCode::Key_C && !m_motion.should_consume_next_character()) { // Needed because the code to replace the deleted line is called after delete_line() so // what was the second last line before the delete, is now the last line. bool was_second_last_line = m_editor->cursor().line() == m_editor->line_count() - 2; @@ -113,53 +835,50 @@ bool VimEditingEngine::on_key_in_normal_mode(const KeyEvent& event) m_editor->add_code_point(0x0A); } switch_to_insert_mode(); - } else if (event.key() == KeyCode::Key_W) { - // Delete to the end of the next word, if in the middle of a word, this will delete - // from the cursor to the said of said word. If the cursor is on whitespace, - // any whitespace between the cursor and the beginning of the next word will be deleted. - u32 current_char = m_editor->current_line().view().code_points()[m_editor->cursor().column()]; - TextPosition delete_to; - if (isspace(current_char)) { - delete_to = find_beginning_of_next_word(); - } else { - delete_to = find_end_of_next_word(); - delete_to.set_column(delete_to.column() + 1); + } else { + m_motion.add_key_code(event.key(), event.ctrl(), event.shift(), event.alt()); + if (m_motion.is_complete()) { + if (!m_motion.is_cancelled()) { + auto range = m_motion.get_range(*this); + VERIFY(range.has_value()); + + if (range->is_valid()) { + m_editor->set_selection(*range); + yank(Selection); + m_editor->delete_text_range(*range); + switch_to_insert_mode(); + } + } + + m_motion.reset(); + m_previous_key = {}; } - m_editor->delete_text_range(TextRange(m_editor->cursor(), delete_to).normalized()); - switch_to_insert_mode(); - } else if (event.key() == KeyCode::Key_B) { - // Delete to the beginning of the previous word, if in the middle of a word, this will delete - // from the cursor to the beginning of said word. If the cursor is on whitespace - // it, and the previous word will be deleted. - u32 current_char = m_editor->current_line().view().code_points()[m_editor->cursor().column()]; - TextPosition delete_to = find_beginning_of_previous_word(); - TextPosition adjusted_cursor = m_editor->cursor(); - // Adjust cursor for the column the wide cursor is covering - if (isalnum(current_char) || ispunct(current_char)) - adjusted_cursor.set_column(adjusted_cursor.column() + 1); - m_editor->delete_text_range(TextRange(delete_to, adjusted_cursor).normalized()); - switch_to_insert_mode(); - } else if (event.key() == KeyCode::Key_E) { - // Delete to the end of the next word, if in the middle of a word, this will delete - // from the cursor to the end of said word. If the cursor is on whitespace - // it, and the next word will be deleted. - TextPosition delete_to = find_end_of_next_word(); - TextPosition adjusted_cursor = m_editor->cursor(); - delete_to.set_column(delete_to.column() + 1); - m_editor->delete_text_range(TextRange(adjusted_cursor, delete_to).normalized()); - switch_to_insert_mode(); } - m_previous_key = {}; } else { + if (m_motion.should_consume_next_character()) { + // We must consume the next character. + // FIXME: deduplicate with code below. + m_motion.add_key_code(event.key(), event.ctrl(), event.shift(), event.alt()); + if (m_motion.is_complete()) { + if (!m_motion.is_cancelled()) { + auto maybe_new_position = m_motion.get_position(*this); + if (maybe_new_position.has_value()) { + auto new_position = maybe_new_position.value(); + m_editor->set_cursor(new_position); + } + } + + m_motion.reset(); + } + return true; + } + // Handle first any key codes that are to be applied regardless of modifiers. switch (event.key()) { - case (KeyCode::Key_Dollar): - move_to_line_end(event); - break; case (KeyCode::Key_Escape): if (m_editor->on_escape_pressed) m_editor->on_escape_pressed(); - break; + return true; default: break; } @@ -170,26 +889,24 @@ bool VimEditingEngine::on_key_in_normal_mode(const KeyEvent& event) case (KeyCode::Key_A): move_to_line_end(event); switch_to_insert_mode(); - break; - case (KeyCode::Key_G): - move_to_last_line(); - break; + return true; case (KeyCode::Key_I): move_to_line_beginning(event); switch_to_insert_mode(); - break; + return true; case (KeyCode::Key_O): move_to_line_beginning(event); m_editor->add_code_point(0x0A); move_one_up(event); switch_to_insert_mode(); - break; + return true; + // FIXME: Integrate these into vim motions too. case (KeyCode::Key_LeftBrace): move_to_previous_empty_lines_block(); - break; + return true; case (KeyCode::Key_RightBrace): move_to_next_empty_lines_block(); - break; + return true; default: break; } @@ -200,13 +917,13 @@ bool VimEditingEngine::on_key_in_normal_mode(const KeyEvent& event) switch (event.key()) { case (KeyCode::Key_D): move_half_page_down(event); - break; + return true; case (KeyCode::Key_R): m_editor->redo(); - break; + return true; case (KeyCode::Key_U): move_half_page_up(event); - break; + return true; default: break; } @@ -221,212 +938,172 @@ bool VimEditingEngine::on_key_in_normal_mode(const KeyEvent& event) case (KeyCode::Key_A): move_one_right(event); switch_to_insert_mode(); - break; - case (KeyCode::Key_B): - move_to_beginning_of_previous_word(); - break; + return true; case (KeyCode::Key_C): m_previous_key = event.key(); - break; - case (KeyCode::Key_Backspace): - case (KeyCode::Key_H): - case (KeyCode::Key_Left): - move_one_left(event); - break; + return true; case (KeyCode::Key_D): m_previous_key = event.key(); - break; - case (KeyCode::Key_E): - move_to_end_of_next_word(); - break; - case (KeyCode::Key_G): - m_previous_key = event.key(); - break; - case (KeyCode::Key_Down): - case (KeyCode::Key_J): - move_one_down(event); - break; + return true; case (KeyCode::Key_I): switch_to_insert_mode(); - break; - case (KeyCode::Key_K): - case (KeyCode::Key_Up): - move_one_up(event); - break; - case (KeyCode::Key_L): - case (KeyCode::Key_Right): - move_one_right(event); - break; + return true; case (KeyCode::Key_O): move_to_line_end(event); m_editor->add_code_point(0x0A); switch_to_insert_mode(); - break; + return true; case (KeyCode::Key_U): m_editor->undo(); - break; - case (KeyCode::Key_W): - move_to_beginning_of_next_word(); - break; + return true; case (KeyCode::Key_X): yank({ m_editor->cursor(), { m_editor->cursor().line(), m_editor->cursor().column() + 1 } }); delete_char(); - break; - case (KeyCode::Key_0): - move_to_line_beginning(event); - break; + return true; case (KeyCode::Key_V): switch_to_visual_mode(); - break; + return true; case (KeyCode::Key_Y): m_previous_key = event.key(); - break; + return true; case (KeyCode::Key_P): put(event); - break; + return true; default: break; } } + + // If nothing else handled the key, we'll be feeding the motion state + // machine instead. + m_motion.add_key_code(event.key(), event.ctrl(), event.shift(), event.alt()); + if (m_motion.is_complete()) { + if (!m_motion.is_cancelled()) { + auto maybe_new_position = m_motion.get_position(*this); + if (maybe_new_position.has_value()) { + auto new_position = maybe_new_position.value(); + m_editor->set_cursor(new_position); + } + } + + m_motion.reset(); + } } return true; } bool VimEditingEngine::on_key_in_visual_mode(const KeyEvent& event) { - if (m_previous_key == KeyCode::Key_G) { - if (event.key() == KeyCode::Key_G) { - move_to_first_line(); - update_selection_on_cursor_move(); - } else if (event.key() == KeyCode::Key_E) { - move_to_end_of_previous_word(); - update_selection_on_cursor_move(); + // If the motion state machine requires the next character, feed it. + if (m_motion.should_consume_next_character()) { + m_motion.add_key_code(event.key(), event.ctrl(), event.shift(), event.alt()); + if (m_motion.is_complete()) { + if (!m_motion.is_cancelled()) { + auto maybe_new_position = m_motion.get_position(*this); + if (maybe_new_position.has_value()) { + auto new_position = maybe_new_position.value(); + m_editor->set_cursor(new_position); + update_selection_on_cursor_move(); + } + } + + m_motion.reset(); } - m_previous_key = {}; - } else { - // Handle first any key codes that are to be applied regardless of modifiers. + + return true; + } + + // Handle first any key codes that are to be applied regardless of modifiers. + switch (event.key()) { + case (KeyCode::Key_Escape): + switch_to_normal_mode(); + if (m_editor->on_escape_pressed) + m_editor->on_escape_pressed(); + return true; + default: + break; + } + + // SHIFT is pressed. + if (event.shift() && !event.ctrl() && !event.alt()) { switch (event.key()) { - case (KeyCode::Key_Dollar): + case (KeyCode::Key_A): move_to_line_end(event); - update_selection_on_cursor_move(); - break; - case (KeyCode::Key_Escape): - switch_to_normal_mode(); - if (m_editor->on_escape_pressed) - m_editor->on_escape_pressed(); - break; + switch_to_insert_mode(); + return true; + case (KeyCode::Key_I): + move_to_line_beginning(event); + switch_to_insert_mode(); + return true; default: break; } + } - // SHIFT is pressed. - if (event.shift() && !event.ctrl() && !event.alt()) { - switch (event.key()) { - case (KeyCode::Key_A): - move_to_line_end(event); - switch_to_insert_mode(); - break; - case (KeyCode::Key_G): - move_to_last_line(); - break; - case (KeyCode::Key_I): - move_to_line_beginning(event); - switch_to_insert_mode(); - break; - default: - break; - } - } - - // CTRL is pressed. - if (event.ctrl() && !event.shift() && !event.alt()) { - switch (event.key()) { - case (KeyCode::Key_D): - move_half_page_down(event); - update_selection_on_cursor_move(); - break; - case (KeyCode::Key_U): - move_half_page_up(event); - update_selection_on_cursor_move(); - break; - default: - break; - } - } - - // No modifier is pressed. - if (!event.ctrl() && !event.shift() && !event.alt()) { - switch (event.key()) { - case (KeyCode::Key_B): - move_to_beginning_of_previous_word(); - update_selection_on_cursor_move(); - break; - case (KeyCode::Key_Backspace): - case (KeyCode::Key_H): - case (KeyCode::Key_Left): - move_one_left(event); - update_selection_on_cursor_move(); - break; - case (KeyCode::Key_D): - yank(Selection); - m_editor->do_delete(); - switch_to_normal_mode(); - break; - case (KeyCode::Key_E): - move_to_end_of_next_word(); - update_selection_on_cursor_move(); - break; - case (KeyCode::Key_G): - m_previous_key = event.key(); - break; - case (KeyCode::Key_Down): - case (KeyCode::Key_J): - move_one_down(event); - update_selection_on_cursor_move(); - break; - case (KeyCode::Key_K): - case (KeyCode::Key_Up): - move_one_up(event); - update_selection_on_cursor_move(); - break; - case (KeyCode::Key_L): - case (KeyCode::Key_Right): - move_one_right(event); - update_selection_on_cursor_move(); - break; - case (KeyCode::Key_U): - // FIXME: Set selection to uppercase. - break; - case (KeyCode::Key_W): - move_to_beginning_of_next_word(); - update_selection_on_cursor_move(); - break; - case (KeyCode::Key_X): - yank(Selection); - m_editor->do_delete(); - switch_to_normal_mode(); - break; - case (KeyCode::Key_0): - move_to_line_beginning(event); - update_selection_on_cursor_move(); - break; - case (KeyCode::Key_V): - switch_to_normal_mode(); - break; - case (KeyCode::Key_C): - yank(Selection); - m_editor->do_delete(); - switch_to_insert_mode(); - break; - case (KeyCode::Key_Y): - yank(Selection); - switch_to_normal_mode(); - break; - default: - break; - } + // CTRL is pressed. + if (event.ctrl() && !event.shift() && !event.alt()) { + switch (event.key()) { + case (KeyCode::Key_D): + move_half_page_down(event); + update_selection_on_cursor_move(); + return true; + case (KeyCode::Key_U): + move_half_page_up(event); + update_selection_on_cursor_move(); + return true; + default: + break; } } + + // No modifier is pressed. + if (!event.ctrl() && !event.shift() && !event.alt()) { + switch (event.key()) { + case (KeyCode::Key_D): + yank(Selection); + m_editor->do_delete(); + switch_to_normal_mode(); + return true; + case (KeyCode::Key_U): + // FIXME: Set selection to uppercase. + return true; + case (KeyCode::Key_X): + yank(Selection); + m_editor->do_delete(); + switch_to_normal_mode(); + return true; + case (KeyCode::Key_V): + switch_to_normal_mode(); + return true; + case (KeyCode::Key_C): + yank(Selection); + m_editor->do_delete(); + switch_to_insert_mode(); + return true; + case (KeyCode::Key_Y): + yank(Selection); + switch_to_normal_mode(); + return true; + default: + break; + } + } + + // By default, we feed the motion state machine. + m_motion.add_key_code(event.key(), event.ctrl(), event.shift(), event.alt()); + if (m_motion.is_complete()) { + if (!m_motion.is_cancelled()) { + auto maybe_new_position = m_motion.get_position(*this); + if (maybe_new_position.has_value()) { + auto new_position = maybe_new_position.value(); + m_editor->set_cursor(new_position); + update_selection_on_cursor_move(); + } + } + + m_motion.reset(); + } + return true; } @@ -436,6 +1113,7 @@ void VimEditingEngine::switch_to_normal_mode() m_editor->reset_cursor_blink(); m_previous_key = {}; clear_visual_mode_data(); + m_motion.reset(); }; void VimEditingEngine::switch_to_insert_mode() @@ -444,6 +1122,7 @@ void VimEditingEngine::switch_to_insert_mode() m_editor->reset_cursor_blink(); m_previous_key = {}; clear_visual_mode_data(); + m_motion.reset(); }; void VimEditingEngine::switch_to_visual_mode() @@ -451,18 +1130,17 @@ void VimEditingEngine::switch_to_visual_mode() m_vim_mode = VimMode::Visual; m_editor->reset_cursor_blink(); m_previous_key = {}; - m_selection_start_position = m_editor->cursor(); m_editor->selection()->set(m_editor->cursor(), { m_editor->cursor().line(), m_editor->cursor().column() + 1 }); m_editor->did_update_selection(); + m_motion.reset(); } void VimEditingEngine::update_selection_on_cursor_move() { auto cursor = m_editor->cursor(); - auto start = m_selection_start_position < cursor ? m_selection_start_position : cursor; - auto end = m_selection_start_position < cursor ? cursor : m_selection_start_position; - end.set_column(end.column() + 1); - m_editor->selection()->set(start, end); + auto& line = m_editor->current_line(); + cursor.set_column(min(cursor.column() + 1, line.length())); + m_editor->selection()->set_end(cursor); m_editor->did_update_selection(); } @@ -472,7 +1150,6 @@ void VimEditingEngine::clear_visual_mode_data() m_editor->selection()->clear(); m_editor->did_update_selection(); } - m_selection_start_position = {}; } void VimEditingEngine::move_half_page_up(const KeyEvent& event) diff --git a/Userland/Libraries/LibGUI/VimEditingEngine.h b/Userland/Libraries/LibGUI/VimEditingEngine.h index 7c57e9ebb54..12133599068 100644 --- a/Userland/Libraries/LibGUI/VimEditingEngine.h +++ b/Userland/Libraries/LibGUI/VimEditingEngine.h @@ -6,10 +6,140 @@ #pragma once +#include +#include #include +#include namespace GUI { +// Wrapper over TextPosition that makes it easier to move it around as a cursor, +// and to get the current line or character. +class VimCursor { +public: + VimCursor(TextEditor& editor, TextPosition initial_position, bool forwards) + : m_editor(editor) + , m_position(initial_position) + , m_forwards(forwards) + { + } + + void move_forwards(); + void move_backwards(); + + // Move a single character in the current direction. + void move(); + // Move a single character in reverse. + void move_reverse(); + // Peek a single character in the current direction. + u32 peek(); + // Peek a single character in reverse. + u32 peek_reverse(); + // Get the character the cursor is currently on. + u32 current_char(); + // Get the line the cursor is currently on. + TextDocumentLine& current_line(); + // Get the current position. + TextPosition& current_position() { return m_position; } + + // Did we hit the edge of the document? + bool hit_edge() { return m_hit_edge; } + // Will the next move cross a line boundary? + bool will_cross_line_boundary(); + // Did we cross a line boundary? + bool crossed_line_boundary() { return m_crossed_line_boundary; } + // Are we on an empty line? + bool on_empty_line(); + // Are we going forwards? + bool forwards() { return m_forwards; } + +private: + TextEditor& m_editor; + TextPosition m_position; + bool m_forwards; + + u32 m_cached_char { 0 }; + + bool m_hit_edge { false }; + bool m_crossed_line_boundary { false }; +}; + +class VimMotion { +public: + enum class Unit { + // The motion isn't complete yet, or was invalid. + Unknown, + // Document. Anything non-negative is counted as G while anything else is gg. + Document, + // Lines. + Line, + // A sequence of letters, digits and underscores, or a sequence of other + // non-blank characters separated by whitespace. + Word, + // A sequence of non-blank characters separated by whitespace. + // This is how Vim separates w from W. + WORD, + // End of a word. This is basically the same as a word but it doesn't + // trim the spaces at the end. + EndOfWord, + // End of a WORD. + EndOfWORD, + // Characters (or Unicode codepoints based on how pedantic you want to + // get). + Character, + // Used for find-mode. + Find + }; + enum class FindMode { + /// Find mode is not enabled. + None, + /// Finding until the given character. + To, + /// Finding through the given character. + Find + }; + + void add_key_code(KeyCode key, bool ctrl, bool shift, bool alt); + Optional get_range(class VimEditingEngine& engine, bool normalize_for_position = false); + Optional get_position(VimEditingEngine& engine); + void reset(); + + /// Returns whether the motion should consume the next character no matter what. + /// Used for f and t motions. + bool should_consume_next_character() { return m_should_consume_next_character; } + bool is_complete() { return m_is_complete; } + bool is_cancelled() { return m_is_complete && m_unit == Unit::Unknown; } + Unit unit() { return m_unit; } + int amount() { return m_amount; } + + // FIXME: come up with a better way to signal start/end of line than sentinels? + static constexpr int START_OF_LINE = NumericLimits::min(); + static constexpr int START_OF_NON_WHITESPACE = NumericLimits::min() + 1; + static constexpr int END_OF_LINE = NumericLimits::max(); + +private: + void calculate_document_range(TextEditor&); + void calculate_line_range(TextEditor&, bool normalize_for_position); + void calculate_word_range(VimCursor&, int amount, bool normalize_for_position); + void calculate_WORD_range(VimCursor&, int amount, bool normalize_for_position); + void calculate_character_range(VimCursor&, int amount, bool normalize_for_position); + void calculate_find_range(VimCursor&, int amount); + + Unit m_unit { Unit::Unknown }; + int m_amount { 0 }; + bool m_is_complete { false }; + bool m_guirky_mode { false }; + bool m_should_consume_next_character { false }; + + FindMode m_find_mode { FindMode::None }; + u32 m_next_character { 0 }; + + size_t m_start_line { 0 }; + size_t m_start_column { 0 }; + size_t m_end_line { 0 }; + size_t m_end_column { 0 }; +}; + class VimEditingEngine final : public EditingEngine { public: @@ -30,6 +160,7 @@ private: }; VimMode m_vim_mode { VimMode::Normal }; + VimMotion m_motion; YankType m_yank_type {}; String m_yank_buffer {}; @@ -37,7 +168,6 @@ private: void yank(TextRange); void put(const GUI::KeyEvent&); - TextPosition m_selection_start_position {}; void update_selection_on_cursor_move(); void clear_visual_mode_data();