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: