diff --git a/Common/Data/Text/WrapText.cpp b/Common/Data/Text/WrapText.cpp index c88628acf5..8d661d285e 100644 --- a/Common/Data/Text/WrapText.cpp +++ b/Common/Data/Text/WrapText.cpp @@ -78,12 +78,15 @@ std::string WordWrapper::Wrapped() { bool WordWrapper::WrapBeforeWord() { if (flags_ & FLAG_WRAP_TEXT) { if (x_ + wordWidth_ > maxW_ && !out_.empty()) { - if (IsShy(out_[out_.size() - 1])) { + if (IsShy(lastChar_)) { // Soft hyphen, replace it with a real hyphen since we wrapped at it. // TODO: There's an edge case here where the hyphen might not fit. - out_[out_.size() - 1] = '-'; + out_[out_.size() - 2] = '-'; + out_[out_.size() - 1] = '\n'; + } else { + out_ += "\n"; } - out_ += "\n"; + lastChar_ = '\n'; lastLineStart_ = out_.size(); x_ = 0.0f; forceEarlyWrap_ = false; @@ -91,20 +94,32 @@ bool WordWrapper::WrapBeforeWord() { } } if (flags_ & FLAG_ELLIPSIZE_TEXT) { - if (x_ + wordWidth_ > maxW_) { - if (!out_.empty() && IsSpace(out_[out_.size() - 1])) { - out_[out_.size() - 1] = '.'; - out_ += ".."; - } else { - out_ += "..."; + const bool hasEllipsis = out_.size() > 3 && out_.substr(out_.size() - 3) == "..."; + if (x_ + wordWidth_ > maxW_ && !hasEllipsis) { + AddEllipsis(); + skipNextWord_ = true; + if ((flags_ & FLAG_WRAP_TEXT) == 0) { + scanForNewline_ = true; } - x_ = maxW_; } } return false; } -void WordWrapper::AppendWord(int endIndex, bool addNewline) { +void WordWrapper::AddEllipsis() { + if (!out_.empty() && IsSpaceOrShy(lastChar_)) { + UTF8 utf(out_.c_str(), (int)out_.size()); + utf.bwd(); + out_.resize(utf.byteIndex()); + out_ += "..."; + } else { + out_ += "..."; + } + lastChar_ = '.'; + x_ += ellipsisWidth_; +} + +void WordWrapper::AppendWord(int endIndex, int lastChar, bool addNewline) { int lastWordStartIndex = lastIndex_; if (WrapBeforeWord()) { // Advance to the first non-whitespace UTF-8 character in the following word (if any) to prevent starting the new line with a whitespace @@ -118,24 +133,47 @@ void WordWrapper::AppendWord(int endIndex, bool addNewline) { } } + lastEllipsisIndex_ = -1; + if (skipNextWord_) { + lastIndex_ = endIndex; + return; + } + // This will include the newline. - if (x_ < maxW_) { + if (x_ <= maxW_) { out_.append(str_ + lastWordStartIndex, str_ + endIndex); } else { scanForNewline_ = true; } if (addNewline && (flags_ & FLAG_WRAP_TEXT)) { out_ += "\n"; + lastChar_ = '\n'; lastLineStart_ = out_.size(); scanForNewline_ = false; + x_ = 0.0f; } else { // We may have appended a newline - check. - size_t pos = out_.substr(lastLineStart_).find_last_of("\n"); + size_t pos = out_.find_last_of("\n"); if (pos != out_.npos) { - lastLineStart_ += pos; + lastLineStart_ = pos + 1; + } + + if (lastChar == -1 && !out_.empty()) { + UTF8 utf(out_.c_str(), (int)out_.size()); + utf.bwd(); + lastChar = utf.next(); + } + lastChar_ = lastChar; + + if (lastLineStart_ != out_.size()) { + // To account for kerning around spaces, we recalculate the entire line width. + x_ = MeasureWidth(out_.c_str() + lastLineStart_, out_.size() - lastLineStart_); + } else { + x_ = 0.0f; } } lastIndex_ = endIndex; + wordWidth_ = 0.0f; } void WordWrapper::Wrap() { @@ -164,10 +202,12 @@ void WordWrapper::Wrap() { // Is this a newline character, hard wrapping? if (c == '\n') { + if (skipNextWord_) { + lastIndex_ = beforeIndex; + skipNextWord_ = false; + } // This will include the newline character. - AppendWord(afterIndex, false); - x_ = 0.0f; - wordWidth_ = 0.0f; + AppendWord(afterIndex, c, false); // We wrapped once, so stop forcing. forceEarlyWrap_ = false; scanForNewline_ = false; @@ -183,19 +223,47 @@ void WordWrapper::Wrap() { // Measure the entire word for kerning purposes. May not be 100% perfect. float newWordWidth = MeasureWidth(str_ + lastIndex_, afterIndex - lastIndex_); - // Is this the end of a word (space)? - if (wordWidth_ > 0.0f && IsSpace(c)) { - AppendWord(afterIndex, false); - // To account for kerning around spaces, we recalculate the entire line width. - x_ = MeasureWidth(out_.c_str() + lastLineStart_, out_.size() - lastLineStart_); - wordWidth_ = 0.0f; + // Is this the end of a word (space)? We'll also output up to a soft hyphen. + if (wordWidth_ > 0.0f && IsSpaceOrShy(c)) { + AppendWord(afterIndex, c, false); + skipNextWord_ = false; continue; } + // We're scanning for the next word. + if (skipNextWord_) + continue; + + if ((flags_ & FLAG_ELLIPSIZE_TEXT) != 0 && wordWidth_ > 0.0f && lastEllipsisIndex_ == -1) { + float checkX = x_; + // If we allow wrapping, assume we'll wrap as needed. + if ((flags_ & FLAG_WRAP_TEXT) != 0 && x_ >= maxW_) { + checkX = 0; + } + + // If we can only fit an ellipsis, time to output and skip ahead. + // Ignore x for newWordWidth, because we might wrap. + if (checkX + wordWidth_ + ellipsisWidth_ <= maxW_ && newWordWidth + ellipsisWidth_ > maxW_) { + lastEllipsisIndex_ = beforeIndex; + continue; + } + } + // Can the word fit on a line even all by itself so far? if (wordWidth_ > 0.0f && newWordWidth > maxW_) { - // Nope. Let's drop what's there so far onto its own line. - if (x_ > 0.0f && x_ + wordWidth_ > maxW_ && beforeIndex > lastIndex_) { + // If we had a good place for an ellipsis, let's do that. + if (lastEllipsisIndex_ != -1) { + AppendWord(lastEllipsisIndex_, -1, false); + AddEllipsis(); + skipNextWord_ = true; + if ((flags_ & FLAG_WRAP_TEXT) == 0) { + scanForNewline_ = true; + } + continue; + } + + // Doesn't fit. Let's drop what's there so far onto its own line. + if (x_ > 0.0f && x_ + wordWidth_ > maxW_ && beforeIndex > lastIndex_ && (flags_ & FLAG_WRAP_TEXT) != 0) { // Let's put as many characters as will fit on the previous line. // This word can't fit on one line even, so it's going to be cut into pieces anyway. // Better to avoid huge gaps, in that case. @@ -209,30 +277,22 @@ void WordWrapper::Wrap() { continue; } // Now, add the word so far (without this latest character) and break. - AppendWord(beforeIndex, true); - if (lastLineStart_ != out_.size()) { - x_ = MeasureWidth(out_.c_str() + lastLineStart_, out_.size() - lastLineStart_); - } else { - x_ = 0.0f; - } - wordWidth_ = 0.0f; + AppendWord(beforeIndex, -1, true); forceEarlyWrap_ = false; // The current character will be handled as part of the next word. continue; } if ((flags_ & FLAG_ELLIPSIZE_TEXT) && wordWidth_ > 0.0f && x_ + newWordWidth + ellipsisWidth_ > maxW_) { - if ((flags_ & FLAG_WRAP_TEXT) == 0) { + if ((flags_ & FLAG_WRAP_TEXT) == 0 && x_ + wordWidth_ + ellipsisWidth_ <= maxW_) { // Now, add the word so far (without this latest character) and show the ellipsis. - AppendWord(beforeIndex, true); - if (lastLineStart_ != out_.size()) { - x_ = MeasureWidth(out_.c_str() + lastLineStart_, out_.size() - lastLineStart_); - } else { - x_ = 0.0f; - } - wordWidth_ = 0.0f; + AppendWord(lastEllipsisIndex_ != -1 ? lastEllipsisIndex_ : beforeIndex, -1, false); + AddEllipsis(); forceEarlyWrap_ = false; - // The current character will be handled as part of the next word. + skipNextWord_ = true; + if ((flags_ & FLAG_WRAP_TEXT) == 0) { + scanForNewline_ = true; + } continue; } } @@ -242,12 +302,10 @@ void WordWrapper::Wrap() { // Is this the end of a word via punctuation / CJK? if (wordWidth_ > 0.0f && (IsCJK(c) || IsPunctuation(c) || forceEarlyWrap_)) { // CJK doesn't require spaces, so we treat each letter as its own word. - AppendWord(afterIndex, false); - x_ += wordWidth_; - wordWidth_ = 0.0f; + AppendWord(afterIndex, c, false); } } // Now insert the rest of the string - the last word. - AppendWord((int)len, false); + AppendWord((int)len, 0, false); } diff --git a/Common/Data/Text/WrapText.h b/Common/Data/Text/WrapText.h index 554210e3cf..bcdd19a6fd 100644 --- a/Common/Data/Text/WrapText.h +++ b/Common/Data/Text/WrapText.h @@ -14,12 +14,16 @@ protected: virtual float MeasureWidth(const char *str, size_t bytes) = 0; void Wrap(); bool WrapBeforeWord(); - void AppendWord(int endIndex, bool addNewline); + void AppendWord(int endIndex, int lastChar, bool addNewline); + void AddEllipsis(); static bool IsCJK(uint32_t c); static bool IsPunctuation(uint32_t c); static bool IsSpace(uint32_t c); static bool IsShy(uint32_t c); + static bool IsSpaceOrShy(uint32_t c) { + return IsSpace(c) || IsShy(c); + } const char *const str_; const float maxW_; @@ -28,8 +32,12 @@ protected: // Index of last output / start of current word. int lastIndex_ = 0; + // Ideal place to put an ellipsis if we run out of space. + int lastEllipsisIndex_ = -1; // Index of last line start. size_t lastLineStart_ = 0; + // Last character written to out_. + int lastChar_ = 0; // Position the current word starts at. float x_ = 0.0f; // Most recent width of word since last index. @@ -40,4 +48,6 @@ protected: bool forceEarlyWrap_ = false; // Skip all characters until the next newline. bool scanForNewline_ = false; + // Skip the next word, replaced with ellipsis. + bool skipNextWord_ = false; }; diff --git a/unittest/UnitTest.cpp b/unittest/UnitTest.cpp index b0585bc05b..50611ba241 100644 --- a/unittest/UnitTest.cpp +++ b/unittest/UnitTest.cpp @@ -40,13 +40,15 @@ #include #endif +#include "Common/Data/Text/Parsers.h" +#include "Common/Data/Text/WrapText.h" +#include "Common/Data/Encoding/Utf8.h" +#include "Common/File/Path.h" +#include "Common/Input/InputState.h" +#include "Common/Math/math_util.h" +#include "Common/Render/DrawBuffer.h" #include "Common/System/NativeApp.h" #include "Common/System/System.h" -#include "Common/Input/InputState.h" -#include "Common/File/Path.h" -#include "Common/Math/math_util.h" -#include "Common/Data/Text/Parsers.h" -#include "Common/Data/Encoding/Utf8.h" #include "Common/ArmEmitter.h" #include "Common/BitScan.h" @@ -659,6 +661,85 @@ static bool TestAndroidContentURI() { return true; } +class UnitTestWordWrapper : public WordWrapper { +public: + UnitTestWordWrapper(const char *str, float maxW, int flags) + : WordWrapper(str, maxW, flags) { + } + +protected: + float MeasureWidth(const char *str, size_t bytes) override { + // Simple case for unit testing. + int w = 0; + for (UTF8 utf(str); !utf.end() && utf.byteIndex() < bytes; ) { + uint32_t c = utf.next(); + switch (c) { + case ' ': + case '.': + w += 1; + break; + case 0x00AD: + // No width for soft hyphens. + break; + default: + w += 2; + break; + } + } + + return w; + } +}; + +#define EXPECT_WORDWRAP_EQ_STR(a, l, f, b) if (UnitTestWordWrapper(a, l, f).Wrapped() != b) { printf("%s: Test Fail (%d, %s)\n%s\nvs\n%s\n", __FUNCTION__, l, #f, UnitTestWordWrapper(a, l, f).Wrapped().c_str(), std::string(b).c_str()); return false; } + +static bool TestWrapText() { + // If there's enough space, it shouldn't wrap. This is exactly enough. + EXPECT_WORDWRAP_EQ_STR("Hello", 10, 0, "Hello"); + EXPECT_WORDWRAP_EQ_STR("Hello", 10, FLAG_WRAP_TEXT, "Hello"); + EXPECT_WORDWRAP_EQ_STR("Hello", 10, FLAG_ELLIPSIZE_TEXT, "Hello"); + EXPECT_WORDWRAP_EQ_STR("Hello", 10, FLAG_WRAP_TEXT | FLAG_ELLIPSIZE_TEXT, "Hello"); + + // Try a single word that doesn't fit in the space. + EXPECT_WORDWRAP_EQ_STR("Hello", 6, 0, "Hello"); + EXPECT_WORDWRAP_EQ_STR("Hello", 6, FLAG_WRAP_TEXT, "Hel\nlo"); + EXPECT_WORDWRAP_EQ_STR("Hello", 6, FLAG_ELLIPSIZE_TEXT, "H..."); + EXPECT_WORDWRAP_EQ_STR("Hello", 6, FLAG_WRAP_TEXT | FLAG_ELLIPSIZE_TEXT, "H..."); + + // Now, multiple words. + EXPECT_WORDWRAP_EQ_STR("Hello goodbye", 14, 0, "Hello goodbye"); + EXPECT_WORDWRAP_EQ_STR("Hello goodbye", 14, FLAG_WRAP_TEXT, "Hello \ngoodbye"); + EXPECT_WORDWRAP_EQ_STR("Hello goodbye", 14, FLAG_ELLIPSIZE_TEXT, "Hello..."); + EXPECT_WORDWRAP_EQ_STR("Hello goodbye", 14, FLAG_WRAP_TEXT | FLAG_ELLIPSIZE_TEXT, "Hello \ngoodbye"); + + // Multiple words with something short after... + EXPECT_WORDWRAP_EQ_STR("Hello goodbye yes", 14, 0, "Hello goodbye "); + EXPECT_WORDWRAP_EQ_STR("Hello goodbye yes", 14, FLAG_WRAP_TEXT, "Hello \ngoodbye \nyes"); + EXPECT_WORDWRAP_EQ_STR("Hello goodbye yes", 14, FLAG_ELLIPSIZE_TEXT, "Hello..."); + EXPECT_WORDWRAP_EQ_STR("Hello goodbye yes", 14, FLAG_WRAP_TEXT | FLAG_ELLIPSIZE_TEXT, "Hello \ngoodbye \nyes"); + + // Now, multiple words, but only the first fits. + EXPECT_WORDWRAP_EQ_STR("Hello goodbye", 10, 0, "Hello "); + EXPECT_WORDWRAP_EQ_STR("Hello goodbye", 10, FLAG_WRAP_TEXT, "Hello \ngoodb\nye"); + EXPECT_WORDWRAP_EQ_STR("Hello goodbye", 10, FLAG_ELLIPSIZE_TEXT, "Hel..."); + EXPECT_WORDWRAP_EQ_STR("Hello goodbye", 10, FLAG_WRAP_TEXT | FLAG_ELLIPSIZE_TEXT, "Hello \ngoo..."); + + // How about the shy character? + const std::string shyTestString = StringFromFormat("Very%c%clong", 0xC2, 0xAD); + EXPECT_WORDWRAP_EQ_STR(shyTestString.c_str(), 10, 0, shyTestString); + EXPECT_WORDWRAP_EQ_STR(shyTestString.c_str(), 10, FLAG_WRAP_TEXT, "Very-\nlong"); + EXPECT_WORDWRAP_EQ_STR(shyTestString.c_str(), 10, FLAG_ELLIPSIZE_TEXT, "Very..."); + EXPECT_WORDWRAP_EQ_STR(shyTestString.c_str(), 10, FLAG_WRAP_TEXT | FLAG_ELLIPSIZE_TEXT, "Very-\nlong"); + + // Newlines should not be removed and should influence wrapping. + EXPECT_WORDWRAP_EQ_STR("Hello\ngoodbye yes\nno", 14, 0, "Hello\ngoodbye "); + EXPECT_WORDWRAP_EQ_STR("Hello\ngoodbye yes\nno", 14, FLAG_WRAP_TEXT, "Hello\ngoodbye \nyes\nno"); + EXPECT_WORDWRAP_EQ_STR("Hello\ngoodbye yes\nno", 14, FLAG_ELLIPSIZE_TEXT, "Hello\ngoodb...\nno"); + EXPECT_WORDWRAP_EQ_STR("Hello\ngoodbye yes\nno", 14, FLAG_WRAP_TEXT | FLAG_ELLIPSIZE_TEXT, "Hello\ngoodbye \nyes\nno"); + + return true; +} + typedef bool (*TestFunc)(); struct TestItem { const char *name; @@ -699,6 +780,7 @@ TestItem availableTests[] = { TEST_ITEM(Path), TEST_ITEM(AndroidContentURI), TEST_ITEM(ThreadManager), + TEST_ITEM(WrapText), }; int main(int argc, const char *argv[]) {