diff --git a/Source/CMakeLists.txt b/Source/CMakeLists.txt index a6c378fcf1c..181a6459b02 100644 --- a/Source/CMakeLists.txt +++ b/Source/CMakeLists.txt @@ -132,6 +132,7 @@ set(libdevilutionx_SRCS levels/town.cpp levels/trigs.cpp + lua/autocomplete.cpp lua/lua.cpp lua/repl.cpp lua/modules/audio.cpp @@ -295,7 +296,7 @@ if(DISCORD_INTEGRATION) target_link_libraries(libdevilutionx PRIVATE discord discord_game_sdk) endif() -target_link_libraries(libdevilutionx PRIVATE ${LUA_LIBRARIES} sol2::sol2) +target_link_libraries(libdevilutionx PUBLIC ${LUA_LIBRARIES} sol2::sol2) if(SCREEN_READER_INTEGRATION AND WIN32) target_compile_definitions(libdevilutionx PRIVATE Tolk) diff --git a/Source/engine/render/text_render.cpp b/Source/engine/render/text_render.cpp index db364a72998..28519ddd0cd 100644 --- a/Source/engine/render/text_render.cpp +++ b/Source/engine/render/text_render.cpp @@ -401,7 +401,7 @@ int GetLineStartX(UiFlags flags, const Rectangle &rect, int lineWidth) uint32_t DoDrawString(const Surface &out, std::string_view text, Rectangle rect, Point &characterPosition, int lineWidth, int rightMargin, int bottomMargin, GameFontTables size, text_color color, bool outline, - const TextRenderOptions &opts) + TextRenderOptions &opts) { CurrentFont currentFont; @@ -410,12 +410,17 @@ uint32_t DoDrawString(const Surface &out, std::string_view text, Rectangle rect, size_t cpLen; const auto maybeDrawCursor = [&]() { - if (opts.cursorPosition == static_cast(text.size() - remaining.size()) && GetAnimationFrame(2, 500) != 0) { + if (opts.cursorPosition == static_cast(text.size() - remaining.size())) { Point position = characterPosition; MaybeWrap(position, 2, rightMargin, position.x, opts.lineHeight); - OptionalClxSpriteList baseFont = LoadFont(size, color, 0); - if (baseFont) - DrawFont(out, position, (*baseFont)['|'], color, outline); + if (GetAnimationFrame(2, 500) != 0) { + OptionalClxSpriteList baseFont = LoadFont(size, color, 0); + if (baseFont) + DrawFont(out, position, (*baseFont)['|'], color, outline); + } + if (opts.renderedCursorPositionOut != nullptr) { + *opts.renderedCursorPositionOut = position; + } } }; diff --git a/Source/engine/render/text_render.hpp b/Source/engine/render/text_render.hpp index 324d7422d02..234d3ec613c 100644 --- a/Source/engine/render/text_render.hpp +++ b/Source/engine/render/text_render.hpp @@ -152,6 +152,9 @@ struct TextRenderOptions { } highlightRange = { 0, 0 }; uint8_t highlightColor = PAL8_RED + 6; + + /** @brief If a cursor is rendered, the surface coordinates are saved here. */ + std::optional *renderedCursorPositionOut = nullptr; }; /** diff --git a/Source/lua/autocomplete.cpp b/Source/lua/autocomplete.cpp new file mode 100644 index 00000000000..8f4975f5e6a --- /dev/null +++ b/Source/lua/autocomplete.cpp @@ -0,0 +1,112 @@ +#ifdef _DEBUG +#include "lua/autocomplete.hpp" + +#include +#include +#include +#include +#include + +#include + +#include "utils/algorithm/container.hpp" + +namespace devilution { + +namespace { + +std::string_view GetLastToken(std::string_view text) +{ + if (text.empty()) + return {}; + size_t i = text.size(); + while (i > 0 && text[i - 1] != ' ') + --i; + return text.substr(i); +} + +bool IsCallable(const sol::object &value) +{ + if (value.get_type() == sol::type::function) + return true; + if (!value.is()) + return false; + const auto table = value.as(); + const auto metatable = table.get>(sol::metatable_key); + if (!metatable || !metatable->is()) + return false; + const auto callFn = metatable->as().get>(sol::meta_function::call); + return callFn.has_value(); +} + +void SuggestionsFromTable(const sol::table &table, std::string_view prefix, + size_t maxSuggestions, std::unordered_set &out) +{ + for (const auto &[key, value] : table) { + if (key.get_type() == sol::type::string) { + std::string keyStr = key.as(); + if (!keyStr.starts_with(prefix) || keyStr.size() == prefix.size()) + continue; + if (keyStr.starts_with("__") && !prefix.starts_with("__")) + continue; + // sol-internal keys -- we don't have fonts for these so skip them. + if (keyStr.find("♻") != std::string::npos + || keyStr.find("☢") != std::string::npos + || keyStr.find("🔩") != std::string::npos) + continue; + std::string completionText = keyStr.substr(prefix.size()); + LuaAutocompleteSuggestion suggestion { std::move(keyStr), std::move(completionText) }; + if (IsCallable(value)) { + suggestion.completionText.append("()"); + suggestion.cursorAdjust = -1; + } + out.insert(std::move(suggestion)); + if (out.size() == maxSuggestions) + break; + } + } + const auto fallback = table.get>(sol::metatable_key); + if (fallback.has_value() && fallback->get_type() == sol::type::table) { + SuggestionsFromTable(fallback->as(), prefix, maxSuggestions, out); + } +} + +} // namespace + +void GetLuaAutocompleteSuggestions(std::string_view text, const sol::environment &lua, + size_t maxSuggestions, std::vector &out) +{ + out.clear(); + if (text.empty()) + return; + std::string_view token = GetLastToken(text); + const size_t dotPos = token.rfind('.'); + const std::string_view prefix = token.substr(dotPos + 1); + token.remove_suffix(token.size() - (dotPos == std::string_view::npos ? 0 : dotPos)); + + std::unordered_set suggestions; + const auto addSuggestions = [&](const sol::table &table) { + SuggestionsFromTable(table, prefix, maxSuggestions, suggestions); + }; + + if (token.empty()) { + addSuggestions(lua); + const auto fallback = lua.get>("_G"); + if (fallback.has_value() && fallback->get_type() == sol::type::table) { + addSuggestions(fallback->as()); + } + } else { + const auto obj = lua.traverse_get>(token); + if (!obj.has_value()) + return; + if (obj->get_type() == sol::type::table) { + addSuggestions(obj->as()); + } + } + + out.insert(out.end(), suggestions.begin(), suggestions.end()); + c_sort(out); +} + +} // namespace devilution +#endif // _DEBUG diff --git a/Source/lua/autocomplete.hpp b/Source/lua/autocomplete.hpp new file mode 100644 index 00000000000..464d40039f6 --- /dev/null +++ b/Source/lua/autocomplete.hpp @@ -0,0 +1,44 @@ +#pragma once +#ifdef _DEBUG +#include +#include +#include +#include + +#include + +namespace devilution { + +struct LuaAutocompleteSuggestion { + std::string displayText; + std::string completionText; + int cursorAdjust = 0; + + bool operator==(const LuaAutocompleteSuggestion &other) const + { + return displayText == other.displayText; + } + + bool operator<(const LuaAutocompleteSuggestion &other) const + { + return displayText < other.displayText; + } +}; + +void GetLuaAutocompleteSuggestions( + std::string_view text, const sol::environment &lua, + size_t maxSuggestions, std::vector &out); + +} // namespace devilution + +namespace std { +template <> +struct hash { + size_t operator()(const devilution::LuaAutocompleteSuggestion &suggestion) const + { + return hash()(suggestion.displayText); + } +}; +} // namespace std + +#endif // _DEBUG diff --git a/Source/lua/lua.hpp b/Source/lua/lua.hpp index 28c976016d8..70191f211c2 100644 --- a/Source/lua/lua.hpp +++ b/Source/lua/lua.hpp @@ -3,10 +3,7 @@ #include #include - -namespace sol { -class state; -} // namespace sol +#include namespace devilution { diff --git a/Source/lua/repl.cpp b/Source/lua/repl.cpp index fbf2e22abb0..26c39575e2a 100644 --- a/Source/lua/repl.cpp +++ b/Source/lua/repl.cpp @@ -54,13 +54,6 @@ void CreateReplEnvironment() lua_setwarnf(replEnv->lua_state(), LuaConsoleWarn, /*ud=*/nullptr); } -sol::environment &ReplEnvironment() -{ - if (!replEnv.has_value()) - CreateReplEnvironment(); - return *replEnv; -} - sol::protected_function_result TryRunLuaAsExpressionThenStatement(std::string_view code) { // Try to compile as an expression first. This also how the `lua` repl is implemented. @@ -81,12 +74,19 @@ sol::protected_function_result TryRunLuaAsExpressionThenStatement(std::string_vi } } sol::stack_aligned_protected_function fn(lua.lua_state(), -1); - sol::set_environment(ReplEnvironment(), fn); + sol::set_environment(GetLuaReplEnvironment(), fn); return fn(); } } // namespace +sol::environment &GetLuaReplEnvironment() +{ + if (!replEnv.has_value()) + CreateReplEnvironment(); + return *replEnv; +} + tl::expected RunLuaReplLine(std::string_view code) { const sol::protected_function_result result = TryRunLuaAsExpressionThenStatement(code); diff --git a/Source/lua/repl.hpp b/Source/lua/repl.hpp index 74fd02b8bfa..cd89e8299c4 100644 --- a/Source/lua/repl.hpp +++ b/Source/lua/repl.hpp @@ -5,10 +5,13 @@ #include #include +#include namespace devilution { tl::expected RunLuaReplLine(std::string_view code); +sol::environment &GetLuaReplEnvironment(); + } // namespace devilution #endif // _DEBUG diff --git a/Source/panels/console.cpp b/Source/panels/console.cpp index ead7f35ca72..2a6c053a0a7 100644 --- a/Source/panels/console.cpp +++ b/Source/panels/console.cpp @@ -22,6 +22,7 @@ #include "engine/render/text_render.hpp" #include "engine/size.hpp" #include "engine/surface.hpp" +#include "lua/autocomplete.hpp" #include "lua/repl.hpp" #include "utils/algorithm/container.hpp" #include "utils/sdl_geometry.h" @@ -54,6 +55,10 @@ TextInputState ConsoleInputState { }; bool InputTextChanged = false; std::string WrappedInputText { Prompt }; +std::vector AutocompleteSuggestions; +int AutocompleteSuggestionsMaxWidth = -1; +int AutocompleteSuggestionFocusIndex = -1; +constexpr size_t MaxSuggestions = 12; struct ConsoleLine { enum Type : uint8_t { @@ -103,9 +108,12 @@ constexpr UiFlags InputTextUiFlags = TextUiFlags | UiFlags::ColorDialogWhite; constexpr UiFlags OutputTextUiFlags = TextUiFlags | UiFlags::ColorDialogWhite; constexpr UiFlags WarningTextUiFlags = TextUiFlags | UiFlags::ColorDialogYellow; constexpr UiFlags ErrorTextUiFlags = TextUiFlags | UiFlags::ColorDialogRed; +constexpr UiFlags AutocompleteSuggestionsTextUiFlags = TextUiFlags | UiFlags::ColorDialogWhite; +constexpr UiFlags AutocompleteSuggestionsFocusedTextUiFlags = TextUiFlags | UiFlags::ColorDialogYellow; constexpr int TextSpacing = 0; constexpr GameFontTables TextFontSize = GetFontSizeFromUiFlags(InputTextUiFlags); +constexpr GameFontTables AutocompleteSuggestionsTextFontSize = GetFontSizeFromUiFlags(AutocompleteSuggestionsTextUiFlags); // Scroll offset from the bottom (in pages), to be applied on next render. int PendingScrollPages; @@ -156,11 +164,53 @@ void SendInput() HistoryIndex = -1; } -void DrawInputText(const Surface &inputTextSurface, std::string_view originalInputText, std::string_view wrappedInputText) +void DrawAutocompleteSuggestions(const Surface &out, const std::vector &suggestions, Point position) +{ + if (AutocompleteSuggestionsMaxWidth == -1) { + int maxWidth = 0; + for (const LuaAutocompleteSuggestion &suggestion : suggestions) { + maxWidth = std::max(maxWidth, GetLineWidth(suggestion.displayText, AutocompleteSuggestionsTextFontSize, TextSpacing)); + } + AutocompleteSuggestionsMaxWidth = maxWidth; + } + + const int outerWidth = AutocompleteSuggestionsMaxWidth + TextPaddingX * 2; + + if (position.x + outerWidth > out.w()) { + position.x -= AutocompleteSuggestionsMaxWidth; + } + const int height = static_cast(suggestions.size()) * LineHeight + TextPaddingYBottom + TextPaddingYTop; + + position.y -= height; + FillRect(out, position.x, position.y, outerWidth, height, PAL16_BLUE + 14); + size_t i = 0; + + Point textPosition { position.x + TextPaddingX, position.y + TextPaddingYTop }; + for (const LuaAutocompleteSuggestion &suggestion : suggestions) { + if (static_cast(i) == AutocompleteSuggestionFocusIndex) { + const int extraTop = i == 0 ? TextPaddingYTop : 0; + const int extraHeight = extraTop + TextPaddingYBottom; + FillRect(out, position.x, textPosition.y - extraTop, outerWidth, LineHeight + extraHeight, PAL16_BLUE + 8); + } + DrawString(out, suggestion.displayText, textPosition, + TextRenderOptions { + .flags = AutocompleteSuggestionsTextUiFlags, + .spacing = TextSpacing, + }); + textPosition.y += LineHeight; + ++i; + } +} + +void DrawInputText(const Surface &out, + Rectangle rect, std::string_view originalInputText, std::string_view wrappedInputText) { int lineY = 0; int numRendered = -static_cast(Prompt.size()); bool prevIsOriginalNewline = false; + + const Surface inputTextSurface = out.subregion(rect.position.x, rect.position.y, rect.size.width, rect.size.height); + std::optional renderedCursorPositionOut; for (const std::string_view line : SplitByChar(wrappedInputText, '\n')) { const int lineCursorPosition = static_cast(ConsoleInputCursor.position) - numRendered; const bool isCursorOnPrevLine = lineCursorPosition == 0 && !prevIsOriginalNewline && numRendered > 0; @@ -171,7 +221,7 @@ void DrawInputText(const Surface &inputTextSurface, std::string_view originalInp .spacing = TextSpacing, .cursorPosition = isCursorOnPrevLine ? -1 : lineCursorPosition, .highlightRange = { static_cast(ConsoleInputCursor.selection.begin) - numRendered, static_cast(ConsoleInputCursor.selection.end) - numRendered }, - }); + .renderedCursorPositionOut = &renderedCursorPositionOut }); lineY += LineHeight; numRendered += static_cast(line.size()); prevIsOriginalNewline = static_cast(numRendered) < originalInputText.size() @@ -179,6 +229,13 @@ void DrawInputText(const Surface &inputTextSurface, std::string_view originalInp if (prevIsOriginalNewline) ++numRendered; } + + if (!AutocompleteSuggestions.empty() && renderedCursorPositionOut.has_value()) { + Point position = *renderedCursorPositionOut; + position.x += rect.position.x; + position.y += rect.position.y; + DrawAutocompleteSuggestions(out, AutocompleteSuggestions, position); + } } void DrawConsoleLines(const Surface &out) @@ -357,6 +414,15 @@ void OpenConsole() FirstRender = true; } +void AcceptSuggestion() +{ + const LuaAutocompleteSuggestion &suggestion = AutocompleteSuggestions[AutocompleteSuggestionFocusIndex]; + ConsoleInputState.type(suggestion.completionText); + if (suggestion.cursorAdjust == -1) { + ConsoleInputState.moveCursorLeft(/*word=*/false); + } +} + bool ConsoleHandleEvent(const SDL_Event &event) { if (!IsConsoleVisible) { @@ -377,20 +443,46 @@ bool ConsoleHandleEvent(const SDL_Event &event) case SDL_KEYDOWN: switch (event.key.keysym.sym) { case SDLK_ESCAPE: - CloseConsole(); + if (!AutocompleteSuggestions.empty()) { + AutocompleteSuggestions.clear(); + AutocompleteSuggestionFocusIndex = -1; + } else { + CloseConsole(); + } return true; case SDLK_UP: - isShift ? PrevOutput() : PrevInput(); + if (AutocompleteSuggestionFocusIndex != -1) { + AutocompleteSuggestionFocusIndex = std::max( + 0, AutocompleteSuggestionFocusIndex - 1); + } else { + isShift ? PrevOutput() : PrevInput(); + } return true; case SDLK_DOWN: - isShift ? NextOutput() : NextInput(); + if (AutocompleteSuggestionFocusIndex != -1) { + AutocompleteSuggestionFocusIndex = std::min( + static_cast(AutocompleteSuggestions.size()) - 1, + AutocompleteSuggestionFocusIndex + 1); + } else { + isShift ? NextOutput() : NextInput(); + } + return true; + case SDLK_TAB: + if (AutocompleteSuggestionFocusIndex != -1) { + AcceptSuggestion(); + InputTextChanged = true; + } return true; case SDLK_RETURN: case SDLK_KP_ENTER: if (isShift) { ConsoleInputState.type("\n"); } else { - SendInput(); + if (AutocompleteSuggestionFocusIndex != -1) { + AcceptSuggestion(); + } else { + SendInput(); + } } InputTextChanged = true; return true; @@ -445,6 +537,9 @@ void DrawConsole(const Surface &out) const std::string_view originalInputText = ConsoleInputState.value(); if (InputTextChanged) { WrappedInputText = WordWrapString(StrCat(Prompt, originalInputText), OuterRect.size.width - 2 * TextPaddingX, TextFontSize, TextSpacing); + GetLuaAutocompleteSuggestions(originalInputText.substr(0, ConsoleInputCursor.position), GetLuaReplEnvironment(), /*maxSuggestions=*/MaxSuggestions, AutocompleteSuggestions); + AutocompleteSuggestionsMaxWidth = -1; + AutocompleteSuggestionFocusIndex = AutocompleteSuggestions.empty() ? -1 : 0; InputTextChanged = false; } @@ -481,13 +576,14 @@ void DrawConsole(const Surface &out) DrawHorizontalLine(out, InputRect.position - Displacement { 0, 1 }, InputRect.size.width, BorderColor); DrawInputText( - out.subregion( - inputTextRect.position.x, - inputTextRect.position.y, - // Extra space for the cursor on the right: - inputTextRect.size.width + TextPaddingX, - // Extra space for letters like g. - inputTextRect.size.height + TextPaddingYBottom), + out, + Rectangle( + inputTextRect.position, + Size { + // Extra space for the cursor on the right: + inputTextRect.size.width + TextPaddingX, + // Extra space for letters like g. + inputTextRect.size.height + TextPaddingYBottom }), originalInputText, WrappedInputText);