feat: add Tab autocomplete for slash commands in chat input

Pressing Tab while typing a slash command cycles through all matching
commands (e.g. /em<Tab> → /emote, /emote<Tab> → /emote again).
Unambiguous matches append a trailing space. Repeated Tab presses
cycle forward through all matches. History navigation (Up/Down)
resets the autocomplete session.
This commit is contained in:
Kelsi 2026-03-12 06:38:10 -07:00
parent 68251b647d
commit c14b338a92
2 changed files with 69 additions and 1 deletions

View file

@ -54,6 +54,11 @@ private:
std::vector<std::string> chatSentHistory_;
int chatHistoryIdx_ = -1; // -1 = not browsing history
// Tab-completion state for slash commands
std::string chatTabPrefix_; // prefix captured on first Tab press
std::vector<std::string> chatTabMatches_; // matching command list
int chatTabMatchIdx_ = -1; // active match index (-1 = inactive)
// Chat tabs
int activeChatTab_ = 0;
struct ChatTab {

View file

@ -1770,8 +1770,70 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) {
self->chatInputMoveCursorToEnd = false;
}
// Tab: slash-command autocomplete
if (data->EventFlag == ImGuiInputTextFlags_CallbackCompletion) {
if (data->BufTextLen > 0 && data->Buf[0] == '/') {
// Split buffer into command word and trailing args
std::string fullBuf(data->Buf, data->BufTextLen);
size_t spacePos = fullBuf.find(' ');
std::string word = (spacePos != std::string::npos) ? fullBuf.substr(0, spacePos) : fullBuf;
std::string rest = (spacePos != std::string::npos) ? fullBuf.substr(spacePos) : "";
// Normalize to lowercase for matching
std::string lowerWord = word;
for (auto& ch : lowerWord) ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
static const std::vector<std::string> kCmds = {
"/afk", "/away", "/cast", "/chathelp", "/clear",
"/dance", "/do", "/dnd", "/e", "/emote",
"/follow", "/g", "/guild", "/guildinfo",
"/gmticket", "/grouploot", "/i", "/instance",
"/invite", "/j", "/join", "/kick",
"/l", "/leave", "/local", "/me",
"/p", "/party", "/r", "/raid",
"/raidwarning", "/random", "/reply", "/roll",
"/s", "/say", "/setloot", "/shout",
"/stopattack", "/stopfollow", "/t", "/time",
"/trade", "/uninvite", "/w", "/whisper",
"/who", "/wts", "/wtb", "/y", "/yell", "/zone"
};
// New session if prefix changed
if (self->chatTabMatchIdx_ < 0 || self->chatTabPrefix_ != lowerWord) {
self->chatTabPrefix_ = lowerWord;
self->chatTabMatches_.clear();
for (const auto& cmd : kCmds) {
if (cmd.size() >= lowerWord.size() &&
cmd.compare(0, lowerWord.size(), lowerWord) == 0)
self->chatTabMatches_.push_back(cmd);
}
self->chatTabMatchIdx_ = 0;
} else {
// Cycle forward through matches
++self->chatTabMatchIdx_;
if (self->chatTabMatchIdx_ >= static_cast<int>(self->chatTabMatches_.size()))
self->chatTabMatchIdx_ = 0;
}
if (!self->chatTabMatches_.empty()) {
std::string match = self->chatTabMatches_[self->chatTabMatchIdx_];
// Append trailing space when match is unambiguous
if (self->chatTabMatches_.size() == 1 && rest.empty())
match += ' ';
std::string newBuf = match + rest;
data->DeleteChars(0, data->BufTextLen);
data->InsertChars(0, newBuf.c_str());
}
}
return 0;
}
// Up/Down arrow: cycle through sent message history
if (data->EventFlag == ImGuiInputTextFlags_CallbackHistory) {
// Any history navigation resets autocomplete
self->chatTabMatchIdx_ = -1;
self->chatTabMatches_.clear();
const int histSize = static_cast<int>(self->chatSentHistory_.size());
if (histSize == 0) return 0;
@ -1802,7 +1864,8 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) {
ImGuiInputTextFlags inputFlags = ImGuiInputTextFlags_EnterReturnsTrue |
ImGuiInputTextFlags_CallbackAlways |
ImGuiInputTextFlags_CallbackHistory;
ImGuiInputTextFlags_CallbackHistory |
ImGuiInputTextFlags_CallbackCompletion;
if (ImGui::InputText("##ChatInput", chatInputBuffer, sizeof(chatInputBuffer), inputFlags, inputCallback, this)) {
sendChatMessage(gameHandler);
// Close chat input on send so movement keys work immediately.