#include <Geode/binding/ProfilePage.hpp> #include <Geode/binding/CCContentLayer.hpp> #include <Geode/loader/Mod.hpp> #include <Geode/ui/MDTextArea.hpp> #include <Geode/utils/casts.hpp> #include <Geode/utils/cocos.hpp> #include <Geode/utils/web.hpp> #include <Geode/utils/ranges.hpp> #include <Geode/utils/string.hpp> #include <md4c.h> USE_GEODE_NAMESPACE(); static constexpr float g_fontScale = .5f; static constexpr float g_paragraphPadding = 7.f; static constexpr float g_indent = 7.f; static constexpr float g_codeBlockIndent = 8.f; static constexpr ccColor3B g_linkColor = cc3x(0x7ff4f4); TextRenderer::Font g_mdFont = [](int style) -> TextRenderer::Label { if ((style & TextStyleBold) && (style & TextStyleItalic)) { return CCLabelBMFont::create("", "mdFontBI.fnt"_spr); } if ((style & TextStyleBold)) { return CCLabelBMFont::create("", "mdFontB.fnt"_spr); } if ((style & TextStyleItalic)) { return CCLabelBMFont::create("", "mdFontI.fnt"_spr); } return CCLabelBMFont::create("", "mdFont.fnt"_spr); }; TextRenderer::Font g_mdMonoFont = [](int style) -> TextRenderer::Label { return CCLabelBMFont::create("", "mdFontMono.fnt"_spr); }; class MDContentLayer : public CCContentLayer { protected: CCMenu* m_content; public: static MDContentLayer* create(CCMenu* content, float width, float height) { auto ret = new MDContentLayer(); if (ret && ret->initWithColor({ 0, 255, 0, 0 }, width, height)) { ret->m_content = content; ret->autorelease(); return ret; } CC_SAFE_DELETE(ret); return nullptr; } void setPosition(CCPoint const& pos) override { // cringe CCContentLayer expect its children to // all be TableViewCells CCLayerColor::setPosition(pos); // so that's why based MDContentLayer expects itself // to have a CCMenu :-) if (m_content) { for (auto child : CCArrayExt<CCNode>(m_content->getChildren())) { auto y = this->getPositionY() + child->getPositionY(); child->setVisible( !((m_content->getContentSize().height < y) || (y < -child->getContentSize().height)) ); } } } }; Result<ccColor3B> colorForIdentifier(std::string const& tag) { if (utils::string::contains(tag, ' ')) { auto hexStr = utils::string::split(utils::string::normalize(tag), " ").at(1); try { auto hex = std::stoi(hexStr, nullptr, 16); return Ok(cc3x(hex)); } catch (...) { return Err("Invalid hex"); } } else { auto colorText = tag.substr(1); if (!colorText.size()) { return Err("No color specified"); } else if (colorText.size() > 1) { return Err("Color tag " + tag + " unexpectedly long, either do <cx> or <c hex>"); } else { switch (colorText.front()) { case 'b': return Ok(cc3x(0x4a52e1)); break; case 'g': return Ok(cc3x(0x40e348)); break; case 'l': return Ok(cc3x(0x60abef)); break; case 'j': return Ok(cc3x(0x32c8ff)); break; case 'y': return Ok(cc3x(0xffff00)); break; case 'o': return Ok(cc3x(0xffa54b)); break; case 'r': return Ok(cc3x(0xff5a5a)); break; case 'p': return Ok(cc3x(0xff00ff)); break; default: return Err("Unknown color " + colorText); } } } return Err("Unknown error"); } bool MDTextArea::init(std::string const& str, CCSize const& size) { if (!CCLayer::init()) return false; m_text = str; m_size = size; this->setContentSize(size); m_renderer = TextRenderer::create(); CC_SAFE_RETAIN(m_renderer); m_bgSprite = CCScale9Sprite::create("square02b_001.png", { 0.0f, 0.0f, 80.0f, 80.0f }); m_bgSprite->setScale(.5f); m_bgSprite->setColor({ 0, 0, 0 }); m_bgSprite->setOpacity(75); m_bgSprite->setContentSize(size * 2 + CCSize { 25.f, 25.f }); m_bgSprite->setPosition(size / 2); this->addChild(m_bgSprite); m_scrollLayer = ScrollLayer::create({ 0, 0, m_size.width, m_size.height }, true); m_content = CCMenu::create(); m_content->setZOrder(2); m_scrollLayer->m_contentLayer->addChild(m_content); CCDirector::sharedDirector()->getTouchDispatcher()->incrementForcePrio(2); m_scrollLayer->registerWithTouchDispatcher(); this->addChild(m_scrollLayer); this->updateLabel(); return true; } MDTextArea::~MDTextArea() { CC_SAFE_RELEASE(m_renderer); } class BreakLine : public CCNode { protected: void draw() override { // some nodes sometimes set the blend func to // something else without resetting it back ccGLBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); ccDrawSolidRect({ 0, 0 }, this->getContentSize(), { 1.f, 1.f, 1.f, .2f }); CCNode::draw(); } public: static BreakLine* create(float width) { auto ret = new BreakLine; if (ret && ret->init()) { ret->autorelease(); ret->setContentSize({ width, 1.f }); return ret; } CC_SAFE_DELETE(ret); return nullptr; } }; void MDTextArea::onLink(CCObject* pSender) { auto href = as<CCString*>(as<CCNode*>(pSender)->getUserObject()); auto layer = FLAlertLayer::create( this, "Hold Up!", "Links are spooky! Are you sure you want to go to <cy>" + std::string(href->getCString()) + "</c>?", "Cancel", "Yes", 360.f ); layer->setUserObject(href); layer->show(); } void MDTextArea::onGDProfile(CCObject* pSender) { auto href = as<CCString*>(as<CCNode*>(pSender)->getUserObject()); auto profile = std::string(href->getCString()); profile = profile.substr(profile.find(":") + 1); try { ProfilePage::create(std::stoi(profile), false)->show(); } catch (...) { FLAlertLayer::create( "Error", "Invalid profile ID: <cr>" + profile + "</c>. This is " "probably the modder's fault, report the bug to them.", "OK" ) ->show(); } } void MDTextArea::FLAlert_Clicked(FLAlertLayer* layer, bool btn) { if (btn) { web::openLinkInBrowser(as<CCString*>(layer->getUserObject())->getCString()); } } struct MDParser { static std::string s_lastLink; static std::string s_lastImage; static bool s_isOrderedList; static bool s_isCodeBlock; static float s_codeStart; static size_t s_orderedListNum; static std::vector<TextRenderer::Label> s_codeSpans; static int parseText(MD_TEXTTYPE type, MD_CHAR const* rawText, MD_SIZE size, void* mdtextarea) { auto textarea = static_cast<MDTextArea*>(mdtextarea); auto renderer = textarea->m_renderer; auto text = std::string(rawText, size); switch (type) { case MD_TEXTTYPE::MD_TEXT_CODE: { auto rendered = renderer->renderString(text); if (!s_isCodeBlock) { // code span BGs need to be rendered after all // rendering is done since the position of the // rendered labels may change after alignments // are adjusted ranges::push(s_codeSpans, rendered); } } break; case MD_TEXTTYPE::MD_TEXT_BR: { renderer->breakLine(); } break; case MD_TEXTTYPE::MD_TEXT_SOFTBR: { renderer->breakLine(); } break; case MD_TEXTTYPE::MD_TEXT_NORMAL: { if (s_lastLink.size()) { renderer->pushColor(g_linkColor); renderer->pushDecoFlags(TextDecorationUnderline); auto rendered = renderer->renderStringInteractive( text, textarea, utils::string::startsWith(s_lastLink, "user:") ? menu_selector(MDTextArea::onGDProfile) : menu_selector(MDTextArea::onLink) ); for (auto const& label : rendered) { label.m_node->setUserObject(CCString::create(s_lastLink)); } renderer->popDecoFlags(); renderer->popColor(); } else if (s_lastImage.size()) { bool isFrame = false; if (utils::string::startsWith(s_lastImage, "frame:")) { s_lastImage = s_lastImage.substr(s_lastImage.find(":") + 1); isFrame = true; } CCSprite* spr = nullptr; if (isFrame) { spr = CCSprite::createWithSpriteFrameName(s_lastImage.c_str()); } else { spr = CCSprite::create(s_lastImage.c_str()); } if (spr) { renderer->renderNode(spr); } else { renderer->renderString(text); } s_lastImage = ""; } else { renderer->renderString(text); } } break; case MD_TEXTTYPE::MD_TEXT_HTML: { if (text.size() > 2) { auto tag = utils::string::trim(text.substr(1, text.size() - 2)); auto isClosing = tag.front() == '/'; if (isClosing) tag = tag.substr(1); if (tag.front() != 'c') { log::warn("Unknown tag {}", text); renderer->renderString(text); } else { if (isClosing) { renderer->popColor(); } else { auto color = colorForIdentifier(tag); if (color) { renderer->pushColor(color.unwrap()); } else { log::warn("Error parsing color: {}", color.unwrapErr()); } } } } else { log::warn("Too short tag {}", text); renderer->renderString(text); } } break; default: { log::warn("Unhandled text type {}", type); } break; } return 0; } static int enterBlock(MD_BLOCKTYPE type, void* detail, void* mdtextarea) { auto textarea = static_cast<MDTextArea*>(mdtextarea); auto renderer = textarea->m_renderer; switch (type) { case MD_BLOCKTYPE::MD_BLOCK_DOC: { } break; case MD_BLOCKTYPE::MD_BLOCK_H: { auto hdetail = static_cast<MD_BLOCK_H_DETAIL*>(detail); renderer->pushStyleFlags(TextStyleBold); switch (hdetail->level) { case 1: renderer->pushScale(g_fontScale * 2.f); break; case 2: renderer->pushScale(g_fontScale * 1.5f); break; case 3: renderer->pushScale(g_fontScale * 1.17f); break; case 4: renderer->pushScale(g_fontScale); break; case 5: renderer->pushScale(g_fontScale * .83f); break; default: case 6: renderer->pushScale(g_fontScale * .67f); break; } // switch (hdetail->level) { // case 3: renderer->pushCaps(TextCapitalization::AllUpper); break; // } } break; case MD_BLOCKTYPE::MD_BLOCK_P: { } break; case MD_BLOCKTYPE::MD_BLOCK_UL: case MD_BLOCKTYPE::MD_BLOCK_OL: { renderer->pushIndent(g_indent); s_isOrderedList = type == MD_BLOCKTYPE::MD_BLOCK_OL; s_orderedListNum = 0; } break; case MD_BLOCKTYPE::MD_BLOCK_HR: { renderer->breakLine(g_paragraphPadding / 2); renderer->renderNode(BreakLine::create(textarea->m_size.width)); renderer->breakLine(g_paragraphPadding); } break; case MD_BLOCKTYPE::MD_BLOCK_LI: { renderer->pushOpacity(renderer->getCurrentOpacity() / 2); auto lidetail = static_cast<MD_BLOCK_LI_DETAIL*>(detail); if (s_isOrderedList) { s_orderedListNum++; renderer->renderString(std::to_string(s_orderedListNum) + ". "); } else { renderer->renderString("• "); } renderer->popOpacity(); } break; case MD_BLOCKTYPE::MD_BLOCK_CODE: { s_isCodeBlock = true; s_codeStart = renderer->getCursorPos().y; renderer->pushFont(g_mdMonoFont); renderer->pushIndent(g_codeBlockIndent); renderer->pushWrapOffset(g_codeBlockIndent); } break; default: { log::warn("Unhandled block enter type {}", type); } break; } return 0; } static int leaveBlock(MD_BLOCKTYPE type, void* detail, void* mdtextarea) { auto textarea = static_cast<MDTextArea*>(mdtextarea); auto renderer = textarea->m_renderer; switch (type) { case MD_BLOCKTYPE::MD_BLOCK_DOC: { } break; case MD_BLOCKTYPE::MD_BLOCK_H: { auto hdetail = static_cast<MD_BLOCK_H_DETAIL*>(detail); renderer->breakLine(); if (hdetail->level == 1) { renderer->breakLine(g_paragraphPadding / 2); renderer->renderNode(BreakLine::create(textarea->m_size.width)); } renderer->breakLine(g_paragraphPadding); renderer->popScale(); renderer->popStyleFlags(); // switch (hdetail->level) { // case 3: renderer->popCaps(); break; // } } break; case MD_BLOCKTYPE::MD_BLOCK_P: { renderer->breakLine(); renderer->breakLine(g_paragraphPadding); } break; case MD_BLOCKTYPE::MD_BLOCK_OL: case MD_BLOCKTYPE::MD_BLOCK_UL: { renderer->popIndent(); renderer->breakLine(); } break; case MD_BLOCKTYPE::MD_BLOCK_CODE: { auto codeEnd = renderer->getCursorPos().y; auto pad = g_codeBlockIndent / 1.5f; CCSize size { textarea->m_size.width - renderer->getCurrentIndent() - renderer->getCurrentWrapOffset() + pad * 2, s_codeStart - codeEnd + pad * 2 }; auto bg = CCScale9Sprite::create("square02b_001.png", { 0.0f, 0.0f, 80.0f, 80.0f }); bg->setScale(.25f); bg->setColor({ 0, 0, 0 }); bg->setOpacity(75); bg->setContentSize(size * 4); bg->setPosition( size.width / 2 + renderer->getCurrentIndent() - pad, // mmm i love magic numbers // the -2.f is to offset the the box // to fit the Ubuntu font very neatly. // idk if it works the same for other // fonts s_codeStart - 2.f + pad - size.height / 2 ); bg->setAnchorPoint({ .5f, .5f }); bg->setZOrder(-1); textarea->m_content->addChild(bg); renderer->popWrapOffset(); renderer->popIndent(); renderer->popFont(); renderer->breakLine(); } break; case MD_BLOCKTYPE::MD_BLOCK_LI: { renderer->breakLine(); } break; case MD_BLOCKTYPE::MD_BLOCK_HR: { } break; default: { log::warn("Unhandled block leave type {}", type); } break; } return 0; } static int enterSpan(MD_SPANTYPE type, void* detail, void* mdtextarea) { auto renderer = static_cast<MDTextArea*>(mdtextarea)->m_renderer; switch (type) { case MD_SPANTYPE::MD_SPAN_STRONG: { renderer->pushStyleFlags(TextStyleBold); } break; case MD_SPANTYPE::MD_SPAN_EM: { renderer->pushStyleFlags(TextStyleItalic); } break; case MD_SPANTYPE::MD_SPAN_DEL: { renderer->pushDecoFlags(TextDecorationStrikethrough); } break; case MD_SPANTYPE::MD_SPAN_U: { renderer->pushDecoFlags(TextDecorationUnderline); } break; case MD_SPANTYPE::MD_SPAN_IMG: { auto adetail = static_cast<MD_SPAN_IMG_DETAIL*>(detail); s_lastImage = std::string(adetail->src.text, adetail->src.size); } break; case MD_SPANTYPE::MD_SPAN_A: { auto adetail = static_cast<MD_SPAN_A_DETAIL*>(detail); s_lastLink = std::string(adetail->href.text, adetail->href.size); } break; case MD_SPANTYPE::MD_SPAN_CODE: { s_isCodeBlock = false; renderer->pushFont(g_mdMonoFont); } break; default: { log::warn("Unhandled span enter type {}", type); } break; } return 0; } static int leaveSpan(MD_SPANTYPE type, void* detail, void* mdtextarea) { auto renderer = static_cast<MDTextArea*>(mdtextarea)->m_renderer; switch (type) { case MD_SPANTYPE::MD_SPAN_STRONG: { renderer->popStyleFlags(); } break; case MD_SPANTYPE::MD_SPAN_EM: { renderer->popStyleFlags(); } break; case MD_SPANTYPE::MD_SPAN_DEL: { renderer->popDecoFlags(); } break; case MD_SPANTYPE::MD_SPAN_U: { renderer->popDecoFlags(); } break; case MD_SPANTYPE::MD_SPAN_A: { s_lastLink = ""; } break; case MD_SPANTYPE::MD_SPAN_IMG: { s_lastImage = ""; } break; case MD_SPANTYPE::MD_SPAN_CODE: { renderer->popFont(); } break; default: { log::warn("Unhandled span leave type {}", type); } break; } return 0; } }; std::string MDParser::s_lastLink = ""; std::string MDParser::s_lastImage = ""; bool MDParser::s_isOrderedList = false; size_t MDParser::s_orderedListNum = 0; bool MDParser::s_isCodeBlock = false; float MDParser::s_codeStart = 0; decltype(MDParser::s_codeSpans) MDParser::s_codeSpans = {}; void MDTextArea::updateLabel() { m_renderer->begin(m_content, CCPointZero, m_size); m_renderer->pushFont(g_mdFont); m_renderer->pushScale(.5f); m_renderer->pushVerticalAlign(TextAlignment::End); m_renderer->pushHorizontalAlign(TextAlignment::Begin); MD_PARSER parser; parser.abi_version = 0; parser.flags = MD_FLAG_UNDERLINE | MD_FLAG_STRIKETHROUGH | MD_FLAG_PERMISSIVEURLAUTOLINKS | MD_FLAG_PERMISSIVEWWWAUTOLINKS; parser.text = &MDParser::parseText; parser.enter_block = &MDParser::enterBlock; parser.leave_block = &MDParser::leaveBlock; parser.enter_span = &MDParser::enterSpan; parser.leave_span = &MDParser::leaveSpan; parser.debug_log = nullptr; parser.syntax = nullptr; MDParser::s_codeSpans = {}; if (md_parse(m_text.c_str(), m_text.size(), &parser, this)) { m_renderer->renderString("Error parsing Markdown"); } for (auto& render : MDParser::s_codeSpans) { auto bg = CCScale9Sprite::create("square02b_001.png", { 0.0f, 0.0f, 80.0f, 80.0f }); bg->setScale(.125f); bg->setColor({ 0, 0, 0 }); bg->setOpacity(75); bg->setContentSize(render.m_node->getScaledContentSize() * 8 + CCSize { 20.f, .0f }); bg->setPosition( render.m_node->getPositionX() - 2.5f * (.5f - render.m_node->getAnchorPoint().x), render.m_node->getPositionY() - .5f ); bg->setAnchorPoint(render.m_node->getAnchorPoint()); bg->setZOrder(-1); m_content->addChild(bg); // i know what you're thinking. // my brother in christ, what the hell is this? // where did this magical + 1.5f come from? // the reason is that if you remove them, code // spans are slightly offset and it triggers my // OCD. render.m_node->setPositionY(render.m_node->getPositionY() + 1.5f); } m_renderer->end(); m_scrollLayer->m_contentLayer->setContentSize(m_content->getContentSize()); m_scrollLayer->moveToTop(); } CCScrollLayerExt* MDTextArea::getScrollLayer() const { return m_scrollLayer; } void MDTextArea::setString(char const* text) { m_text = text; this->updateLabel(); } char const* MDTextArea::getString() { return m_text.c_str(); } MDTextArea* MDTextArea::create(std::string const& str, CCSize const& size) { auto ret = new MDTextArea; if (ret && ret->init(str, size)) { ret->autorelease(); return ret; } CC_SAFE_DELETE(ret); return nullptr; }