From fdb57388155c33ac75f394cf7289870c767c6622 Mon Sep 17 00:00:00 2001 From: Gleb Mazovetskiy Date: Mon, 23 Oct 2023 23:00:40 +0100 Subject: [PATCH] Add cursor support to DiabloUI and chat Supports move left/right/home/end, backspace, delete, and Ctrl+V. --- Source/CMakeLists.txt | 1 + Source/DiabloUI/diabloui.cpp | 94 +++---------- Source/DiabloUI/diabloui.h | 3 +- Source/DiabloUI/text_input.cpp | 81 +++++++++++ Source/DiabloUI/text_input.hpp | 202 +++++++++++++++++++++++++++ Source/DiabloUI/ui_flags.hpp | 3 +- Source/DiabloUI/ui_item.h | 1 + Source/control.cpp | 41 +++--- Source/control.h | 2 +- Source/controls/menu_controls.cpp | 17 ++- Source/diablo.cpp | 8 +- Source/engine/render/text_render.cpp | 36 +++-- Source/engine/render/text_render.hpp | 3 +- Source/utils/utf8.cpp | 29 ++-- Source/utils/utf8.hpp | 11 ++ 15 files changed, 391 insertions(+), 141 deletions(-) create mode 100644 Source/DiabloUI/text_input.cpp create mode 100644 Source/DiabloUI/text_input.hpp diff --git a/Source/CMakeLists.txt b/Source/CMakeLists.txt index a88004e2407..47466359424 100644 --- a/Source/CMakeLists.txt +++ b/Source/CMakeLists.txt @@ -88,6 +88,7 @@ set(libdevilutionx_SRCS DiabloUI/settingsmenu.cpp DiabloUI/support_lines.cpp DiabloUI/title.cpp + DiabloUI/text_input.cpp dvlnet/abstract_net.cpp dvlnet/base.cpp diff --git a/Source/DiabloUI/diabloui.cpp b/Source/DiabloUI/diabloui.cpp index c4db15e2de5..64613c63895 100644 --- a/Source/DiabloUI/diabloui.cpp +++ b/Source/DiabloUI/diabloui.cpp @@ -3,11 +3,13 @@ #include #include #include +#include #include #include "DiabloUI/button.h" #include "DiabloUI/dialogs.h" #include "DiabloUI/scrollbar.h" +#include "DiabloUI/text_input.hpp" #include "controls/controller.h" #include "controls/input.h" #include "controls/menu_controls.h" @@ -58,7 +60,6 @@ OptionalOwnedClxSpriteList ArtBackgroundWidescreen; OptionalOwnedClxSpriteList ArtBackground; OptionalOwnedClxSpriteList ArtCursor; -bool textInputActive = true; std::size_t SelectedItem = 0; namespace { @@ -79,8 +80,8 @@ bool (*gfnListYesNo)(); std::vector gUiItems; UiList *gUiList = nullptr; bool UiItemsWraps; -char *UiTextInput; -int UiTextInputLen; + +std::optional UiTextInputState; bool allowEmptyTextInput = false; uint32_t fadeTc; @@ -107,6 +108,11 @@ void AdjustListOffset(std::size_t itemIndex) } // namespace +bool IsTextInputActive() +{ + return UiTextInputState.has_value(); +} + void UiInitList(void (*fnFocus)(int value), void (*fnSelect)(int value), void (*fnEsc)(), const std::vector> &items, bool itemsWraps, void (*fnFullscreen)(), bool (*fnYesNo)(), size_t selectedItem /*= 0*/) { SelectedItem = selectedItem; @@ -128,13 +134,11 @@ void UiInitList(void (*fnFocus)(int value), void (*fnSelect)(int value), void (* #ifndef __SWITCH__ SDL_StopTextInput(); // input is enabled by default #endif - textInputActive = false; UiScrollbar *uiScrollbar = nullptr; for (const auto &item : items) { if (item->IsType(UiType::Edit)) { auto *pItemUIEdit = static_cast(item.get()); SDL_SetTextInputRect(&item->m_rect); - textInputActive = true; allowEmptyTextInput = pItemUIEdit->m_allowEmpty; #ifdef __SWITCH__ switch_start_text_input(pItemUIEdit->m_hint, pItemUIEdit->m_value, pItemUIEdit->m_max_length); @@ -145,8 +149,11 @@ void UiInitList(void (*fnFocus)(int value), void (*fnSelect)(int value), void (* #else SDL_StartTextInput(); #endif - UiTextInput = pItemUIEdit->m_value; - UiTextInputLen = pItemUIEdit->m_max_length; + UiTextInputState.emplace(TextInputState::Options { + .value = pItemUIEdit->m_value, + .cursorPosition = &pItemUIEdit->m_cursor, + .maxLength = pItemUIEdit->m_max_length, + }); } else if (item->IsType(UiType::List)) { auto *uiList = static_cast(item.get()); SelectedItemMax = std::max(uiList->m_vecItems.size() - 1, static_cast(0)); @@ -293,14 +300,6 @@ void UiFocusPageDown() } } -void SelheroCatToName(const char *inBuf, char *outBuf, int cnt) -{ - size_t outLen = strlen(outBuf); - char *dest = outBuf + outLen; - size_t destCount = cnt - outLen; - CopyUtf8(dest, inBuf, destCount); -} - bool HandleMenuAction(MenuAction menuAction) { switch (menuAction) { @@ -404,55 +403,8 @@ void UiFocusNavigation(SDL_Event *event) } #endif - if (textInputActive) { - switch (event->type) { - case SDL_KEYDOWN: { - switch (event->key.keysym.sym) { -#ifndef USE_SDL1 - case SDLK_v: - if ((SDL_GetModState() & KMOD_CTRL) != 0) { - if (SDL_HasClipboardText() == SDL_TRUE) { - std::unique_ptr> clipboard { SDL_GetClipboardText() }; - if (clipboard == nullptr || *clipboard == '\0') { - Log("{}", SDL_GetError()); - } else { - SelheroCatToName(clipboard.get(), UiTextInput, UiTextInputLen); - } - } - } - return; -#endif - case SDLK_BACKSPACE: - case SDLK_LEFT: { - UiTextInput[FindLastUtf8Symbols(UiTextInput)] = '\0'; - return; - } - default: - break; - } -#ifdef USE_SDL1 - if ((event->key.keysym.mod & KMOD_CTRL) == 0) { - std::string utf8; - AppendUtf8(event->key.keysym.unicode, utf8); - SelheroCatToName(utf8.c_str(), UiTextInput, UiTextInputLen); - } -#endif - break; - } -#ifndef USE_SDL1 - case SDL_TEXTINPUT: - if (textInputActive) { -#ifdef __vita__ - CopyUtf8(UiTextInput, event->text.text, UiTextInputLen); -#else - SelheroCatToName(event->text.text, UiTextInput, UiTextInputLen); -#endif - } - return; -#endif - default: - break; - } + if (UiTextInputState.has_value() && HandleTextInputEvent(*event, *UiTextInputState)) { + return; } if (event->type == SDL_MOUSEBUTTONDOWN || event->type == SDL_MOUSEBUTTONUP) { @@ -519,15 +471,14 @@ void UiHandleEvents(SDL_Event *event) void UiFocusNavigationSelect() { UiPlaySelectSound(); - if (textInputActive) { - if (!allowEmptyTextInput && strlen(UiTextInput) == 0) { + if (UiTextInputState.has_value()) { + if (!allowEmptyTextInput && UiTextInputState->empty()) { return; } #ifndef __SWITCH__ SDL_StopTextInput(); #endif - UiTextInput = nullptr; - UiTextInputLen = 0; + UiTextInputState = std::nullopt; } if (gfnListSelect != nullptr) gfnListSelect(SelectedItem); @@ -536,12 +487,11 @@ void UiFocusNavigationSelect() void UiFocusNavigationEsc() { UiPlaySelectSound(); - if (textInputActive) { + if (UiTextInputState.has_value()) { #ifndef __SWITCH__ SDL_StopTextInput(); #endif - UiTextInput = nullptr; - UiTextInputLen = 0; + UiTextInputState = std::nullopt; } if (gfnListEsc != nullptr) gfnListEsc(); @@ -914,7 +864,7 @@ void Render(const UiEdit &uiEdit) Rectangle rect = MakeRectangle(uiEdit.m_rect).inset({ 43, 1 }); const Surface &out = Surface(DiabloUiSurface()); - DrawString(out, uiEdit.m_value, rect, uiEdit.GetFlags() | UiFlags::TextCursor); + DrawString(out, uiEdit.m_value, rect, uiEdit.GetFlags(), /*spacing=*/1, /*lineHeight=*/-1, uiEdit.m_cursor); } bool HandleMouseEventArtTextButton(const SDL_Event &event, const UiArtTextButton *uiButton) diff --git a/Source/DiabloUI/diabloui.h b/Source/DiabloUI/diabloui.h index 7cbea8d5b84..5d597fc1b0c 100644 --- a/Source/DiabloUI/diabloui.h +++ b/Source/DiabloUI/diabloui.h @@ -17,7 +17,8 @@ namespace devilution { extern std::size_t SelectedItem; -extern bool textInputActive; + +bool IsTextInputActive(); enum _artFocus : uint8_t { FOCUS_SMALL, diff --git a/Source/DiabloUI/text_input.cpp b/Source/DiabloUI/text_input.cpp new file mode 100644 index 00000000000..9ae84f2f491 --- /dev/null +++ b/Source/DiabloUI/text_input.cpp @@ -0,0 +1,81 @@ +#include "DiabloUI/text_input.hpp" + +#include + +#include + +#ifdef USE_SDL1 +#include "utils/sdl2_to_1_2_backports.h" +#endif + +#include "utils/log.hpp" +#include "utils/sdl_ptrs.h" +#include "utils/utf8.hpp" + +namespace devilution { + +bool HandleTextInputEvent(const SDL_Event &event, TextInputState &state) +{ + switch (event.type) { + case SDL_KEYDOWN: { + switch (event.key.keysym.sym) { +#ifndef USE_SDL1 + case SDLK_v: + if ((SDL_GetModState() & KMOD_CTRL) != 0) { + if (SDL_HasClipboardText() == SDL_TRUE) { + std::unique_ptr> clipboard { SDL_GetClipboardText() }; + if (clipboard == nullptr || *clipboard == '\0') { + Log("{}", SDL_GetError()); + } else { + state.type(clipboard.get()); + } + } + } + return true; +#endif + case SDLK_BACKSPACE: + state.backspace(); + return true; + case SDLK_DELETE: + state.del(); + return true; + case SDLK_LEFT: + state.moveCursorLeft(); + return true; + case SDLK_RIGHT: + state.moveCursorRight(); + return true; + case SDLK_HOME: + state.setCursorToStart(); + return true; + case SDLK_END: + state.setCursorToEnd(); + return true; + default: + break; + } +#ifdef USE_SDL1 + if ((event.key.keysym.mod & KMOD_CTRL) == 0 && event.key.keysym.unicode >= ' ') { + std::string utf8; + AppendUtf8(event.key.keysym.unicode, utf8); + state.type(utf8); + return true; + } +#endif + return false; + } +#ifndef USE_SDL1 + case SDL_TEXTINPUT: +#ifdef __vita__ + state.assign(event.text.text); +#else + state.type(event.text.text); +#endif + return true; +#endif + default: + return false; + } +} + +} // namespace devilution diff --git a/Source/DiabloUI/text_input.hpp b/Source/DiabloUI/text_input.hpp new file mode 100644 index 00000000000..4073a151620 --- /dev/null +++ b/Source/DiabloUI/text_input.hpp @@ -0,0 +1,202 @@ +#pragma once + +#include +#include +#include +#include + +#include + +#include "utils/utf8.hpp" + +namespace devilution { + +/** + * @brief Manages state for a single-line text input with a cursor. + * + * The text value and the cursor position are stored externally. + */ +class TextInputState { + /** + * @brief Manages an unowned fixed size char array. + */ + struct Buffer { + Buffer(char *begin, size_t maxLength) + : buf_(begin) + , maxLength_(maxLength) + { + std::string_view str(begin); + str = TruncateUtf8(str, maxLength); + len_ = str.size(); + buf_[len_] = '\0'; + } + + [[nodiscard]] size_t size() const + { + return len_; + } + + [[nodiscard]] bool empty() const + { + return len_ == 0; + } + + Buffer &operator=(std::string_view value) + { + value = TruncateUtf8(value, maxLength_); + CopyUtf8(buf_, value, maxLength_); + len_ = value.size(); + return *this; + } + + void insert(size_t pos, std::string_view value) + { + value = truncateForInsertion(value); + std::memmove(&buf_[pos + value.size()], &buf_[pos], len_ - pos); + std::memcpy(&buf_[pos], value.data(), value.size()); + len_ += value.size(); + buf_[len_] = '\0'; + } + + void erase(size_t pos, size_t len) + { + std::memmove(&buf_[pos], &buf_[pos + len], len); + len_ -= len; + buf_[len_] = '\0'; + } + + explicit operator std::string_view() const + { + return { buf_, len_ }; + } + + private: + /** + * @brief Truncates `text` so that it would fit when inserted, + * respecting UTF-8 code point boundaries. + */ + [[nodiscard]] std::string_view truncateForInsertion(std::string_view text) const + { + const size_t newLength = len_ + text.size(); + if (newLength > maxLength_) { + return TruncateUtf8(text, newLength - maxLength_); + } + return text; + } + + char *buf_; // unowned + size_t maxLength_; + size_t len_; + }; + +public: + struct Options { + char *value; // unowned + size_t *cursorPosition; // unowned + size_t maxLength = 0; + }; + TextInputState(const Options &options) + : value_(options.value, options.maxLength) + , cursorPosition_(options.cursorPosition) + { + *cursorPosition_ = value_.size(); + } + + [[nodiscard]] std::string_view value() const + { + return std::string_view(value_); + } + + [[nodiscard]] bool empty() const + { + return value_.empty(); + } + + /** + * @brief Overwrites the value with the given text and moves cursor to the end. + */ + void assign(std::string_view text) + { + value_ = text; + *cursorPosition_ = value_.size(); + } + + /** + * @brief Truncate to precisely `length` bytes. + */ + void truncate(size_t length) + { + if (length >= value().size()) + return; + value_ = value().substr(0, length); + *cursorPosition_ = std::min(*cursorPosition_, value_.size()); + } + + /** + * @brief Inserts the text at the current cursor position. + */ + void type(std::string_view text) + { + const size_t prevSize = value_.size(); + value_.insert(*cursorPosition_, text); + *cursorPosition_ += value_.size() - prevSize; + } + + void backspace() + { + if (*cursorPosition_ == 0) + return; + const size_t toRemove = *cursorPosition_ - FindLastUtf8Symbols(beforeCursor()); + *cursorPosition_ -= toRemove; + value_.erase(*cursorPosition_, toRemove); + } + + void del() + { + if (*cursorPosition_ == value_.size()) + return; + value_.erase(*cursorPosition_, Utf8CodePointLen(afterCursor().data())); + } + + void setCursorToStart() + { + *cursorPosition_ = 0; + } + + void setCursorToEnd() + { + *cursorPosition_ = std::string_view(value_).size(); + } + + void moveCursorLeft() + { + if (*cursorPosition_ == 0) + return; + --*cursorPosition_; + } + + void moveCursorRight() + { + if (*cursorPosition_ == value_.size()) + return; + ++*cursorPosition_; + } + +private: + [[nodiscard]] std::string_view beforeCursor() const + { + return std::string_view(value_).substr(0, *cursorPosition_); + } + + [[nodiscard]] std::string_view afterCursor() const + { + return std::string_view(value_).substr(*cursorPosition_); + } + + Buffer value_; + size_t *cursorPosition_; // unowned +}; + +bool HandleTextInputEvent(const SDL_Event &event, TextInputState &state); + +} // namespace devilution diff --git a/Source/DiabloUI/ui_flags.hpp b/Source/DiabloUI/ui_flags.hpp index 9c3c9d33ac6..1afdad7d8e9 100644 --- a/Source/DiabloUI/ui_flags.hpp +++ b/Source/DiabloUI/ui_flags.hpp @@ -43,8 +43,7 @@ enum class UiFlags : uint32_t { ElementHidden = 1 << 26, PentaCursor = 1 << 27, - TextCursor = 1 << 28, - Outlined = 1 << 29, + Outlined = 1 << 28, /** @brief Ensures that the if current element is active that the next element is also visible. */ NeedsNextElement = 1 << 30, diff --git a/Source/DiabloUI/ui_item.h b/Source/DiabloUI/ui_item.h index 62d95fd0ca3..c293cd54850 100644 --- a/Source/DiabloUI/ui_item.h +++ b/Source/DiabloUI/ui_item.h @@ -271,6 +271,7 @@ class UiEdit : public UiItemBase { std::string_view m_hint; char *m_value; std::size_t m_max_length; + size_t m_cursor; bool m_allowEmpty; }; diff --git a/Source/control.cpp b/Source/control.cpp index dc663c8a9d3..6f055b30807 100644 --- a/Source/control.cpp +++ b/Source/control.cpp @@ -15,6 +15,7 @@ #include +#include "DiabloUI/text_input.hpp" #include "automap.h" #include "controls/modifier_hints.h" #include "controls/plrctrls.h" @@ -150,6 +151,9 @@ bool TalkButtonsDown[3]; int sgbPlrTalkTbl; bool WhisperList[MAX_PLRS]; +size_t ChatCursorPosition; +std::optional ChatInputState; + enum panel_button_id : uint8_t { PanelButtonCharinfo, PanelButtonQlog, @@ -601,7 +605,7 @@ void ControlPressEnter() talkSave &= 7; if (i != talkSave) { strcpy(TalkSave[i], TalkSave[talkSave]); - strcpy(TalkSave[talkSave], TalkMessage); + *BufCopy(TalkSave[talkSave], ChatInputState->value()) = '\0'; } } TalkMessage[0] = '\0'; @@ -615,7 +619,7 @@ void ControlUpDown(int v) for (int i = 0; i < 8; i++) { TalkSaveIndex = (v + TalkSaveIndex) & 7; if (TalkSave[TalkSaveIndex][0] != 0) { - strcpy(TalkMessage, TalkSave[TalkSaveIndex]); + ChatInputState->assign(TalkSave[TalkSaveIndex]); return; } } @@ -860,6 +864,7 @@ void InitControlPan() } } talkflag = false; + ChatInputState = std::nullopt; if (IsChatAvailable()) { if (!HeadlessMode) { { @@ -1437,8 +1442,8 @@ void DrawTalkPan(const Surface &out) int x = mainPanelPosition.x + 200; int y = mainPanelPosition.y + 10; - const uint32_t len = DrawString(out, TalkMessage, { { x, y }, { 250, 39 } }, UiFlags::ColorWhite | UiFlags::PentaCursor, 1, 13); - TalkMessage[std::min(len, sizeof(TalkMessage) - 1)] = '\0'; + const uint32_t len = DrawString(out, TalkMessage, { { x, y }, { 250, 39 } }, UiFlags::ColorWhite | UiFlags::PentaCursor, 1, 13, ChatCursorPosition); + ChatInputState->truncate(len); x += 46; int talkBtn = 0; @@ -1532,9 +1537,13 @@ void control_type_message() return; talkflag = true; + TalkMessage[0] = '\0'; + ChatInputState.emplace(TextInputState::Options { + .value = TalkMessage, + .cursorPosition = &ChatCursorPosition, + .maxLength = sizeof(TalkMessage) - 1 }); SDL_Rect rect = MakeSdlRect(GetMainPanel().position.x + 200, GetMainPanel().position.y + 22, 0, 27); SDL_SetTextInputRect(&rect); - TalkMessage[0] = '\0'; for (bool &talkButtonDown : TalkButtonsDown) { talkButtonDown = false; } @@ -1548,6 +1557,7 @@ void control_reset_talk() { talkflag = false; SDL_StopTextInput(); + ChatInputState = std::nullopt; sgbPlrTalkTbl = 0; RedrawEverything(); } @@ -1563,9 +1573,9 @@ bool IsTalkActive() return true; } -void control_new_text(std::string_view text) +bool HandleTalkTextInputEvent(const SDL_Event &event) { - strncat(TalkMessage, text.data(), sizeof(TalkMessage) - strlen(TalkMessage) - 1); + return HandleTextInputEvent(event, *ChatInputState); } bool control_presskeys(SDL_Keycode vkey) @@ -1583,29 +1593,12 @@ bool control_presskeys(SDL_Keycode vkey) case SDLK_KP_ENTER: ControlPressEnter(); return true; - case SDLK_BACKSPACE: - TalkMessage[FindLastUtf8Symbols(TalkMessage)] = '\0'; - return true; case SDLK_DOWN: ControlUpDown(1); return true; case SDLK_UP: ControlUpDown(-1); return true; -#ifndef USE_SDL1 - case SDLK_v: - if ((SDL_GetModState() & KMOD_CTRL) != 0) { - if (SDL_HasClipboardText() == SDL_TRUE) { - std::unique_ptr> clipboard { SDL_GetClipboardText() }; - if (clipboard == nullptr || *clipboard == '\0') { - Log("{}", SDL_GetError()); - } else { - strncat(TalkMessage, clipboard.get(), sizeof(TalkMessage) - strlen(TalkMessage) - 1); - } - } - } - return true; -#endif default: return vkey >= SDLK_SPACE && vkey <= SDLK_z; } diff --git a/Source/control.h b/Source/control.h index 68e7e1fc858..1ff7f8609d6 100644 --- a/Source/control.h +++ b/Source/control.h @@ -188,7 +188,7 @@ void control_release_talk_btn(); void control_type_message(); void control_reset_talk(); bool IsTalkActive(); -void control_new_text(std::string_view text); +bool HandleTalkTextInputEvent(const SDL_Event &event); bool control_presskeys(SDL_Keycode vkey); void DiabloHotkeyMsg(uint32_t dwMsg); void CloseGoldDrop(); diff --git a/Source/controls/menu_controls.cpp b/Source/controls/menu_controls.cpp index 619886cca9f..732525e84f8 100644 --- a/Source/controls/menu_controls.cpp +++ b/Source/controls/menu_controls.cpp @@ -113,16 +113,25 @@ std::vector GetMenuActions(const SDL_Event &event) case SDLK_KP_ENTER: return { MenuAction_SELECT }; case SDLK_SPACE: - if (!textInputActive) { + if (!IsTextInputActive()) { return { MenuAction_SELECT }; } break; case SDLK_DELETE: - return { MenuAction_DELETE }; + if (!IsTextInputActive()) { + return { MenuAction_DELETE }; + } + break; case SDLK_LEFT: - return { MenuAction_LEFT }; + if (!IsTextInputActive()) { + return { MenuAction_LEFT }; + } + break; case SDLK_RIGHT: - return { MenuAction_RIGHT }; + if (!IsTextInputActive()) { + return { MenuAction_RIGHT }; + } + break; case SDLK_ESCAPE: return { MenuAction_BACK }; default: diff --git a/Source/diablo.cpp b/Source/diablo.cpp index e1a9d72967d..3c7c5ae4f2f 100644 --- a/Source/diablo.cpp +++ b/Source/diablo.cpp @@ -679,10 +679,6 @@ void HandleMouseButtonUp(Uint8 button, uint16_t modState) bool HandleTextInput(std::string_view text) { - if (IsTalkActive()) { - control_new_text(text); - return true; - } if (dropGoldFlag) { GoldDropNewText(text); return true; @@ -723,6 +719,10 @@ void GameEventHandler(const SDL_Event &event, uint16_t modState) return; } + if (IsTalkActive() && HandleTalkTextInputEvent(event)) { + return; + } + switch (event.type) { case SDL_KEYDOWN: { #ifdef USE_SDL1 diff --git a/Source/engine/render/text_render.cpp b/Source/engine/render/text_render.cpp index 99c82941f20..7a3f0cc4cb1 100644 --- a/Source/engine/render/text_render.cpp +++ b/Source/engine/render/text_render.cpp @@ -410,16 +410,28 @@ int GetLineStartX(UiFlags flags, const Rectangle &rect, int lineWidth) uint32_t DoDrawString(const Surface &out, std::string_view text, Rectangle rect, Point &characterPosition, int spacing, int lineHeight, int lineWidth, int rightMargin, int bottomMargin, - UiFlags flags, GameFontTables size, text_color color, bool outline) + UiFlags flags, GameFontTables size, text_color color, bool outline, int cursorPosition = -1) { CurrentFont currentFont; char32_t next; std::string_view remaining = text; size_t cpLen; + + const auto maybeDrawCursor = [&]() { + if (cursorPosition == static_cast(text.size() - remaining.size()) && GetAnimationFrame(2, 500) != 0) { + Point position = characterPosition; + MaybeWrap(position, 2, rightMargin, position.x, lineHeight); + OptionalClxSpriteList baseFont = LoadFont(size, color, 0); + if (baseFont) + DrawFont(out, position, *baseFont, color, '|', outline); + } + }; + for (; !remaining.empty() && remaining[0] != '\0' && (next = DecodeFirstUtf8CodePoint(remaining, &cpLen)) != Utf8DecodeError; remaining.remove_prefix(cpLen)) { + maybeDrawCursor(); if (next == ZWSP) continue; @@ -452,6 +464,7 @@ uint32_t DoDrawString(const Surface &out, std::string_view text, Rectangle rect, DrawFont(out, characterPosition, *currentFont.sprite, color, frame, outline); characterPosition.x += width + spacing; } + maybeDrawCursor(); return remaining.data() - text.data(); } @@ -658,7 +671,7 @@ std::string WordWrapString(std::string_view text, unsigned width, GameFontTables /** * @todo replace Rectangle with cropped Surface */ -uint32_t DrawString(const Surface &out, std::string_view text, const Rectangle &rect, UiFlags flags, int spacing, int lineHeight) +uint32_t DrawString(const Surface &out, std::string_view text, const Rectangle &rect, UiFlags flags, int spacing, int lineHeight, int cursorPosition) { GameFontTables size = GetSizeFromFlags(flags); text_color color = GetColorFromFlags(flags); @@ -682,7 +695,7 @@ uint32_t DrawString(const Surface &out, std::string_view text, const Rectangle & lineHeight = GetLineHeight(text, size); if (HasAnyOf(flags, UiFlags::VerticalCenter)) { - int textHeight = (c_count(text, '\n') + 1) * lineHeight; + const int textHeight = (c_count(text, '\n') + 1) * lineHeight; characterPosition.y += std::max(0, (rect.size.height - textHeight) / 2); } @@ -692,17 +705,17 @@ uint32_t DrawString(const Surface &out, std::string_view text, const Rectangle & const Surface clippedOut = ClipSurface(out, rect); - const uint32_t bytesDrawn = DoDrawString(clippedOut, text, rect, characterPosition, spacing, lineHeight, lineWidth, rightMargin, bottomMargin, flags, size, color, outlined); + // Only draw the PentaCursor if the cursor is not at the end. + if (HasAnyOf(flags, UiFlags::PentaCursor) && static_cast(cursorPosition) == text.size()) { + cursorPosition = -1; + } + + const uint32_t bytesDrawn = DoDrawString(clippedOut, text, rect, characterPosition, spacing, lineHeight, lineWidth, rightMargin, bottomMargin, flags, size, color, outlined, cursorPosition); if (HasAnyOf(flags, UiFlags::PentaCursor)) { const ClxSprite sprite = (*pSPentSpn2Cels)[PentSpn2Spin()]; MaybeWrap(characterPosition, sprite.width(), rightMargin, initialX, lineHeight); ClxDraw(clippedOut, characterPosition + Displacement { 0, lineHeight - BaseLineOffset[size] }, sprite); - } else if (HasAnyOf(flags, UiFlags::TextCursor) && GetAnimationFrame(2, 500) != 0) { - MaybeWrap(characterPosition, 2, rightMargin, initialX, lineHeight); - OptionalClxSpriteList baseFont = LoadFont(size, color, 0); - if (baseFont) - DrawFont(clippedOut, characterPosition, *baseFont, color, '|', outlined); } return bytesDrawn; @@ -802,11 +815,6 @@ void DrawStringWithColors(const Surface &out, std::string_view fmt, DrawStringFo const ClxSprite sprite = (*pSPentSpn2Cels)[PentSpn2Spin()]; MaybeWrap(characterPosition, sprite.width(), rightMargin, initialX, lineHeight); ClxDraw(clippedOut, characterPosition + Displacement { 0, lineHeight - BaseLineOffset[size] }, sprite); - } else if (HasAnyOf(flags, UiFlags::TextCursor) && GetAnimationFrame(2, 500) != 0) { - MaybeWrap(characterPosition, 2, rightMargin, initialX, lineHeight); - OptionalClxSpriteList baseFont = LoadFont(size, color, 0); - if (baseFont) - DrawFont(clippedOut, characterPosition, *baseFont, color, '|', outlined); } } diff --git a/Source/engine/render/text_render.hpp b/Source/engine/render/text_render.hpp index 0083854dfac..284c2722e69 100644 --- a/Source/engine/render/text_render.hpp +++ b/Source/engine/render/text_render.hpp @@ -170,9 +170,10 @@ int GetLineHeight(std::string_view text, GameFontTables fontIndex); * @param spacing Additional space to add between characters. * This value may be adjusted if the flag UIS_FIT_SPACING is passed in the flags parameter. * @param lineHeight Allows overriding the default line height, useful for multi-line strings. + * @param cursorPosition If non-negative, draws a blinking cursor after the given byte index. * @return The number of bytes rendered, including characters "drawn" outside the buffer. */ -uint32_t DrawString(const Surface &out, std::string_view text, const Rectangle &rect, UiFlags flags = UiFlags::None, int spacing = 1, int lineHeight = -1); +uint32_t DrawString(const Surface &out, std::string_view text, const Rectangle &rect, UiFlags flags = UiFlags::None, int spacing = 1, int lineHeight = -1, int cursorPosition = -1); /** * @brief Draws a line of text at the given position relative to the origin of the output buffer. diff --git a/Source/utils/utf8.cpp b/Source/utils/utf8.cpp index 250d1d2975d..2c28694ffef 100644 --- a/Source/utils/utf8.cpp +++ b/Source/utils/utf8.cpp @@ -6,26 +6,8 @@ #include -#include "utils/attributes.h" - namespace devilution { -namespace { - -/** Truncates `str` to at most `len` at a code point boundary. */ -std::string_view TruncateUtf8(std::string_view str, std::size_t len) -{ - if (str.size() > len) { - std::size_t truncIndex = len; - while (truncIndex > 0 && IsTrailUtf8CodeUnit(str[truncIndex])) - truncIndex--; - str.remove_suffix(str.size() - truncIndex); - } - return str; -} - -} // namespace - char32_t DecodeFirstUtf8CodePoint(std::string_view input, std::size_t *len) { uint32_t codepoint = 0; @@ -45,6 +27,17 @@ char32_t DecodeFirstUtf8CodePoint(std::string_view input, std::size_t *len) return Utf8DecodeError; } +std::string_view TruncateUtf8(std::string_view str, std::size_t len) +{ + if (str.size() > len) { + std::size_t truncIndex = len; + while (truncIndex > 0 && IsTrailUtf8CodeUnit(str[truncIndex])) + truncIndex--; + str.remove_suffix(str.size() - truncIndex); + } + return str; +} + void CopyUtf8(char *dest, std::string_view source, std::size_t bytes) { source = TruncateUtf8(source, bytes - 1); diff --git a/Source/utils/utf8.hpp b/Source/utils/utf8.hpp index 1656f4113de..1b9d23cab9c 100644 --- a/Source/utils/utf8.hpp +++ b/Source/utils/utf8.hpp @@ -52,6 +52,14 @@ inline bool IsTrailUtf8CodeUnit(char x) return static_cast(x) < static_cast('\xC0'); } +/** + * @brief Returns the number of code units for a code point starting at *src; + */ +inline size_t Utf8CodePointLen(const char *src) +{ + return "\1\1\1\1\1\1\1\1\1\1\1\1\2\2\3\4"[static_cast(*src) >> 4]; +} + /** * Returns the start byte index of the last code point in a UTF-8 string. */ @@ -76,4 +84,7 @@ void CopyUtf8(char *dest, std::string_view source, std::size_t bytes); void AppendUtf8(char32_t codepoint, std::string &out); +/** @brief Truncates `str` to at most `len` at a code point boundary. */ +std::string_view TruncateUtf8(std::string_view str, std::size_t len); + } // namespace devilution