diff --git a/CMake/Tests.cmake b/CMake/Tests.cmake index bb37b85b5..95328c02a 100644 --- a/CMake/Tests.cmake +++ b/CMake/Tests.cmake @@ -170,6 +170,16 @@ if(DEVILUTIONX_SCREENSHOT_FORMAT STREQUAL DEVILUTIONX_SCREENSHOT_FORMAT_PNG AND kerning_fit_spacing__align_right.png vertical_overflow.png vertical_overflow-colors.png + cursor-start.png + cursor-middle.png + cursor-end.png + multiline_cursor-end_first_line.png + multiline_cursor-start_second_line.png + multiline_cursor-middle_second_line.png + multiline_cursor-end_second_line.png + highlight-partial.png + highlight-full.png + multiline_highlight.png SRC_PREFIX test/fixtures/text_render_integration_test/ OUTPUT_DIR "${DEVILUTIONX_TEST_FIXTURES_OUTPUT_DIRECTORY}/text_render_integration_test" OUTPUT_VARIABLE _text_render_integration_test_fixtures diff --git a/Source/engine/render/text_render.cpp b/Source/engine/render/text_render.cpp index c2abebd31..dabb93a76 100644 --- a/Source/engine/render/text_render.cpp +++ b/Source/engine/render/text_render.cpp @@ -437,6 +437,88 @@ int GetLineStartX(UiFlags flags, const Rectangle &rect, int lineWidth) return rect.position.x; } +void DrawLine( + const Surface &out, + std::string_view text, + Point characterPosition, + Rectangle rect, + UiFlags flags, + int curSpacing, + GameFontTables size, + text_color color, + bool outline, + const TextRenderOptions &opts, + size_t lineStartPos, + int totalWidth) +{ + CurrentFont currentFont; + + std::string_view lineCopy = text; + + size_t currentPos = 0; + + size_t cpLen; + + const auto maybeDrawCursor = [&]() { + const auto byteIndex = static_cast(lineStartPos + currentPos); + Point position = characterPosition; + if (opts.cursorPosition == byteIndex) { + if (GetAnimationFrame(2, 500) != 0 || opts.cursorStatic) { + FontStack baseFont = LoadFont(size, color, 0); + if (baseFont.has_value()) { + DrawFont(out, position, baseFont.glyph('|'), color, outline); + } + } + if (opts.renderedCursorPositionOut != nullptr) { + *opts.renderedCursorPositionOut = position; + } + } + }; + + // Start from the beginning of the line + characterPosition.x = GetLineStartX(flags, rect, totalWidth); + + while (!lineCopy.empty()) { + char32_t c = DecodeFirstUtf8CodePoint(lineCopy, &cpLen); + if (c == Utf8DecodeError) break; + if (c == ZWSP) { + lineCopy.remove_prefix(cpLen); + continue; + } + + if (!currentFont.load(size, color, c)) { + c = U'?'; + if (!currentFont.load(size, color, c)) { + app_fatal("Missing fonts"); + } + } + const uint8_t frame = c & 0xFF; + + const ClxSprite glyph = currentFont.glyph(frame); + const int charWidth = glyph.width(); + + const auto byteIndex = static_cast(lineStartPos + currentPos); + + // Draw highlight + if (byteIndex >= opts.highlightRange.begin && byteIndex < opts.highlightRange.end) { + const bool lastInRange = static_cast(byteIndex + cpLen) == opts.highlightRange.end; + FillRect(out, characterPosition.x, characterPosition.y, + glyph.width() + (lastInRange ? 0 : curSpacing), glyph.height(), + opts.highlightColor); + } + + DrawFont(out, characterPosition, glyph, color, outline); + maybeDrawCursor(); + + // Move to the next position + characterPosition.x += charWidth + curSpacing; + currentPos += cpLen; + lineCopy.remove_prefix(cpLen); + } + assert(currentPos == text.size()); + maybeDrawCursor(); +} + uint32_t DoDrawString(const Surface &out, std::string_view text, Rectangle rect, Point &characterPosition, int lineWidth, int charactersInLine, int rightMargin, int bottomMargin, GameFontTables size, text_color color, bool outline, TextRenderOptions &opts) @@ -455,19 +537,26 @@ uint32_t DoDrawString(const Surface &out, std::string_view text, Rectangle rect, std::string_view remaining = text; size_t cpLen; - const auto maybeDrawCursor = [&]() { - if (opts.cursorPosition == static_cast(text.size() - remaining.size())) { - Point position = characterPosition; - MaybeWrap(position, 2, rightMargin, position.x, opts.lineHeight); - if (GetAnimationFrame(2, 500) != 0) { - FontStack baseFont = LoadFont(size, color, 0); - if (baseFont.has_value()) { - DrawFont(out, position, baseFont.glyph('|'), color, outline); - } - } - if (opts.renderedCursorPositionOut != nullptr) { - *opts.renderedCursorPositionOut = position; - } + // Track line boundaries + size_t lineStartPos = 0; + size_t lineEndPos = 0; + + const auto drawLine = [&]() { + std::string_view lineText = text.substr(lineStartPos, lineEndPos - lineStartPos); + if (!lineText.empty()) { + DrawLine( + out, + lineText, + characterPosition, + rect, + opts.flags, + curSpacing, + size, + color, + outline, + opts, + lineStartPos, + lineWidth); } }; @@ -487,8 +576,10 @@ uint32_t DoDrawString(const Surface &out, std::string_view text, Rectangle rect, const uint8_t frame = next & 0xFF; const uint16_t width = currentFont.glyph(frame).width(); if (next == U'\n' || characterPosition.x + width > rightMargin) { - if (next == '\n') - maybeDrawCursor(); + lineEndPos = text.size() - remaining.size(); + + drawLine(); + const int nextLineY = characterPosition.y + opts.lineHeight; if (nextLineY >= bottomMargin) break; @@ -506,26 +597,26 @@ uint32_t DoDrawString(const Surface &out, std::string_view text, Rectangle rect, } characterPosition.x = GetLineStartX(opts.flags, rect, lineWidth); + // Start a new line + lineStartPos = next == U'\n' ? (text.size() - remaining.size() + cpLen) : (text.size() - remaining.size()); + lineEndPos = lineStartPos; + if (next == U'\n') continue; } - const ClxSprite glyph = currentFont.glyph(frame); - const auto byteIndex = static_cast(text.size() - remaining.size()); - - // Draw highlight - if (byteIndex >= opts.highlightRange.begin && byteIndex < opts.highlightRange.end) { - const bool lastInRange = static_cast(byteIndex + cpLen) == opts.highlightRange.end; - FillRect(out, characterPosition.x, characterPosition.y, - glyph.width() + (lastInRange ? 0 : curSpacing), glyph.height(), - opts.highlightColor); - } + // Update end position as we add characters + lineEndPos = text.size() - remaining.size() + cpLen; - DrawFont(out, characterPosition, glyph, color, outline); - maybeDrawCursor(); + // Update position for the next character characterPosition.x += width + curSpacing; } - maybeDrawCursor(); + + // Draw any remaining characters in the last line + if (lineStartPos < lineEndPos) { + drawLine(); + } + return static_cast(remaining.data() - text.data()); } diff --git a/Source/engine/render/text_render.hpp b/Source/engine/render/text_render.hpp index 957d137d8..47ccf7f64 100644 --- a/Source/engine/render/text_render.hpp +++ b/Source/engine/render/text_render.hpp @@ -155,6 +155,8 @@ struct TextRenderOptions { /** @brief If a cursor is rendered, the surface coordinates are saved here. */ std::optional *renderedCursorPositionOut = nullptr; + + bool cursorStatic = false; }; /** diff --git a/test/fixtures/text_render_integration_test/cursor-end.png b/test/fixtures/text_render_integration_test/cursor-end.png new file mode 100644 index 000000000..7de350d04 Binary files /dev/null and b/test/fixtures/text_render_integration_test/cursor-end.png differ diff --git a/test/fixtures/text_render_integration_test/cursor-middle.png b/test/fixtures/text_render_integration_test/cursor-middle.png new file mode 100644 index 000000000..fed7cf064 Binary files /dev/null and b/test/fixtures/text_render_integration_test/cursor-middle.png differ diff --git a/test/fixtures/text_render_integration_test/cursor-start.png b/test/fixtures/text_render_integration_test/cursor-start.png new file mode 100644 index 000000000..8d60dca8d Binary files /dev/null and b/test/fixtures/text_render_integration_test/cursor-start.png differ diff --git a/test/fixtures/text_render_integration_test/highlight-full.png b/test/fixtures/text_render_integration_test/highlight-full.png new file mode 100644 index 000000000..6d1e7f95d Binary files /dev/null and b/test/fixtures/text_render_integration_test/highlight-full.png differ diff --git a/test/fixtures/text_render_integration_test/highlight-partial.png b/test/fixtures/text_render_integration_test/highlight-partial.png new file mode 100644 index 000000000..f94036a78 Binary files /dev/null and b/test/fixtures/text_render_integration_test/highlight-partial.png differ diff --git a/test/fixtures/text_render_integration_test/multiline_cursor-end_first_line.png b/test/fixtures/text_render_integration_test/multiline_cursor-end_first_line.png new file mode 100644 index 000000000..295aeaf2a Binary files /dev/null and b/test/fixtures/text_render_integration_test/multiline_cursor-end_first_line.png differ diff --git a/test/fixtures/text_render_integration_test/multiline_cursor-end_second_line.png b/test/fixtures/text_render_integration_test/multiline_cursor-end_second_line.png new file mode 100644 index 000000000..c5fa0bf97 Binary files /dev/null and b/test/fixtures/text_render_integration_test/multiline_cursor-end_second_line.png differ diff --git a/test/fixtures/text_render_integration_test/multiline_cursor-middle_second_line.png b/test/fixtures/text_render_integration_test/multiline_cursor-middle_second_line.png new file mode 100644 index 000000000..c7f681055 Binary files /dev/null and b/test/fixtures/text_render_integration_test/multiline_cursor-middle_second_line.png differ diff --git a/test/fixtures/text_render_integration_test/multiline_cursor-start_second_line.png b/test/fixtures/text_render_integration_test/multiline_cursor-start_second_line.png new file mode 100644 index 000000000..b64aaf48a Binary files /dev/null and b/test/fixtures/text_render_integration_test/multiline_cursor-start_second_line.png differ diff --git a/test/fixtures/text_render_integration_test/multiline_highlight.png b/test/fixtures/text_render_integration_test/multiline_highlight.png new file mode 100644 index 000000000..6dab07894 Binary files /dev/null and b/test/fixtures/text_render_integration_test/multiline_highlight.png differ diff --git a/test/text_render_integration_test.cpp b/test/text_render_integration_test.cpp index 004b0044c..6f4cacbe2 100644 --- a/test/text_render_integration_test.cpp +++ b/test/text_render_integration_test.cpp @@ -211,6 +211,116 @@ const TestFixture Fixtures[] { { "Two", UiFlags::ColorUiSilverDark }, }, }, + TestFixture { + .name = "cursor-start", + .width = 120, + .height = 15, + .fmt = "Hello World", + .opts = { + .flags = UiFlags::ColorUiGold, + .cursorPosition = 0, // Cursor at start + .cursorStatic = true, + }, + }, + TestFixture { + .name = "cursor-middle", + .width = 120, + .height = 15, + .fmt = "Hello World", + .opts = { + .flags = UiFlags::ColorUiGold, + .cursorPosition = 5, // Cursor after "Hello", + .cursorStatic = true, + }, + }, + TestFixture { + .name = "cursor-end", + .width = 120, + .height = 15, + .fmt = "Hello World", + .opts = { + .flags = UiFlags::ColorUiGold, + .cursorPosition = 11, // Cursor at end + .cursorStatic = true, + }, + }, + TestFixture { + .name = "multiline_cursor-end_first_line", + .width = 100, + .height = 50, + .fmt = "First line\nSecond line", + .opts = { + .flags = UiFlags::ColorUiGold, + .cursorPosition = 10, // Cursor at end of first line + .cursorStatic = true, + }, + }, + TestFixture { + .name = "multiline_cursor-start_second_line", + .width = 100, + .height = 50, + .fmt = "First line\nSecond line", + .opts = { + .flags = UiFlags::ColorUiGold, + .cursorPosition = 11, // Cursor at start of second line + .cursorStatic = true, + }, + }, + TestFixture { + .name = "multiline_cursor-middle_second_line", + .width = 100, + .height = 50, + .fmt = "First line\nSecond line", + .opts = { + .flags = UiFlags::ColorUiGold, + .cursorPosition = 14, // Cursor at second line, at the 'o' of "Second" + .cursorStatic = true, + }, + }, + TestFixture { + .name = "multiline_cursor-end_second_line", + .width = 100, + .height = 50, + .fmt = "First line\nSecond line", + .opts = { + .flags = UiFlags::ColorUiGold, + .cursorPosition = 22, // Cursor at start of second line + .cursorStatic = true, + }, + }, + TestFixture { + .name = "highlight-partial", + .width = 120, + .height = 15, + .fmt = "Hello World", + .opts = { + .flags = UiFlags::ColorUiGold, + .highlightRange = { 5, 10 }, // Highlight " Worl" + .highlightColor = PAL8_BLUE, + }, + }, + TestFixture { + .name = "highlight-full", + .width = 120, + .height = 15, + .fmt = "Hello World", + .opts = { + .flags = UiFlags::ColorUiGold, + .highlightRange = { 0, 11 }, // Highlight entire text + .highlightColor = PAL8_BLUE, + }, + }, + TestFixture { + .name = "multiline_highlight", + .width = 70, + .height = 50, + .fmt = "Hello\nWorld", + .opts = { + .flags = UiFlags::ColorUiGold, + .highlightRange = { 3, 8 }, // Highlight "lo\nWo" + .highlightColor = PAL8_BLUE, + }, + }, }; SDLPaletteUniquePtr LoadPalette()