|
|
/** |
|
|
* @file text_render.cpp |
|
|
* |
|
|
* Text rendering. |
|
|
*/ |
|
|
#include "text_render.hpp" |
|
|
|
|
|
#include <algorithm> |
|
|
#include <array> |
|
|
#include <cstddef> |
|
|
#include <cstdint> |
|
|
#include <optional> |
|
|
#include <string_view> |
|
|
#include <utility> |
|
|
#include <variant> |
|
|
|
|
|
#include <ankerl/unordered_dense.h> |
|
|
#include <fmt/core.h> |
|
|
|
|
|
#include "DiabloUI/ui_flags.hpp" |
|
|
#include "engine/clx_sprite.hpp" |
|
|
#include "engine/load_cel.hpp" |
|
|
#include "engine/load_clx.hpp" |
|
|
#include "engine/load_file.hpp" |
|
|
#include "engine/load_pcx.hpp" |
|
|
#include "engine/point.hpp" |
|
|
#include "engine/rectangle.hpp" |
|
|
#include "engine/render/clx_render.hpp" |
|
|
#include "engine/render/primitive_render.hpp" |
|
|
#include "engine/surface.hpp" |
|
|
#include "engine/ticks.hpp" |
|
|
#include "game_mode.hpp" |
|
|
#include "utils/algorithm/container.hpp" |
|
|
#include "utils/is_of.hpp" |
|
|
#include "utils/language.h" |
|
|
#include "utils/log.hpp" |
|
|
#include "utils/str_cat.hpp" |
|
|
#include "utils/utf8.hpp" |
|
|
|
|
|
namespace devilution { |
|
|
|
|
|
OptionalOwnedClxSpriteList pSPentSpn2Cels; |
|
|
|
|
|
namespace { |
|
|
|
|
|
constexpr char32_t ZWSP = U'\u200B'; // Zero-width space |
|
|
|
|
|
struct OwnedFontStack { |
|
|
OptionalOwnedClxSpriteList baseFont; |
|
|
OptionalOwnedClxSpriteList overrideFont; |
|
|
}; |
|
|
|
|
|
struct FontStack { |
|
|
OptionalClxSpriteList baseFont; |
|
|
OptionalClxSpriteList overrideFont; |
|
|
|
|
|
FontStack() = default; |
|
|
|
|
|
explicit FontStack(const OwnedFontStack &owned) |
|
|
{ |
|
|
if (owned.baseFont.has_value()) baseFont.emplace(*owned.baseFont); |
|
|
if (owned.overrideFont.has_value()) overrideFont.emplace(*owned.overrideFont); |
|
|
} |
|
|
|
|
|
[[nodiscard]] bool has_value() const // NOLINT(readability-identifier-naming) |
|
|
{ |
|
|
return baseFont.has_value() || overrideFont.has_value(); |
|
|
} |
|
|
|
|
|
[[nodiscard]] ClxSprite glyph(size_t i) const |
|
|
{ |
|
|
if (overrideFont.has_value()) { |
|
|
ClxSprite overrideGlyph = (*overrideFont)[i]; |
|
|
if (overrideGlyph.width() != 0) return overrideGlyph; |
|
|
} |
|
|
return (*baseFont)[i]; |
|
|
} |
|
|
}; |
|
|
|
|
|
ankerl::unordered_dense::map<uint32_t, OwnedFontStack> Fonts; |
|
|
|
|
|
std::array<int, 6> FontSizes = { 12, 24, 30, 42, 46, 22 }; |
|
|
constexpr std::array<int, 6> LineHeights = { 12, 26, 38, 42, 50, 22 }; |
|
|
constexpr int SmallFontTallLineHeight = 16; |
|
|
std::array<int, 6> BaseLineOffset = { -3, -2, -3, -6, -7, 3 }; |
|
|
|
|
|
std::array<const char *, 19> ColorTranslations = { |
|
|
"fonts\\goldui.trn", |
|
|
"fonts\\grayui.trn", |
|
|
"fonts\\golduis.trn", |
|
|
"fonts\\grayuis.trn", |
|
|
|
|
|
nullptr, // ColorDialogWhite |
|
|
nullptr, // ColorDialogRed |
|
|
"fonts\\yellow.trn", |
|
|
|
|
|
nullptr, |
|
|
"fonts\\black.trn", |
|
|
|
|
|
"fonts\\white.trn", |
|
|
"fonts\\whitegold.trn", |
|
|
"fonts\\red.trn", |
|
|
"fonts\\blue.trn", |
|
|
"fonts\\orange.trn", |
|
|
|
|
|
"fonts\\buttonface.trn", |
|
|
"fonts\\buttonpushed.trn", |
|
|
"fonts\\gamedialogwhite.trn", |
|
|
"fonts\\gamedialogyellow.trn", |
|
|
"fonts\\gamedialogred.trn", |
|
|
}; |
|
|
|
|
|
std::array<std::optional<std::array<uint8_t, 256>>, 19> ColorTranslationsData; |
|
|
|
|
|
text_color GetColorFromFlags(UiFlags flags) |
|
|
{ |
|
|
if (HasAnyOf(flags, UiFlags::ColorWhite)) |
|
|
return ColorWhite; |
|
|
if (HasAnyOf(flags, UiFlags::ColorBlue)) |
|
|
return ColorBlue; |
|
|
if (HasAnyOf(flags, UiFlags::ColorOrange)) |
|
|
return ColorOrange; |
|
|
if (HasAnyOf(flags, UiFlags::ColorRed)) |
|
|
return ColorRed; |
|
|
if (HasAnyOf(flags, UiFlags::ColorBlack)) |
|
|
return ColorBlack; |
|
|
if (HasAnyOf(flags, UiFlags::ColorGold)) |
|
|
return ColorGold; |
|
|
if (HasAnyOf(flags, UiFlags::ColorUiGold)) |
|
|
return ColorUiGold; |
|
|
if (HasAnyOf(flags, UiFlags::ColorUiSilver)) |
|
|
return ColorUiSilver; |
|
|
if (HasAnyOf(flags, UiFlags::ColorUiGoldDark)) |
|
|
return ColorUiGoldDark; |
|
|
if (HasAnyOf(flags, UiFlags::ColorUiSilverDark)) |
|
|
return ColorUiSilverDark; |
|
|
if (HasAnyOf(flags, UiFlags::ColorDialogWhite)) |
|
|
return gbRunGame ? ColorInGameDialogWhite : ColorDialogWhite; |
|
|
if (HasAnyOf(flags, UiFlags::ColorDialogYellow)) |
|
|
return ColorInGameDialogYellow; |
|
|
if (HasAnyOf(flags, UiFlags::ColorDialogRed)) |
|
|
return ColorInGameDialogRed; |
|
|
if (HasAnyOf(flags, UiFlags::ColorYellow)) |
|
|
return ColorYellow; |
|
|
if (HasAnyOf(flags, UiFlags::ColorButtonface)) |
|
|
return ColorButtonface; |
|
|
if (HasAnyOf(flags, UiFlags::ColorButtonpushed)) |
|
|
return ColorButtonpushed; |
|
|
|
|
|
return ColorWhitegold; |
|
|
} |
|
|
|
|
|
uint16_t GetUnicodeRow(char32_t codePoint) |
|
|
{ |
|
|
return static_cast<uint32_t>(codePoint) >> 8; |
|
|
} |
|
|
|
|
|
bool IsCJK(uint16_t row) |
|
|
{ |
|
|
return row >= 0x30 && row <= 0x9f; |
|
|
} |
|
|
|
|
|
bool IsHangul(uint16_t row) |
|
|
{ |
|
|
return row >= 0xac && row <= 0xd7; |
|
|
} |
|
|
|
|
|
bool IsSmallFontTallRow(uint16_t row) |
|
|
{ |
|
|
return IsCJK(row) || IsHangul(row); |
|
|
} |
|
|
|
|
|
void GetFontPath(GameFontTables size, uint16_t row, std::string_view ext, char *out) |
|
|
{ |
|
|
*BufCopy(out, "fonts\\", FontSizes[size], "-", AsHexPad2(row), ext) = '\0'; |
|
|
} |
|
|
|
|
|
void GetFontPath(std::string_view language_code, GameFontTables size, uint16_t row, std::string_view ext, char *out) |
|
|
{ |
|
|
*BufCopy(out, "fonts\\", language_code, "\\", FontSizes[size], "-", AsHexPad2(row), ext) = '\0'; |
|
|
} |
|
|
|
|
|
uint32_t GetFontId(GameFontTables size, uint16_t row) |
|
|
{ |
|
|
return (size << 16) | row; |
|
|
} |
|
|
|
|
|
FontStack LoadFont(GameFontTables size, text_color color, uint16_t row) |
|
|
{ |
|
|
if (ColorTranslations[color] != nullptr && !ColorTranslationsData[color]) { |
|
|
ColorTranslationsData[color].emplace(); |
|
|
LoadFileInMem(ColorTranslations[color], *ColorTranslationsData[color]); |
|
|
} |
|
|
|
|
|
const uint32_t fontId = GetFontId(size, row); |
|
|
auto hotFont = Fonts.find(fontId); |
|
|
if (hotFont != Fonts.end()) { |
|
|
return FontStack(hotFont->second); |
|
|
} |
|
|
|
|
|
OwnedFontStack &font = Fonts[fontId]; |
|
|
char path[32]; |
|
|
|
|
|
// Load language-specific glyphs: |
|
|
const std::string_view languageCode = GetLanguageCode(); |
|
|
const std::string_view lang = languageCode.substr(0, 2); |
|
|
if (lang == "zh" || lang == "ja" || lang == "ko" |
|
|
|| (lang == "tr" && row == 0)) { |
|
|
GetFontPath(languageCode, size, row, ".clx", &path[0]); |
|
|
font.overrideFont = LoadOptionalClx(path); |
|
|
} |
|
|
|
|
|
// Load the base glyphs: |
|
|
GetFontPath(size, row, ".clx", &path[0]); |
|
|
font.baseFont = LoadOptionalClx(path); |
|
|
|
|
|
#ifndef UNPACKED_MPQS |
|
|
if (!font.baseFont.has_value()) { |
|
|
// Could be an old devilutionx.mpq or fonts.mpq with PCX instead of CLX. |
|
|
// |
|
|
// We'll show an error elsewhere (in `CheckArchivesUpToDate`) and we need to load |
|
|
// the font files to display it. |
|
|
char pcxPath[32]; |
|
|
GetFontPath(size, row, "", &pcxPath[0]); |
|
|
font.baseFont = LoadPcxSpriteList(pcxPath, /*numFramesOrFrameHeight=*/256, /*transparentColor=*/1); |
|
|
} |
|
|
#endif |
|
|
|
|
|
if (!font.baseFont.has_value()) { |
|
|
LogError("Error loading font: {}", path); |
|
|
} |
|
|
|
|
|
return FontStack(font); |
|
|
} |
|
|
|
|
|
class CurrentFont { |
|
|
public: |
|
|
FontStack fontStack; |
|
|
|
|
|
[[nodiscard]] ClxSprite glyph(size_t i) const |
|
|
{ |
|
|
return fontStack.glyph(i); |
|
|
} |
|
|
|
|
|
bool load(GameFontTables size, text_color color, char32_t next) |
|
|
{ |
|
|
const uint32_t unicodeRow = GetUnicodeRow(next); |
|
|
if (unicodeRow == currentUnicodeRow_ && hasAttemptedLoad_) { |
|
|
return true; |
|
|
} |
|
|
|
|
|
fontStack = LoadFont(size, color, unicodeRow); |
|
|
hasAttemptedLoad_ = true; |
|
|
currentUnicodeRow_ = unicodeRow; |
|
|
|
|
|
return fontStack.has_value(); |
|
|
} |
|
|
|
|
|
void clear() |
|
|
{ |
|
|
hasAttemptedLoad_ = false; |
|
|
} |
|
|
|
|
|
private: |
|
|
bool hasAttemptedLoad_ = false; |
|
|
uint32_t currentUnicodeRow_ = 0; |
|
|
}; |
|
|
|
|
|
void DrawFont(const Surface &out, Point position, ClxSprite glyph, text_color color, bool outline) |
|
|
{ |
|
|
if (outline) { |
|
|
ClxDrawOutlineSkipColorZero(out, 0, { position.x, position.y + glyph.height() - 1 }, glyph); |
|
|
} |
|
|
if (ColorTranslationsData[color]) { |
|
|
RenderClxSpriteWithTRN(out, glyph, position, ColorTranslationsData[color]->data()); |
|
|
} else { |
|
|
RenderClxSprite(out, glyph, position); |
|
|
} |
|
|
} |
|
|
|
|
|
bool IsFullWidthPunct(char32_t c) |
|
|
{ |
|
|
return IsAnyOf(c, U',', U'、', U'。', U'?', U'!'); |
|
|
} |
|
|
|
|
|
bool IsBreakAllowed(char32_t codepoint, char32_t nextCodepoint) |
|
|
{ |
|
|
return IsFullWidthPunct(codepoint) && !IsFullWidthPunct(nextCodepoint); |
|
|
} |
|
|
|
|
|
std::size_t CountNewlines(std::string_view fmt, const DrawStringFormatArg *args, std::size_t argsLen) |
|
|
{ |
|
|
std::size_t result = c_count(fmt, '\n'); |
|
|
for (std::size_t i = 0; i < argsLen; ++i) { |
|
|
if (std::holds_alternative<std::string_view>(args[i].value())) |
|
|
result += c_count(args[i].GetFormatted(), '\n'); |
|
|
} |
|
|
return result; |
|
|
} |
|
|
|
|
|
class FmtArgParser { |
|
|
public: |
|
|
FmtArgParser(std::string_view fmt, |
|
|
DrawStringFormatArg *args, |
|
|
size_t len, |
|
|
size_t offset = 0) |
|
|
: fmt_(fmt) |
|
|
, args_(args) |
|
|
, len_(len) |
|
|
, next_(offset) |
|
|
{ |
|
|
} |
|
|
|
|
|
std::optional<std::size_t> operator()(std::string_view &rest) |
|
|
{ |
|
|
std::optional<std::size_t> result; |
|
|
if (rest[0] != '{') |
|
|
return result; |
|
|
|
|
|
const std::size_t closingBracePos = rest.find('}', 1); |
|
|
if (closingBracePos == std::string_view::npos) { |
|
|
LogError("Unclosed format argument: {}", fmt_); |
|
|
return result; |
|
|
} |
|
|
|
|
|
std::size_t fmtLen; |
|
|
bool positional; |
|
|
if (closingBracePos == 2 && rest[1] >= '0' && rest[1] <= '9') { |
|
|
result = rest[1] - '0'; |
|
|
fmtLen = 3; |
|
|
positional = true; |
|
|
} else { |
|
|
result = next_++; |
|
|
fmtLen = closingBracePos + 1; |
|
|
positional = false; |
|
|
} |
|
|
if (!result) { |
|
|
LogError("Unsupported format argument: {}", rest); |
|
|
} else if (*result >= len_) { |
|
|
LogError("Not enough format arguments, {} given for: {}", len_, fmt_); |
|
|
result = std::nullopt; |
|
|
} else { |
|
|
if (!args_[*result].HasFormatted()) { |
|
|
const auto fmtStr = positional ? "{}" : std::string_view(rest.data(), fmtLen); |
|
|
args_[*result].SetFormatted(fmt::format(fmt::runtime(fmtStr), std::get<int>(args_[*result].value()))); |
|
|
} |
|
|
rest.remove_prefix(fmtLen); |
|
|
} |
|
|
return result; |
|
|
} |
|
|
|
|
|
size_t offset() const |
|
|
{ |
|
|
return next_; |
|
|
} |
|
|
|
|
|
private: |
|
|
std::string_view fmt_; |
|
|
DrawStringFormatArg *args_; |
|
|
std::size_t len_; |
|
|
std::size_t next_; |
|
|
}; |
|
|
|
|
|
bool ContainsSmallFontTallCodepoints(std::string_view text) |
|
|
{ |
|
|
while (!text.empty()) { |
|
|
const char32_t next = ConsumeFirstUtf8CodePoint(&text); |
|
|
if (next == Utf8DecodeError) |
|
|
break; |
|
|
if (next == ZWSP) |
|
|
continue; |
|
|
if (IsSmallFontTallRow(GetUnicodeRow(next))) |
|
|
return true; |
|
|
} |
|
|
return false; |
|
|
} |
|
|
|
|
|
int GetLineHeight(std::string_view fmt, DrawStringFormatArg *args, std::size_t argsLen, GameFontTables fontIndex) |
|
|
{ |
|
|
constexpr std::array<int, 6> LineHeights = { 12, 26, 38, 42, 50, 22 }; |
|
|
if (fontIndex == GameFont12 && IsSmallFontTall()) { |
|
|
FmtArgParser fmtArgParser { fmt, args, argsLen }; |
|
|
std::string_view rest = fmt; |
|
|
while (!rest.empty()) { |
|
|
const std::optional<std::size_t> fmtArgPos = fmtArgParser(rest); |
|
|
if (fmtArgPos) { |
|
|
if (ContainsSmallFontTallCodepoints(args[*fmtArgPos].GetFormatted())) { |
|
|
return SmallFontTallLineHeight; |
|
|
} |
|
|
continue; |
|
|
} |
|
|
const char32_t cp = ConsumeFirstUtf8CodePoint(&rest); |
|
|
if (cp == Utf8DecodeError) break; |
|
|
if (cp == ZWSP) continue; |
|
|
if (IsSmallFontTallRow(GetUnicodeRow(cp))) return SmallFontTallLineHeight; |
|
|
} |
|
|
} |
|
|
return LineHeights[fontIndex]; |
|
|
} |
|
|
|
|
|
Surface ClipSurface(const Surface &out, Rectangle rect) |
|
|
{ |
|
|
if (rect.size.height == 0) { |
|
|
return out.subregion(0, 0, std::min(rect.position.x + rect.size.width, out.w()), out.h()); |
|
|
} |
|
|
return out.subregion(0, 0, |
|
|
std::min(rect.position.x + rect.size.width, out.w()), |
|
|
std::min(rect.position.y + rect.size.height, out.h())); |
|
|
} |
|
|
|
|
|
int AdjustSpacingToFitHorizontally(int &lineWidth, int maxSpacing, int charactersInLine, int availableWidth) |
|
|
{ |
|
|
if (lineWidth <= availableWidth || charactersInLine < 2) |
|
|
return maxSpacing; |
|
|
|
|
|
const int overhang = lineWidth - availableWidth; |
|
|
const int spacingRedux = (overhang + charactersInLine - 2) / (charactersInLine - 1); |
|
|
lineWidth -= spacingRedux * (charactersInLine - 1); |
|
|
return maxSpacing - spacingRedux; |
|
|
} |
|
|
|
|
|
void MaybeWrap(Point &characterPosition, int characterWidth, int rightMargin, int initialX, int lineHeight) |
|
|
{ |
|
|
if (characterPosition.x + characterWidth > rightMargin) { |
|
|
characterPosition.x = initialX; |
|
|
characterPosition.y += lineHeight; |
|
|
} |
|
|
} |
|
|
|
|
|
int GetLineStartX(UiFlags flags, const Rectangle &rect, int lineWidth) |
|
|
{ |
|
|
if (HasAnyOf(flags, UiFlags::AlignCenter)) { |
|
|
return std::max(rect.position.x, rect.position.x + (rect.size.width - lineWidth) / 2); |
|
|
} |
|
|
if (HasAnyOf(flags, UiFlags::AlignRight)) |
|
|
return rect.position.x + rect.size.width - 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<int>(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) { |
|
|
currentPos += cpLen; |
|
|
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<int>(lineStartPos + currentPos); |
|
|
|
|
|
// Draw highlight |
|
|
if (byteIndex >= opts.highlightRange.begin && byteIndex < opts.highlightRange.end) { |
|
|
const bool lastInRange = static_cast<int>(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) |
|
|
{ |
|
|
CurrentFont currentFont; |
|
|
int curSpacing = opts.spacing; |
|
|
if (HasAnyOf(opts.flags, UiFlags::KerningFitSpacing)) { |
|
|
curSpacing = AdjustSpacingToFitHorizontally(lineWidth, opts.spacing, charactersInLine, rect.size.width); |
|
|
if (curSpacing != opts.spacing && HasAnyOf(opts.flags, UiFlags::AlignCenter | UiFlags::AlignRight)) { |
|
|
const int adjustedLineWidth = GetLineWidth(text, size, curSpacing, &charactersInLine); |
|
|
characterPosition.x = GetLineStartX(opts.flags, rect, adjustedLineWidth); |
|
|
} |
|
|
} |
|
|
|
|
|
char32_t next; |
|
|
std::string_view remaining = text; |
|
|
size_t cpLen; |
|
|
|
|
|
// 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); |
|
|
} |
|
|
}; |
|
|
|
|
|
for (; !remaining.empty() && remaining[0] != '\0' |
|
|
&& (next = DecodeFirstUtf8CodePoint(remaining, &cpLen)) != Utf8DecodeError; |
|
|
remaining.remove_prefix(cpLen)) { |
|
|
if (next == ZWSP) |
|
|
continue; |
|
|
|
|
|
if (!currentFont.load(size, color, next)) { |
|
|
next = U'?'; |
|
|
if (!currentFont.load(size, color, next)) { |
|
|
app_fatal("Missing fonts"); |
|
|
} |
|
|
} |
|
|
|
|
|
const uint8_t frame = next & 0xFF; |
|
|
const uint16_t width = currentFont.glyph(frame).width(); |
|
|
if (next == U'\n' || characterPosition.x + width > rightMargin) { |
|
|
lineEndPos = text.size() - remaining.size(); |
|
|
|
|
|
drawLine(); |
|
|
|
|
|
const int nextLineY = characterPosition.y + opts.lineHeight; |
|
|
if (nextLineY >= bottomMargin) |
|
|
break; |
|
|
characterPosition.y = nextLineY; |
|
|
|
|
|
if (HasAnyOf(opts.flags, UiFlags::KerningFitSpacing)) { |
|
|
int nextLineWidth = GetLineWidth(remaining.substr(cpLen), size, opts.spacing, &charactersInLine); |
|
|
curSpacing = AdjustSpacingToFitHorizontally(nextLineWidth, opts.spacing, charactersInLine, rect.size.width); |
|
|
} |
|
|
|
|
|
if (HasAnyOf(opts.flags, UiFlags::AlignCenter | UiFlags::AlignRight)) { |
|
|
lineWidth = width; |
|
|
if (remaining.size() > cpLen) |
|
|
lineWidth += curSpacing + GetLineWidth(remaining.substr(cpLen), size, curSpacing); |
|
|
} |
|
|
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; |
|
|
} |
|
|
|
|
|
// Update end position as we add characters |
|
|
lineEndPos = text.size() - remaining.size() + cpLen; |
|
|
|
|
|
// Update position for the next character |
|
|
characterPosition.x += width + curSpacing; |
|
|
} |
|
|
|
|
|
// Draw any remaining characters in the last line |
|
|
if (lineStartPos < lineEndPos) { |
|
|
drawLine(); |
|
|
} |
|
|
|
|
|
return static_cast<uint32_t>(remaining.data() - text.data()); |
|
|
} |
|
|
|
|
|
} // namespace |
|
|
|
|
|
void LoadSmallSelectionSpinner() |
|
|
{ |
|
|
pSPentSpn2Cels = LoadCel("data\\pentspn2", 12); |
|
|
} |
|
|
|
|
|
void UnloadFonts() |
|
|
{ |
|
|
Fonts.clear(); |
|
|
} |
|
|
|
|
|
int GetLineWidth(std::string_view text, GameFontTables size, int spacing, int *charactersInLine) |
|
|
{ |
|
|
int lineWidth = 0; |
|
|
CurrentFont currentFont; |
|
|
uint32_t codepoints = 0; |
|
|
char32_t next; |
|
|
while (!text.empty()) { |
|
|
next = ConsumeFirstUtf8CodePoint(&text); |
|
|
if (next == Utf8DecodeError) |
|
|
break; |
|
|
if (next == ZWSP) |
|
|
continue; |
|
|
|
|
|
if (next == U'\n') |
|
|
break; |
|
|
|
|
|
if (!currentFont.load(size, text_color::ColorDialogWhite, next)) { |
|
|
next = U'?'; |
|
|
if (!currentFont.load(size, text_color::ColorDialogWhite, next)) { |
|
|
app_fatal("Missing fonts"); |
|
|
} |
|
|
} |
|
|
|
|
|
const uint8_t frame = next & 0xFF; |
|
|
lineWidth += currentFont.glyph(frame).width() + spacing; |
|
|
++codepoints; |
|
|
} |
|
|
if (charactersInLine != nullptr) |
|
|
*charactersInLine = codepoints; |
|
|
|
|
|
return lineWidth != 0 ? (lineWidth - spacing) : 0; |
|
|
} |
|
|
|
|
|
bool IsConsumed(std::string_view s) { return s.empty() || s[0] == '\0'; }; |
|
|
|
|
|
int GetLineWidth(std::string_view fmt, DrawStringFormatArg *args, std::size_t argsLen, size_t argsOffset, GameFontTables size, int spacing, int *charactersInLine, |
|
|
std::optional<size_t> firstArgOffset) |
|
|
{ |
|
|
int lineWidth = 0; |
|
|
CurrentFont currentFont; |
|
|
|
|
|
uint32_t codepoints = 0; |
|
|
char32_t prev = U'\0'; |
|
|
char32_t next; |
|
|
std::string_view remaining = fmt; |
|
|
FmtArgParser fmtArgParser { fmt, args, argsLen, argsOffset }; |
|
|
size_t cpLen; |
|
|
|
|
|
// The current formatted argument value being processed. |
|
|
std::string_view curFormatted; |
|
|
|
|
|
// The string that we're currently processing: either `remaining` or `curFormatted`. |
|
|
std::string_view *str; |
|
|
|
|
|
if (firstArgOffset.has_value()) { |
|
|
curFormatted = args[argsOffset - 1].GetFormatted().substr(*firstArgOffset); |
|
|
} |
|
|
|
|
|
for (; !(IsConsumed(curFormatted) && IsConsumed(remaining)); |
|
|
str->remove_prefix(cpLen), prev = next) { |
|
|
const bool isProcessingFormatArgValue = !IsConsumed(curFormatted); |
|
|
str = isProcessingFormatArgValue ? &curFormatted : &remaining; |
|
|
next = DecodeFirstUtf8CodePoint(*str, &cpLen); |
|
|
if (next == Utf8DecodeError) break; |
|
|
|
|
|
// {{ and }} escapes in fmt. |
|
|
if (!isProcessingFormatArgValue && (prev == U'{' || prev == U'}') && prev == next) continue; |
|
|
// ZWSP are line-breaking opportunities that can otherwise be skipped for rendering as they have 0-width. |
|
|
if (next == ZWSP) continue; |
|
|
if (next == U'\n') break; |
|
|
|
|
|
if (!isProcessingFormatArgValue) { |
|
|
const std::optional<std::size_t> fmtArgPos = fmtArgParser(*str); |
|
|
if (fmtArgPos.has_value()) { |
|
|
// `fmtArgParser` has already consumed `*str`. Ensure the loop doesn't consume any more. |
|
|
cpLen = 0; |
|
|
// The loop assigns `prev = next`. |
|
|
// We reset it to U'\0' to ensure that {{ and }} escapes are not processed accross |
|
|
// the boundary of the format string and a formatted value. |
|
|
next = U'\0'; |
|
|
currentFont.clear(); |
|
|
const DrawStringFormatArg &arg = args[*fmtArgPos]; |
|
|
curFormatted = arg.GetFormatted(); |
|
|
continue; |
|
|
} |
|
|
} |
|
|
|
|
|
if (!currentFont.load(size, text_color::ColorDialogWhite, next)) { |
|
|
next = U'?'; |
|
|
if (!currentFont.load(size, text_color::ColorDialogWhite, next)) { |
|
|
app_fatal("Missing fonts"); |
|
|
} |
|
|
} |
|
|
|
|
|
const uint8_t frame = next & 0xFF; |
|
|
lineWidth += currentFont.glyph(frame).width() + spacing; |
|
|
++codepoints; |
|
|
} |
|
|
if (charactersInLine != nullptr) |
|
|
*charactersInLine = codepoints; |
|
|
|
|
|
return lineWidth != 0 ? (lineWidth - spacing) : 0; |
|
|
} |
|
|
|
|
|
int GetLineHeight(std::string_view text, GameFontTables fontIndex) |
|
|
{ |
|
|
if (fontIndex == GameFont12 && IsSmallFontTall() && ContainsSmallFontTallCodepoints(text)) { |
|
|
return SmallFontTallLineHeight; |
|
|
} |
|
|
return LineHeights[fontIndex]; |
|
|
} |
|
|
|
|
|
std::string WordWrapString(std::string_view text, unsigned width, GameFontTables size, int spacing) |
|
|
{ |
|
|
std::string output; |
|
|
if (text.empty() || text[0] == '\0') |
|
|
return output; |
|
|
|
|
|
output.reserve(text.size()); |
|
|
const char *begin = text.data(); |
|
|
const char *processedEnd = text.data(); |
|
|
std::string_view::size_type lastBreakablePos = std::string_view::npos; |
|
|
std::size_t lastBreakableLen = 0; |
|
|
unsigned lineWidth = 0; |
|
|
CurrentFont currentFont; |
|
|
|
|
|
char32_t codepoint = U'\0'; // the current codepoint |
|
|
char32_t nextCodepoint; // the next codepoint |
|
|
std::size_t nextCodepointLen; |
|
|
std::string_view remaining = text; |
|
|
nextCodepoint = DecodeFirstUtf8CodePoint(remaining, &nextCodepointLen); |
|
|
do { |
|
|
codepoint = nextCodepoint; |
|
|
const std::size_t codepointLen = nextCodepointLen; |
|
|
if (codepoint == Utf8DecodeError) |
|
|
break; |
|
|
remaining.remove_prefix(codepointLen); |
|
|
nextCodepoint = !remaining.empty() ? DecodeFirstUtf8CodePoint(remaining, &nextCodepointLen) : U'\0'; |
|
|
|
|
|
if (codepoint == U'\n') { // Existing line break, scan next line |
|
|
lastBreakablePos = std::string_view::npos; |
|
|
lineWidth = 0; |
|
|
output.append(processedEnd, remaining.data()); |
|
|
processedEnd = remaining.data(); |
|
|
continue; |
|
|
} |
|
|
|
|
|
if (codepoint != ZWSP) { |
|
|
const uint8_t frame = codepoint & 0xFF; |
|
|
if (!currentFont.load(size, text_color::ColorDialogWhite, codepoint)) { |
|
|
codepoint = U'?'; |
|
|
if (!currentFont.load(size, text_color::ColorDialogWhite, codepoint)) { |
|
|
app_fatal("Missing fonts"); |
|
|
} |
|
|
} |
|
|
|
|
|
lineWidth += currentFont.glyph(frame).width() + spacing; |
|
|
} |
|
|
|
|
|
if (IsBreakableWhitespace(codepoint)) { |
|
|
lastBreakablePos = remaining.data() - begin - codepointLen; |
|
|
lastBreakableLen = codepointLen; |
|
|
continue; |
|
|
} |
|
|
|
|
|
if (lineWidth - spacing <= width) { |
|
|
if (IsBreakAllowed(codepoint, nextCodepoint)) { |
|
|
lastBreakablePos = remaining.data() - begin; |
|
|
lastBreakableLen = 0; |
|
|
} |
|
|
|
|
|
continue; // String is still within the limit, continue to the next symbol |
|
|
} |
|
|
|
|
|
if (lastBreakablePos == std::string_view::npos) { // Single word longer than width |
|
|
lastBreakablePos = remaining.data() - begin - codepointLen; |
|
|
lastBreakableLen = 0; |
|
|
} |
|
|
|
|
|
// Break line and continue to next line |
|
|
const char *end = &text[lastBreakablePos]; |
|
|
output.append(processedEnd, end); |
|
|
output += '\n'; |
|
|
|
|
|
// Restart from the beginning of the new line. |
|
|
remaining = text.substr(lastBreakablePos + lastBreakableLen); |
|
|
processedEnd = remaining.data(); |
|
|
lastBreakablePos = std::string_view::npos; |
|
|
lineWidth = 0; |
|
|
nextCodepoint = !remaining.empty() ? DecodeFirstUtf8CodePoint(remaining, &nextCodepointLen) : U'\0'; |
|
|
} while (!remaining.empty() && remaining[0] != '\0'); |
|
|
output.append(processedEnd, remaining.data()); |
|
|
return output; |
|
|
} |
|
|
|
|
|
/** |
|
|
* @todo replace Rectangle with cropped Surface |
|
|
*/ |
|
|
uint32_t DrawString(const Surface &out, std::string_view text, const Rectangle &rect, TextRenderOptions opts) |
|
|
{ |
|
|
const GameFontTables size = GetFontSizeFromUiFlags(opts.flags); |
|
|
const text_color color = GetColorFromFlags(opts.flags); |
|
|
|
|
|
int charactersInLine = 0; |
|
|
int lineWidth = 0; |
|
|
if (HasAnyOf(opts.flags, (UiFlags::AlignCenter | UiFlags::AlignRight | UiFlags::KerningFitSpacing))) |
|
|
lineWidth = GetLineWidth(text, size, opts.spacing, &charactersInLine); |
|
|
|
|
|
Point characterPosition { GetLineStartX(opts.flags, rect, lineWidth), rect.position.y }; |
|
|
const int initialX = characterPosition.x; |
|
|
|
|
|
const int rightMargin = rect.position.x + rect.size.width; |
|
|
const int bottomMargin = rect.size.height != 0 ? std::min(rect.position.y + rect.size.height + BaseLineOffset[size], out.h()) : out.h(); |
|
|
|
|
|
if (opts.lineHeight == -1) |
|
|
opts.lineHeight = GetLineHeight(text, size); |
|
|
|
|
|
if (HasAnyOf(opts.flags, UiFlags::VerticalCenter)) { |
|
|
const int textHeight = static_cast<int>((c_count(text, '\n') + 1) * opts.lineHeight); |
|
|
characterPosition.y += std::max(0, (rect.size.height - textHeight) / 2); |
|
|
} |
|
|
|
|
|
characterPosition.y += BaseLineOffset[size]; |
|
|
|
|
|
const bool outlined = HasAnyOf(opts.flags, UiFlags::Outlined); |
|
|
|
|
|
const Surface clippedOut = ClipSurface(out, rect); |
|
|
|
|
|
// Only draw the PentaCursor if the cursor is not at the end. |
|
|
if (HasAnyOf(opts.flags, UiFlags::PentaCursor) && static_cast<size_t>(opts.cursorPosition) == text.size()) { |
|
|
opts.cursorPosition = -1; |
|
|
} |
|
|
|
|
|
const uint32_t bytesDrawn = DoDrawString(clippedOut, text, rect, characterPosition, |
|
|
lineWidth, charactersInLine, rightMargin, bottomMargin, size, color, outlined, opts); |
|
|
|
|
|
if (HasAnyOf(opts.flags, UiFlags::PentaCursor)) { |
|
|
const ClxSprite sprite = (*pSPentSpn2Cels)[PentSpn2Spin()]; |
|
|
MaybeWrap(characterPosition, sprite.width(), rightMargin, initialX, opts.lineHeight); |
|
|
ClxDraw(clippedOut, characterPosition + Displacement { 0, opts.lineHeight - BaseLineOffset[size] }, sprite); |
|
|
} |
|
|
|
|
|
return bytesDrawn; |
|
|
} |
|
|
|
|
|
void DrawStringWithColors(const Surface &out, std::string_view fmt, DrawStringFormatArg *args, std::size_t argsLen, const Rectangle &rect, TextRenderOptions opts) |
|
|
{ |
|
|
const GameFontTables size = GetFontSizeFromUiFlags(opts.flags); |
|
|
const text_color color = GetColorFromFlags(opts.flags); |
|
|
|
|
|
int charactersInLine = 0; |
|
|
int lineWidth = 0; |
|
|
if (HasAnyOf(opts.flags, (UiFlags::AlignCenter | UiFlags::AlignRight | UiFlags::KerningFitSpacing))) |
|
|
lineWidth = GetLineWidth(fmt, args, argsLen, 0, size, opts.spacing, &charactersInLine); |
|
|
|
|
|
Point characterPosition { GetLineStartX(opts.flags, rect, lineWidth), rect.position.y }; |
|
|
const int initialX = characterPosition.x; |
|
|
|
|
|
const int rightMargin = rect.position.x + rect.size.width; |
|
|
const int bottomMargin = rect.size.height != 0 ? std::min(rect.position.y + rect.size.height + BaseLineOffset[size], out.h()) : out.h(); |
|
|
|
|
|
if (opts.lineHeight == -1) |
|
|
opts.lineHeight = GetLineHeight(fmt, args, argsLen, size); |
|
|
|
|
|
if (HasAnyOf(opts.flags, UiFlags::VerticalCenter)) { |
|
|
const int textHeight = static_cast<int>((CountNewlines(fmt, args, argsLen) + 1) * opts.lineHeight); |
|
|
characterPosition.y += std::max(0, (rect.size.height - textHeight) / 2); |
|
|
} |
|
|
|
|
|
characterPosition.y += BaseLineOffset[size]; |
|
|
|
|
|
const bool outlined = HasAnyOf(opts.flags, UiFlags::Outlined); |
|
|
|
|
|
const Surface clippedOut = ClipSurface(out, rect); |
|
|
|
|
|
CurrentFont currentFont; |
|
|
const int originalSpacing = opts.spacing; |
|
|
if (HasAnyOf(opts.flags, UiFlags::KerningFitSpacing)) { |
|
|
opts.spacing = AdjustSpacingToFitHorizontally(lineWidth, originalSpacing, charactersInLine, rect.size.width); |
|
|
if (opts.spacing != originalSpacing && HasAnyOf(opts.flags, UiFlags::AlignCenter | UiFlags::AlignRight)) { |
|
|
const int adjustedLineWidth = GetLineWidth(fmt, args, argsLen, 0, size, opts.spacing, &charactersInLine); |
|
|
characterPosition.x = GetLineStartX(opts.flags, rect, adjustedLineWidth); |
|
|
} |
|
|
} |
|
|
|
|
|
char32_t prev = U'\0'; |
|
|
char32_t next; |
|
|
std::string_view remaining = fmt; |
|
|
FmtArgParser fmtArgParser { fmt, args, argsLen }; |
|
|
size_t cpLen; |
|
|
|
|
|
// The current formatted argument value being processed. |
|
|
std::string_view curFormatted; |
|
|
text_color curFormattedColor; |
|
|
|
|
|
// The string that we're currently processing: either `remaining` or `curFormatted`. |
|
|
std::string_view *str; |
|
|
|
|
|
for (; !(IsConsumed(curFormatted) && IsConsumed(remaining)); |
|
|
str->remove_prefix(cpLen), prev = next) { |
|
|
const bool isProcessingFormatArgValue = !IsConsumed(curFormatted); |
|
|
str = isProcessingFormatArgValue ? &curFormatted : &remaining; |
|
|
next = DecodeFirstUtf8CodePoint(*str, &cpLen); |
|
|
if (next == Utf8DecodeError) break; |
|
|
|
|
|
// {{ and }} escapes in fmt. |
|
|
if (!isProcessingFormatArgValue && (prev == U'{' || prev == U'}') && prev == next) continue; |
|
|
// ZWSP are line-breaking opportunities that can otherwise be skipped for rendering as they have 0-width. |
|
|
if (next == ZWSP) continue; |
|
|
|
|
|
if (!isProcessingFormatArgValue) { |
|
|
const std::optional<std::size_t> fmtArgPos = fmtArgParser(*str); |
|
|
if (fmtArgPos.has_value()) { |
|
|
// `fmtArgParser` has already consumed `*str`. Ensure the loop doesn't consume any more. |
|
|
cpLen = 0; |
|
|
// The loop assigns `prev = next`. |
|
|
// We reset it to U'\0' to ensure that {{ and }} escapes are not processed accross |
|
|
// the boundary of the format string and a formatted value. |
|
|
next = U'\0'; |
|
|
currentFont.clear(); |
|
|
const DrawStringFormatArg &arg = args[*fmtArgPos]; |
|
|
curFormatted = arg.GetFormatted(); |
|
|
curFormattedColor = GetColorFromFlags(arg.GetFlags()); |
|
|
continue; |
|
|
} |
|
|
} |
|
|
|
|
|
const text_color curColor = isProcessingFormatArgValue ? curFormattedColor : color; |
|
|
if (!currentFont.load(size, curColor, next)) { |
|
|
next = U'?'; |
|
|
if (!currentFont.load(size, curColor, next)) { |
|
|
app_fatal("Missing fonts"); |
|
|
} |
|
|
} |
|
|
|
|
|
const uint8_t frame = next & 0xFF; |
|
|
const uint16_t width = currentFont.glyph(frame).width(); |
|
|
if (next == U'\n' || characterPosition.x + width > rightMargin) { |
|
|
const int nextLineY = characterPosition.y + opts.lineHeight; |
|
|
if (nextLineY >= bottomMargin) |
|
|
break; |
|
|
characterPosition.y = nextLineY; |
|
|
|
|
|
if (HasAnyOf(opts.flags, UiFlags::KerningFitSpacing)) { |
|
|
int nextLineWidth = isProcessingFormatArgValue |
|
|
? GetLineWidth(remaining, args, argsLen, fmtArgParser.offset(), size, originalSpacing, &charactersInLine, |
|
|
/*firstArgOffset=*/args[fmtArgParser.offset() - 1].GetFormatted().size() - (curFormatted.size() - cpLen)) |
|
|
: GetLineWidth(remaining.substr(cpLen), args, argsLen, fmtArgParser.offset(), size, originalSpacing, &charactersInLine); |
|
|
opts.spacing = AdjustSpacingToFitHorizontally(nextLineWidth, originalSpacing, charactersInLine, rect.size.width); |
|
|
} |
|
|
|
|
|
if (HasAnyOf(opts.flags, UiFlags::AlignCenter | UiFlags::AlignRight)) { |
|
|
lineWidth = width; |
|
|
if (str->size() > cpLen) { |
|
|
lineWidth += opts.spacing |
|
|
+ (isProcessingFormatArgValue |
|
|
? GetLineWidth(remaining, args, argsLen, fmtArgParser.offset(), size, opts.spacing, &charactersInLine, |
|
|
/*firstArgOffset=*/args[fmtArgParser.offset() - 1].GetFormatted().size() - (curFormatted.size() - cpLen)) |
|
|
: GetLineWidth(remaining.substr(cpLen), args, argsLen, fmtArgParser.offset(), size, opts.spacing, &charactersInLine)); |
|
|
} |
|
|
} |
|
|
characterPosition.x = GetLineStartX(opts.flags, rect, lineWidth); |
|
|
|
|
|
if (next == U'\n') |
|
|
continue; |
|
|
} |
|
|
|
|
|
DrawFont(clippedOut, characterPosition, currentFont.glyph(frame), curColor, outlined); |
|
|
characterPosition.x += width + opts.spacing; |
|
|
} |
|
|
|
|
|
if (HasAnyOf(opts.flags, UiFlags::PentaCursor)) { |
|
|
const ClxSprite sprite = (*pSPentSpn2Cels)[PentSpn2Spin()]; |
|
|
MaybeWrap(characterPosition, sprite.width(), rightMargin, initialX, opts.lineHeight); |
|
|
ClxDraw(clippedOut, characterPosition + Displacement { 0, opts.lineHeight - BaseLineOffset[size] }, sprite); |
|
|
} |
|
|
} |
|
|
|
|
|
uint8_t PentSpn2Spin() |
|
|
{ |
|
|
return GetAnimationFrame(8, 50); |
|
|
} |
|
|
|
|
|
bool IsBreakableWhitespace(char32_t c) |
|
|
{ |
|
|
return IsAnyOf(c, U' ', U' ', ZWSP); |
|
|
} |
|
|
|
|
|
} // namespace devilution
|
|
|
|