/* * The Mana Client * Copyright (C) 2004-2009 The Mana World Development Team * Copyright (C) 2009-2012 The Mana Developers * Copyright (C) 2009 Aethyra Development Team * * This file is part of The Mana Client. * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 2 of the License, or * any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ #include "gui/widgets/browserbox.h" #include "keyboardconfig.h" #include "gui/gui.h" #include "gui/truetypefont.h" #include "gui/widgets/linkhandler.h" #include "resources/itemdb.h" #include "resources/iteminfo.h" #include "resources/theme.h" #include "utils/stringutils.h" #include #include #include #include /** * Check for key replacements in format "###key;" */ static void replaceKeys(std::string &text) { auto keyStart = text.find("###"); while (keyStart != std::string::npos) { const auto keyEnd = text.find(";", keyStart + 3); if (keyEnd == std::string::npos) break; std::string_view key(text.data() + keyStart + 3, keyEnd - keyStart - 3); // Remove "key" prefix if (key.size() > 3 && key.substr(0, 3) == "key") key.remove_prefix(3); const auto keyName = keyboard.getKeyName(key); if (!keyName.empty()) { text.replace(keyStart, keyEnd - keyStart + 1, keyName); keyStart = text.find("###", keyStart + keyName.size()); } else { keyStart = text.find("###", keyEnd + 1); } } } struct LayoutContext { LayoutContext(gcn::Font *font, const Palette &palette); LinePart linePart(int x, std::string text); int y = 0; gcn::Font *font; const int fontHeight; const int minusWidth; const int tildeWidth; int lineHeight; const gcn::Color textColor; const std::optional textOutlineColor; gcn::Color color; std::optional outlineColor; }; inline LayoutContext::LayoutContext(gcn::Font *font, const Palette &palette) : font(font) , fontHeight(font->getHeight()) , minusWidth(font->getWidth("-")) , tildeWidth(font->getWidth("~")) , lineHeight(fontHeight) , textColor(palette.getColor(Theme::TEXT)) , textOutlineColor(palette.getOutlineColor(Theme::TEXT)) , color(textColor) , outlineColor(textOutlineColor) { if (auto *trueTypeFont = dynamic_cast(font)) lineHeight = trueTypeFont->getLineHeight(); } inline LinePart LayoutContext::linePart(int x, std::string text) { return { x, y, color, outlineColor, std::move(text), font }; } BrowserBox::BrowserBox(Mode mode): mMode(mode) { setFocusable(true); addMouseListener(this); } BrowserBox::~BrowserBox() = default; void BrowserBox::addRows(std::string_view rows) { std::string_view::size_type start = 0; std::string_view::size_type end = 0; while (end != std::string::npos) { end = rows.find('\n', start); addRow(rows.substr(start, end - start)); start = end + 1; } } void BrowserBox::addRow(std::string_view row) { TextRow &newRow = mTextRows.emplace_back(); // Use links and user defined colors if (mUseLinksAndUserColors) { // Check for links in format "@@link|Caption@@" auto linkStart = row.find("@@"); while (linkStart != std::string::npos) { const auto linkSep = row.find("|", linkStart); const auto linkEnd = row.find("@@", linkSep); if (linkSep == std::string::npos || linkEnd == std::string::npos) break; BrowserLink &link = newRow.links.emplace_back(); link.link = row.substr(linkStart + 2, linkSep - (linkStart + 2)); link.caption = row.substr(linkSep + 1, linkEnd - (linkSep + 1)); if (link.caption.empty()) { const int id = atoi(link.link.c_str()); if (id) link.caption = itemDb->get(id).name; else link.caption = link.link; } newRow.text += row.substr(0, linkStart); newRow.text += "##<" + link.caption; row = row.substr(linkEnd + 2); if (!row.empty()) { newRow.text += "##>"; } linkStart = row.find("@@"); } newRow.text += row; } // Don't use links and user defined colors else { newRow.text = row; } if (mEnableKeys) replaceKeys(newRow.text); // Layout the newly added row LayoutContext context(getFont(), gui->getTheme()->getPalette(mPalette)); context.y = getHeight(); layoutTextRow(newRow, context); // Auto size mode if (mMode == AUTO_SIZE && newRow.width > getWidth()) setWidth(newRow.width); // Discard older rows when a row limit has been set // (this might invalidate the newRow reference) int removedHeight = 0; while (mMaxRows > 0 && mTextRows.size() > mMaxRows) { removedHeight += mTextRows.front().height; mTextRows.pop_front(); } if (removedHeight > 0) { for (auto &row : mTextRows) { for (auto &part : row.parts) part.y -= removedHeight; for (auto &link : row.links) link.rect.y -= removedHeight; } } setHeight(context.y - removedHeight); } void BrowserBox::clearRows() { mTextRows.clear(); setSize(mMode == AUTO_SIZE ? 0 : getWidth(), 0); mHoveredLink.reset(); maybeRelayoutText(); } void BrowserBox::mousePressed(gcn::MouseEvent &event) { if (!mLinkHandler) return; updateHoveredLink(event.getX(), event.getY()); if (mHoveredLink) { mLinkHandler->handleLink(mHoveredLink->link); gui->setCursorType(Cursor::Pointer); } } void BrowserBox::mouseMoved(gcn::MouseEvent &event) { updateHoveredLink(event.getX(), event.getY()); gui->setCursorType(mHoveredLink ? Cursor::Hand : Cursor::Pointer); event.consume(); // Suppress mouse cursor change by parent } void BrowserBox::mouseExited(gcn::MouseEvent &event) { mHoveredLink.reset(); } void BrowserBox::draw(gcn::Graphics *graphics) { const gcn::ClipRectangle &cr = graphics->getCurrentClipArea(); int yStart = cr.y - cr.yOffset; int yEnd = yStart + cr.height; if (yStart < 0) yStart = 0; if (getWidth() != mLastLayoutWidth) maybeRelayoutText(); if (mHoveredLink) { auto &palette = gui->getTheme()->getPalette(mPalette); auto &link = *mHoveredLink; const gcn::Rectangle &rect = link.rect; if (mHighlightMode & BACKGROUND) { graphics->setColor(palette.getColor(Theme::HIGHLIGHT)); graphics->fillRectangle(rect); } if (mHighlightMode & UNDERLINE) { graphics->setColor(palette.getColor(Theme::HYPERLINK)); graphics->drawLine(rect.x, rect.y + rect.height, rect.x + rect.width, rect.y + rect.height); } } auto g = static_cast(graphics); for (const auto &row : mTextRows) { for (const auto &part : row.parts) { if (part.y + 50 < yStart) continue; if (part.y > yEnd) return; g->drawText(part.text, part.x, part.y, Graphics::LEFT, part.color, part.font, part.outlineColor.has_value() || mOutline, mShadows, part.outlineColor); } } } /** * Relayouts all text rows and returns the new height of the BrowserBox. */ void BrowserBox::relayoutText() { LayoutContext context(getFont(), gui->getTheme()->getPalette(mPalette)); for (auto &row : mTextRows) layoutTextRow(row, context); mLastLayoutWidth = getWidth(); mLayoutTimer.set(33); setHeight(context.y); } /** * Layers out the given \a row of text starting at the given \a context position. * @return the context position for the next row. */ void BrowserBox::layoutTextRow(TextRow &row, LayoutContext &context) { // each line starts with normal font in default color context.font = getFont(); context.color = context.textColor; context.outlineColor = context.textOutlineColor; const int startY = context.y; row.parts.clear(); unsigned linkIndex = 0; bool wrapped = false; int x = 0; // Check for separator lines if (startsWith(row.text, "---")) { for (x = 0; x < getWidth(); x += context.minusWidth - 1) row.parts.push_back(context.linePart(x, "-")); context.y += row.height; row.width = getWidth(); row.height = context.y - startY; return; } auto &palette = gui->getTheme()->getPalette(mPalette); auto prevColor = context.color; auto prevOutlineColor = context.outlineColor; // TODO: Check if we must take texture size limits into account here // TODO: Check if some of the O(n) calls can be removed for (std::string::size_type start = 0, end = std::string::npos; start != std::string::npos; start = end, end = std::string::npos) { // Wrapped line continuation shall be indented if (wrapped) { context.y += context.lineHeight; x = mWrapIndent; wrapped = false; } if (mUseLinksAndUserColors || start == 0) { // Check for color or font change in format "##x", x = [<,>,B,p,0..9] while (row.text.size() > start + 2 && row.text.find("##", start) == start) { const char c = row.text.at(start + 2); start += 3; switch (c) { case '>': context.color = prevColor; context.outlineColor = prevOutlineColor; break; case '<': prevColor = context.color; prevOutlineColor = context.outlineColor; context.color = palette.getColor(Theme::HYPERLINK); context.outlineColor = palette.getOutlineColor(Theme::HYPERLINK); break; case 'B': context.font = boldFont; break; case 'b': context.font = getFont(); break; default: { const auto colorId = Theme::getColorIdForChar(c); if (colorId) { context.color = palette.getColor(*colorId); context.outlineColor = palette.getOutlineColor(*colorId); } else { context.color = context.textColor; context.outlineColor = context.textOutlineColor; } break; } } // Update the position of the links if (c == '<' && linkIndex < row.links.size()) { auto &link = row.links[linkIndex]; link.rect.x = x; link.rect.y = context.y; link.rect.width = context.font->getWidth(link.caption) + 1; link.rect.height = context.fontHeight - 1; linkIndex++; } } } if (start >= row.text.length()) break; // "Tokenize" the string at control sequences if (mUseLinksAndUserColors) end = row.text.find("##", start + 1); std::string::size_type len = end == std::string::npos ? end : end - start; std::string part = row.text.substr(start, len); int partWidth = context.font->getWidth(part); // Auto wrap mode if (mMode == AUTO_WRAP && getWidth() > 0 && partWidth > 0 && (x + partWidth) > getWidth()) { bool forced = false; /* FIXME: This code layout makes it easy to crash remote clients by talking garbage. Forged long utf-8 characters will cause either a buffer underflow in substr or an infinite loop in the main loop. */ do { if (!forced) end = row.text.rfind(' ', end); // Check if we have to (stupidly) force-wrap if (end == std::string::npos || end <= start) { forced = true; end = row.text.size(); x += context.tildeWidth; // Account for the wrap-notifier continue; } // Skip to the start of the current character while ((row.text[end] & 192) == 128) end--; end--; // And then to the last byte of the previous one part = row.text.substr(start, end - start + 1); partWidth = context.font->getWidth(part); } while (end > start && partWidth > 0 && (x + partWidth) > getWidth()); if (forced) { x -= context.tildeWidth; // Remove the wrap-notifier accounting row.parts.push_back(LinePart { getWidth() - context.tildeWidth, context.y, context.color, context.outlineColor, "~", getFont() }); end++; // Skip to the next character } else { end += 2; // Skip to after the space } wrapped = true; } row.parts.push_back(context.linePart(x, std::move(part))); row.width = std::max(row.width, x + partWidth); if (mMode == AUTO_WRAP && partWidth == 0) break; x += partWidth; } context.y += context.lineHeight; row.height = context.y - startY; } void BrowserBox::updateHoveredLink(int x, int y) { mHoveredLink.reset(); for (const auto &row : mTextRows) { for (const auto &link : row.links) { if (link.contains(x, y)) { mHoveredLink = link; return; } } } } void BrowserBox::maybeRelayoutText() { // Reduce relayouting frequency when there is a lot of text if (mTextRows.size() > 1000) if (!mLayoutTimer.passed()) return; relayoutText(); }