From 23d733be955e4e7c67f58a4b34dd791edc8a419e Mon Sep 17 00:00:00 2001 From: Ivan Fefer Date: Wed, 13 Mar 2024 21:43:08 +0100 Subject: [PATCH] Add: Basic autocompletion on tab for console commands (#12163) --- src/CMakeLists.txt | 2 + src/autocompletion.cpp | 66 +++++++++++++ src/autocompletion.h | 46 +++++++++ src/console_gui.cpp | 56 ++++++++++- src/network/network_chat_gui.cpp | 159 ++++++++----------------------- src/string.cpp | 37 +++---- src/string_func.h | 1 + src/tests/string_func.cpp | 24 +++++ src/video/cocoa/cocoa_wnd.mm | 7 +- 9 files changed, 250 insertions(+), 148 deletions(-) create mode 100644 src/autocompletion.cpp create mode 100644 src/autocompletion.h diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index ebb946ae4e..a55346167a 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -52,6 +52,8 @@ add_files( animated_tile_func.h articulated_vehicles.cpp articulated_vehicles.h + autocompletion.cpp + autocompletion.h autoreplace.cpp autoreplace_base.h autoreplace_cmd.cpp diff --git a/src/autocompletion.cpp b/src/autocompletion.cpp new file mode 100644 index 0000000000..e88150c752 --- /dev/null +++ b/src/autocompletion.cpp @@ -0,0 +1,66 @@ +/* + * This file is part of OpenTTD. + * OpenTTD is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 2. + * OpenTTD is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with OpenTTD. If not, see . + */ + +/** @file autocompletion.cpp Generic auto-completion engine. */ + +#include "stdafx.h" + +#include "autocompletion.h" + +#include "console_internal.h" +#include "town.h" +#include "network/network_base.h" + +#include "safeguards.h" + +bool AutoCompletion::AutoComplete() +{ + // We are pressing TAB for the first time after reset. + if (this->suggestions.empty()) { + this->InitSuggestions(this->textbuf->buf); + if (this->suggestions.empty()) { + return false; + } + this->ApplySuggestion(prefix, suggestions[0]); + return true; + } + + // We are pressing TAB again on the same text. + if (this->current_suggestion_index + 1 < this->suggestions.size()) { + this->ApplySuggestion(prefix, this->suggestions[++this->current_suggestion_index]); + } else { + // We are out of options, restore original text. + this->textbuf->Assign(initial_buf); + this->Reset(); + } + return true; +} + +void AutoCompletion::Reset() +{ + this->prefix = ""; + this->query = ""; + this->initial_buf.clear(); + this->suggestions.clear(); + this->current_suggestion_index = 0; +} + +void AutoCompletion::InitSuggestions(std::string_view text) +{ + this->initial_buf = text; + size_t space_pos = this->initial_buf.find_last_of(' '); + this->query = this->initial_buf; + if (space_pos == std::string::npos) { + this->prefix = ""; + } else { + this->prefix = this->query.substr(0, space_pos + 1); + this->query.remove_prefix(space_pos + 1); + } + + this->suggestions = this->GetSuggestions(prefix, query); + this->current_suggestion_index = 0; +} diff --git a/src/autocompletion.h b/src/autocompletion.h new file mode 100644 index 0000000000..452c23e87a --- /dev/null +++ b/src/autocompletion.h @@ -0,0 +1,46 @@ +/* + * This file is part of OpenTTD. + * OpenTTD is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 2. + * OpenTTD is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with OpenTTD. If not, see . + */ + +/** @file autocompletion.h Generic auto-completion engine. */ + +#ifndef AUTOCOMPLETION_H +#define AUTOCOMPLETION_H + +#include "textbuf_type.h" + +class AutoCompletion { +protected: + Textbuf *textbuf; + +private: + std::string initial_buf; ///< Value of text buffer when we started current suggestion session. + + std::string_view prefix; ///< Prefix of the text before the last space. + std::string_view query; ///< Last token of the text. This is used to based the suggestions on. + + std::vector suggestions; + size_t current_suggestion_index; + +public: + AutoCompletion(Textbuf *textbuf) : textbuf(textbuf) + { + this->Reset(); + } + virtual ~AutoCompletion() = default; + + // Returns true the textbuf was updated. + bool AutoComplete(); + void Reset(); + +private: + void InitSuggestions(std::string_view text); + + virtual std::vector GetSuggestions(std::string_view prefix, std::string_view query) = 0; + virtual void ApplySuggestion(std::string_view prefix, std::string_view suggestion) = 0; +}; + +#endif /* AUTOCOMPLETION_H */ diff --git a/src/console_gui.cpp b/src/console_gui.cpp index 2fd8b221ef..6dc0393e66 100644 --- a/src/console_gui.cpp +++ b/src/console_gui.cpp @@ -10,6 +10,7 @@ #include "stdafx.h" #include "textbuf_type.h" #include "window_gui.h" +#include "autocompletion.h" #include "console_gui.h" #include "console_internal.h" #include "window_func.h" @@ -68,9 +69,44 @@ static std::deque _iconsole_buffer; static bool TruncateBuffer(); +class ConsoleAutoCompletion final : public AutoCompletion { +public: + using AutoCompletion::AutoCompletion; + +private: + std::vector GetSuggestions(std::string_view prefix, std::string_view query) override + { + prefix = StrTrimView(prefix); + std::vector suggestions; + + /* We only suggest commands or aliases, so we only do it for the first token or an argument to help command. */ + if (!prefix.empty() && prefix != "help") { + return suggestions; + } + + for (const auto &[_, command] : IConsole::Commands()) { + if (command.name.starts_with(query)) { + suggestions.push_back(command.name); + } + } + for (const auto &[_, alias] : IConsole::Aliases()) { + if (alias.name.starts_with(query)) { + suggestions.push_back(alias.name); + } + } + + return suggestions; + } + + void ApplySuggestion(std::string_view prefix, std::string_view suggestion) override + { + this->textbuf->Assign(fmt::format("{}{} ", prefix, suggestion)); + } +}; /* ** main console cmd buffer ** */ static Textbuf _iconsole_cmdline(ICON_CMDLN_SIZE); +static ConsoleAutoCompletion _iconsole_tab_completion(&_iconsole_cmdline); static std::deque _iconsole_history; static ptrdiff_t _iconsole_historypos; IConsoleModes _iconsole_mode; @@ -86,6 +122,7 @@ static void IConsoleClearCommand() _iconsole_cmdline.pixels = 0; _iconsole_cmdline.caretpos = 0; _iconsole_cmdline.caretxoffs = 0; + _iconsole_tab_completion.Reset(); SetWindowDirty(WC_CONSOLE, 0); } @@ -258,8 +295,18 @@ struct IConsoleWindow : Window IConsoleCmdExec("clear"); break; - default: - if (_iconsole_cmdline.HandleKeyPress(key, keycode) != HKPR_NOT_HANDLED) { + case WKC_TAB: + if (_iconsole_tab_completion.AutoComplete()) { + this->SetDirty(); + } + break; + + default: { + HandleKeyPressResult handle_result = _iconsole_cmdline.HandleKeyPress(key, keycode); + if (handle_result != HKPR_NOT_HANDLED) { + if (handle_result == HKPR_EDITING) { + _iconsole_tab_completion.Reset(); + } IConsoleWindow::scroll = 0; IConsoleResetHistoryPos(); this->SetDirty(); @@ -267,6 +314,7 @@ struct IConsoleWindow : Window return ES_NOT_HANDLED; } break; + } } return ES_HANDLED; } @@ -274,13 +322,14 @@ struct IConsoleWindow : Window void InsertTextString(WidgetID, const char *str, bool marked, const char *caret, const char *insert_location, const char *replacement_end) override { if (_iconsole_cmdline.InsertString(str, marked, caret, insert_location, replacement_end)) { + _iconsole_tab_completion.Reset(); IConsoleWindow::scroll = 0; IConsoleResetHistoryPos(); this->SetDirty(); } } - Textbuf *GetFocusedTextbuf() const override + const Textbuf *GetFocusedTextbuf() const override { return &_iconsole_cmdline; } @@ -434,6 +483,7 @@ static void IConsoleHistoryNavigate(int direction) } else { _iconsole_cmdline.Assign(_iconsole_history[_iconsole_historypos]); } + _iconsole_tab_completion.Reset(); } /** diff --git a/src/network/network_chat_gui.cpp b/src/network/network_chat_gui.cpp index 1b83f67f76..be61d5cbf8 100644 --- a/src/network/network_chat_gui.cpp +++ b/src/network/network_chat_gui.cpp @@ -9,6 +9,7 @@ #include "../stdafx.h" #include "../strings_func.h" +#include "../autocompletion.h" #include "../blitter/factory.hpp" #include "../console_func.h" #include "../video/video_driver.hpp" @@ -44,7 +45,6 @@ struct ChatMessage { static std::deque _chatmsg_list; ///< The actual chat message list. static bool _chatmessage_dirty = false; ///< Does the chat message need repainting? static bool _chatmessage_visible = false; ///< Is a chat message visible. -static bool _chat_tab_completion_active; ///< Whether tab completion is active. static uint MAX_CHAT_MESSAGES = 0; ///< The limit of chat messages to show. /** @@ -262,11 +262,47 @@ static void SendChat(const std::string &buf, DestType type, int dest) } } +class NetworkChatAutoCompletion final : public AutoCompletion { +public: + using AutoCompletion::AutoCompletion; + +private: + std::vector GetSuggestions([[maybe_unused]] std::string_view prefix, std::string_view query) override + { + std::vector suggestions; + for (NetworkClientInfo *ci : NetworkClientInfo::Iterate()) { + if (ci->client_name.starts_with(query)) { + suggestions.push_back(ci->client_name); + } + } + for (const Town *t : Town::Iterate()) { + /* Get the town-name via the string-system */ + SetDParam(0, t->index); + std::string town_name = GetString(STR_TOWN_NAME); + if (town_name.starts_with(query)) { + suggestions.push_back(std::move(town_name)); + } + } + return suggestions; + } + + void ApplySuggestion(std::string_view prefix, std::string_view suggestion) override + { + /* Add ': ' if we are at the start of the line (pretty) */ + if (prefix.empty()) { + this->textbuf->Assign(fmt::format("{}: ", suggestion)); + } else { + this->textbuf->Assign(fmt::format("{}{} ", prefix, suggestion)); + } + } +}; + /** Window to enter the chat message in. */ struct NetworkChatWindow : public Window { DestType dtype; ///< The type of destination. int dest; ///< The identifier of the destination. QueryString message_editbox; ///< Message editbox. + NetworkChatAutoCompletion chat_tab_completion; ///< Holds the state and logic of auto-completion of player names and towns on Tab press. /** * Create a chat input window. @@ -274,7 +310,8 @@ struct NetworkChatWindow : public Window { * @param type The type of destination. * @param dest The actual destination index. */ - NetworkChatWindow(WindowDesc *desc, DestType type, int dest) : Window(desc), message_editbox(NETWORK_CHAT_LENGTH) + NetworkChatWindow(WindowDesc *desc, DestType type, int dest) + : Window(desc), message_editbox(NETWORK_CHAT_LENGTH), chat_tab_completion(&message_editbox.text) { this->dtype = type; this->dest = dest; @@ -295,7 +332,6 @@ struct NetworkChatWindow : public Window { this->SetFocusedWidget(WID_NC_TEXTBOX); InvalidateWindowData(WC_NEWS_WINDOW, 0, this->height); - _chat_tab_completion_active = false; PositionNetworkChatWindow(this); } @@ -311,125 +347,12 @@ struct NetworkChatWindow : public Window { Window::FindWindowPlacementAndResize(_toolbar_width, def_height); } - /** - * Find the next item of the list of things that can be auto-completed. - * @param item The current indexed item to return. This function can, and most - * likely will, alter item, to skip empty items in the arrays. - * @return Returns the view that matched to the index. - */ - std::optional ChatTabCompletionNextItem(uint *item) - { - /* First, try clients */ - if (*item < MAX_CLIENT_SLOTS) { - /* Skip inactive clients */ - for (NetworkClientInfo *ci : NetworkClientInfo::Iterate(*item)) { - *item = ci->index; - return ci->client_name; - } - *item = MAX_CLIENT_SLOTS; - } - - /* Then, try townnames - * Not that the following assumes all town indices are adjacent, ie no - * towns have been deleted. */ - if (*item < (uint)MAX_CLIENT_SLOTS + Town::GetPoolSize()) { - for (const Town *t : Town::Iterate(*item - MAX_CLIENT_SLOTS)) { - /* Get the town-name via the string-system */ - SetDParam(0, t->index); - return GetString(STR_TOWN_NAME); - } - } - - return std::nullopt; - } - - /** - * Find what text to complete. It scans for a space from the left and marks - * the word right from that as to complete. It also writes a \0 at the - * position of the space (if any). If nothing found, buf is returned. - */ - static std::string_view ChatTabCompletionFindText(std::string_view &buf) - { - auto it = buf.find_last_of(' '); - if (it == std::string_view::npos) return buf; - - std::string_view res = buf.substr(it + 1); - buf.remove_suffix(res.size() + 1); - return res; - } - /** * See if we can auto-complete the current text of the user. */ void ChatTabCompletion() { - static std::string _chat_tab_completion_buf; - - Textbuf *tb = &this->message_editbox.text; - uint item = 0; - bool second_scan = false; - - /* Create views, so we do not need to copy the data for now. */ - std::string_view pre_buf = _chat_tab_completion_active ? std::string_view(_chat_tab_completion_buf) : std::string_view(tb->buf); - std::string_view tb_buf = ChatTabCompletionFindText(pre_buf); - - /* - * Comparing pointers of the data, as both "Hi:" and "Hi: Hi:" will result in - * tb_buf and pre_buf being "Hi:", which would be equal in content but not in context. - */ - bool begin_of_line = tb_buf.data() == pre_buf.data(); - - std::optional cur_item; - while ((cur_item = ChatTabCompletionNextItem(&item)).has_value()) { - std::string_view cur_name = cur_item.value(); - item++; - - if (_chat_tab_completion_active) { - /* We are pressing TAB again on the same name, is there another name - * that starts with this? */ - if (!second_scan) { - std::string_view view; - - /* If we are completing at the begin of the line, skip the ': ' we added */ - if (begin_of_line) { - view = std::string_view(tb->buf, (tb->bytes - 1) - 2); - } else { - /* Else, find the place we are completing at */ - size_t offset = pre_buf.size() + 1; - view = std::string_view(tb->buf + offset, (tb->bytes - 1) - offset); - } - - /* Compare if we have a match */ - if (cur_name == view) second_scan = true; - - continue; - } - - /* Now any match we make on _chat_tab_completion_buf after this, is perfect */ - } - - if (tb_buf.size() < cur_name.size() && cur_name.starts_with(tb_buf)) { - /* Save the data it was before completion */ - if (!second_scan) _chat_tab_completion_buf = tb->buf; - _chat_tab_completion_active = true; - - /* Change to the found name. Add ': ' if we are at the start of the line (pretty) */ - if (begin_of_line) { - this->message_editbox.text.Assign(fmt::format("{}: ", cur_name)); - } else { - this->message_editbox.text.Assign(fmt::format("{} {}", pre_buf, cur_name)); - } - - this->SetDirty(); - return; - } - } - - if (second_scan) { - /* We walked all possibilities, and the user presses tab again.. revert to original text */ - this->message_editbox.text.Assign(_chat_tab_completion_buf); - _chat_tab_completion_active = false; - + if (this->chat_tab_completion.AutoComplete()) { this->SetDirty(); } } @@ -475,7 +398,7 @@ struct NetworkChatWindow : public Window { void OnEditboxChanged(WidgetID widget) override { if (widget == WID_NC_TEXTBOX) { - _chat_tab_completion_active = false; + this->chat_tab_completion.Reset(); } } diff --git a/src/string.cpp b/src/string.cpp index f695194906..394a24fab0 100644 --- a/src/string.cpp +++ b/src/string.cpp @@ -254,30 +254,6 @@ bool StrValid(const char *str, const char *last) return *str == '\0'; } -/** - * Trim the spaces from the begin of given string in place, i.e. the string buffer - * that is passed will be modified whenever spaces exist in the given string. - * When there are spaces at the begin, the whole string is moved forward. - * @param str The string to perform the in place left trimming on. - */ -static void StrLeftTrimInPlace(std::string &str) -{ - size_t pos = str.find_first_not_of(' '); - str.erase(0, pos); -} - -/** - * Trim the spaces from the end of given string in place, i.e. the string buffer - * that is passed will be modified whenever spaces exist in the given string. - * When there are spaces at the end, the '\0' will be moved forward. - * @param str The string to perform the in place left trimming on. - */ -static void StrRightTrimInPlace(std::string &str) -{ - size_t pos = str.find_last_not_of(' '); - if (pos != std::string::npos) str.erase(pos + 1); -} - /** * Trim the spaces from given string in place, i.e. the string buffer that * is passed will be modified whenever spaces exist in the given string. @@ -287,8 +263,17 @@ static void StrRightTrimInPlace(std::string &str) */ void StrTrimInPlace(std::string &str) { - StrLeftTrimInPlace(str); - StrRightTrimInPlace(str); + str = StrTrimView(str); +} + +std::string_view StrTrimView(std::string_view str) +{ + size_t first_pos = str.find_first_not_of(' '); + if (first_pos == std::string::npos) { + return std::string_view{}; + } + size_t last_pos = str.find_last_not_of(' '); + return str.substr(first_pos, last_pos - first_pos + 1); } /** diff --git a/src/string_func.h b/src/string_func.h index df487f12d2..d7ec094a64 100644 --- a/src/string_func.h +++ b/src/string_func.h @@ -29,6 +29,7 @@ bool strtolower(std::string &str, std::string::size_type offs = 0); [[nodiscard]] bool StrValid(const char *str, const char *last) NOACCESS(2); void StrTrimInPlace(std::string &str); +std::string_view StrTrimView(std::string_view str); [[nodiscard]] bool StrStartsWithIgnoreCase(std::string_view str, const std::string_view prefix); [[nodiscard]] bool StrEndsWithIgnoreCase(std::string_view str, const std::string_view suffix); diff --git a/src/tests/string_func.cpp b/src/tests/string_func.cpp index ca238db46f..cc5b624250 100644 --- a/src/tests/string_func.cpp +++ b/src/tests/string_func.cpp @@ -384,3 +384,27 @@ TEST_CASE("ConvertHexToBytes") CHECK(bytes3[6] == 0xde); CHECK(bytes3[7] == 0xf0); } + +static const std::vector> _str_trim_testcases = { + {"a", "a"}, + {" a", "a"}, + {"a ", "a"}, + {" a ", "a"}, + {" a b c ", "a b c"}, + {" ", ""} +}; + +TEST_CASE("StrTrimInPlace") +{ + for (auto [input, expected] : _str_trim_testcases) { + StrTrimInPlace(input); + CHECK(input == expected); + } +} + +TEST_CASE("StrTrimView") { + for (const auto& [input, expected] : _str_trim_testcases) { + CHECK(StrTrimView(input) == expected); + } +} + diff --git a/src/video/cocoa/cocoa_wnd.mm b/src/video/cocoa/cocoa_wnd.mm index f6957ff6ef..a36ef78dea 100644 --- a/src/video/cocoa/cocoa_wnd.mm +++ b/src/video/cocoa/cocoa_wnd.mm @@ -789,7 +789,12 @@ void CocoaDialog(const char *title, const char *message, const char *buttonLabel case QZ_LEFT: SB(_dirkeys, 0, 1, down); break; case QZ_RIGHT: SB(_dirkeys, 2, 1, down); break; - case QZ_TAB: _tab_is_down = down; break; + case QZ_TAB: + _tab_is_down = down; + if (down && EditBoxInGlobalFocus()) { + HandleKeypress(WKC_TAB, unicode); + } + break; case QZ_RETURN: case QZ_f: