From 3a2be661eadf32c2262aac6ad95c0ea9f7157872 Mon Sep 17 00:00:00 2001 From: DanS Date: Tue, 21 Jul 2026 13:42:30 -0500 Subject: [PATCH] feat(chat): custom DragonX emoji via the :drgx: shortcode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a DragonX custom emoji to chat. The emoji picker gains a DragonX tile (first cell) that inserts the text ":drgx:"; message bodies containing it render the mark inline via layoutChatBodyRich, which flows text words + the emoji image with word wrap (plain bodies keep the tighter layoutChatBody path, so normal messages are unaffected). Other clients simply show the literal ":drgx:" text — a portable encoding with graceful degradation. The emoji is the DragonX SVG rasterized once at fixed brand colors (crimson body, white detail) via LoadTextureFromSvg — theme-independent so it looks identical for sender and receiver — exposed as App::getDrgxEmojiTexture(). Emoji insertion is factored into one insertToken() helper (space-prepend + on-chain byte cap), shared by the DragonX tile and the Unicode emojis. Drag-to-select text is skipped on ":drgx:" messages (their inline layout doesn't match the plain-text hit-test geometry); right-click "copy" still copies the whole message. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.cpp | 7 ++ src/app.h | 6 ++ src/ui/windows/chat_tab.cpp | 130 ++++++++++++++++++++++++++++++------ 3 files changed, 121 insertions(+), 22 deletions(-) diff --git a/src/app.cpp b/src/app.cpp index cfb0c45..e8f5520 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -1361,6 +1361,13 @@ void App::handleGlobalShortcuts() // theme accent, so it re-rasterizes whenever the accent or the dark↔light variant changes. void App::ensureLogoTexture() { + // DragonX custom chat emoji (":drgx:") — the mark in FIXED brand colors (crimson body + white detail), + // rasterized once. Kept theme-independent so it looks identical for the sender and the receiver. + if (!drgx_emoji_tex_) { + util::LoadTextureFromSvg(embedded::kLogoDragonXSvg, 96, IM_COL32(0xD8, 0x26, 0x52, 0xFF), + IM_COL32(255, 255, 255, 255), &drgx_emoji_tex_, &drgx_emoji_w_, &drgx_emoji_h_); + } + const bool wantDark = ui::material::IsDarkTheme(); const ImU32 logoAccent = ui::material::Primary(); if (logo_loaded_ && wantDark == logo_is_dark_variant_ && logoAccent == logo_accent_) diff --git a/src/app.h b/src/app.h index 0add0de..2756a21 100644 --- a/src/app.h +++ b/src/app.h @@ -479,6 +479,10 @@ public: // Coin logo texture accessor (DragonX currency icon for balance tab) ImTextureID getCoinLogoTexture() const { return coin_logo_tex_; } + // DragonX custom chat emoji (the ":drgx:" shortcode) — the mark in fixed brand colors, lazily + // rasterized. Used by the emoji picker tile + inline in chat bubbles. + ImTextureID getDrgxEmojiTexture() const { return drgx_emoji_tex_; } + /** * @brief Reload theme images (background gradient + logo) from new paths * @param bgPath Path to background image override (empty = use default) @@ -1085,6 +1089,8 @@ private: bool logo_loaded_ = false; bool logo_is_dark_variant_ = true; // tracks which variant is currently loaded ImU32 logo_accent_ = 0; // theme accent the SVG logo was last rasterized with (re-render on change) + ImTextureID drgx_emoji_tex_ = 0; // ":drgx:" custom chat emoji (fixed brand colors) — rasterized once + int drgx_emoji_w_ = 0, drgx_emoji_h_ = 0; // Coin logo texture (DragonX currency icon, separate from wallet branding) ImTextureID coin_logo_tex_ = 0; diff --git a/src/ui/windows/chat_tab.cpp b/src/ui/windows/chat_tab.cpp index 9aa3aae..2b33322 100644 --- a/src/ui/windows/chat_tab.cpp +++ b/src/ui/windows/chat_tab.cpp @@ -29,6 +29,7 @@ #include // std::cos/std::sin — radial byte gauge in the composer #include #include +#include #include #include #include @@ -288,6 +289,65 @@ std::size_t chatBodyHitTest(ImFont* font, float size, const std::string& body, f return best; } +// The custom-DragonX-emoji shortcode. Inserted by the emoji picker's DragonX tile; rendered inline as +// the mark in chat bubbles (see layoutChatBodyRich). Plain text on clients that don't support it. +static const char* kDrgxToken = ":drgx:"; + +// Lay out (and optionally draw) a message body that contains one or more ":drgx:" tokens, flowing text +// WORDS and the inline DragonX-emoji image left-to-right and wrapping at wrapW (each drgx renders as a +// `size`-square image). Only used for bodies that actually contain the token — plain bodies keep the +// tighter layoutChatBody path — so normal messages are unaffected. Returns the total laid-out size. +ImVec2 layoutChatBodyRich(ImDrawList* dl, ImFont* font, float size, const std::string& body, float wrapW, + float paraGap, ImVec2 origin, ImU32 col, ImTextureID drgxTex) { + const float spaceW = font->CalcTextSizeA(size, FLT_MAX, 0.0f, " ").x; + const float emojiW = size; // square, matching the line height + const std::size_t tokLen = std::strlen(kDrgxToken); + float maxW = 0.0f, y = 0.0f, x = 0.0f; + bool firstPara = true, firstOnLine = true; + // Place a token of width w on the current line, wrapping first if it doesn't fit; draw() gets the + // token's top-left screen position. + auto place = [&](float w, const std::function& draw) { + const float lead = firstOnLine ? 0.0f : spaceW; + if (!firstOnLine && x + lead + w > wrapW) { x = 0.0f; y += size; firstOnLine = true; } + const float sx = x + (firstOnLine ? 0.0f : spaceW); + if (dl) draw(origin.x + sx, origin.y + y); + x = sx + w; + if (x > maxW) maxW = x; + firstOnLine = false; + }; + std::size_t pstart = 0; + for (;;) { + const std::size_t nl = body.find('\n', pstart); + const bool lastPara = (nl == std::string::npos); + const std::size_t pend = lastPara ? body.size() : nl; + if (!firstPara) y += paraGap; + x = 0.0f; firstOnLine = true; + std::size_t i = pstart; + while (i < pend) { + if (body[i] == ' ' || body[i] == '\t') { ++i; continue; } + if (body.compare(i, tokLen, kDrgxToken) == 0) { + place(emojiW, [&](float px, float py) { + if (drgxTex) dl->AddImage(drgxTex, ImVec2(px, py), ImVec2(px + emojiW, py + size)); + else dl->AddText(font, size, ImVec2(px, py), col, kDrgxToken); // fallback: literal + }); + i += tokLen; + continue; + } + std::size_t j = i; + while (j < pend && body[j] != ' ' && body[j] != '\t' && body.compare(j, tokLen, kDrgxToken) != 0) ++j; + const std::string word = body.substr(i, j - i); + const float ww = font->CalcTextSizeA(size, FLT_MAX, 0.0f, word.c_str()).x; + place(ww, [&, word](float px, float py) { dl->AddText(font, size, ImVec2(px, py), col, word.c_str()); }); + i = j; + } + y += size; + firstPara = false; + if (lastPara) break; + pstart = nl + 1; + } + return ImVec2(maxW, y); +} + // Outgoing-bubble accent base color for a chat_bubble_accent preset (0 = theme primary). ImU32 bubbleAccentColor(int accent) { switch (accent) { @@ -564,7 +624,19 @@ static const EmojiEntry kEmoji[] = { // Emoji picker overlay: fills the conversation-list pane (cancel + keyword search at the top, then a // grid). Clicking an emoji appends its UTF-8 bytes to `buf` (the composer), respecting the buffer. -void renderEmojiPickerOverlay(char* buf, std::size_t bufSize) { +void renderEmojiPickerOverlay(char* buf, std::size_t bufSize, ImTextureID drgxTex) { + // Insert a token (emoji glyph or the ":drgx:" shortcode) at the end of the draft, prepending a space + // when the draft isn't empty and doesn't already end in whitespace. Respects the on-chain byte cap. + auto insertToken = [&](const char* tok) { + const std::size_t cur = std::strlen(buf), add = std::strlen(tok); + const bool needsSpace = cur > 0 && static_cast(buf[cur - 1]) > ' '; + const std::size_t pad = needsSpace ? 1 : 0; + if (cur + pad + add <= static_cast(kChatBodyMaxBytes) && cur + pad + add < bufSize) { + if (needsSpace) buf[cur] = ' '; + std::memcpy(buf + cur + pad, tok, add); + buf[cur + pad + add] = '\0'; + } + }; if (ImGui::SmallButton(TR("chat_cancel"))) { s_show_emoji_picker = false; s_emoji_search[0] = '\0'; return; } ImGui::SameLine(); ImGui::SetNextItemWidth(-FLT_MIN); @@ -590,23 +662,25 @@ void renderEmojiPickerOverlay(char* buf, std::size_t bufSize) { es.bgRounding = 8.0f * dp; ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(spacing, spacing)); int shown = 0; + // DragonX custom emoji tile (first cell) — inserts the ":drgx:" shortcode, which renders inline as the + // mark in message bubbles. Shown unless the search filter excludes "dragon"/"drgx". + if (q.empty() || containsCI("dragon drgx dragonx wyrm", q)) { + ImGui::PushID("drgxtile"); + const ImVec2 tp = ImGui::GetCursorScreenPos(); + ImGui::InvisibleButton("##drgx", ImVec2(cell, cell)); + ImDrawList* gdl = ImGui::GetWindowDrawList(); + if (ImGui::IsItemHovered()) gdl->AddRectFilled(tp, ImVec2(tp.x + cell, tp.y + cell), es.hoverBg, es.bgRounding); + if (drgxTex) gdl->AddImage(drgxTex, tp, ImVec2(tp.x + cell, tp.y + cell)); + else gdl->AddText(emojiFont, emojiFont->LegacySize, tp, material::OnSurface(), u8"🐉"); + if (ImGui::IsItemClicked()) insertToken(kDrgxToken); + ImGui::PopID(); + if (++shown % perRow != 0) ImGui::SameLine(); + } for (const auto& e : kEmoji) { if (!q.empty() && !containsCI(e.keywords, q)) continue; ImGui::PushID(shown); - if (material::IconButton("##em", e.glyph, emojiFont, ImVec2(cell, cell), es)) { - const std::size_t cur = std::strlen(buf), add = std::strlen(e.glyph); - // Prepend a space to the LEFT of the emoji when the draft isn't empty and doesn't already - // end in whitespace (so emojis after words get a separating space, but a leading one isn't - // added at the start). Respect the on-chain byte cap (not just the raw buffer) so the emoji - // path can't overshoot what the input's own hard cap allows. - const bool needsSpace = cur > 0 && static_cast(buf[cur - 1]) > ' '; - const std::size_t pad = needsSpace ? 1 : 0; - if (cur + pad + add <= static_cast(kChatBodyMaxBytes) && cur + pad + add < bufSize) { - if (needsSpace) buf[cur] = ' '; - std::memcpy(buf + cur + pad, e.glyph, add); - buf[cur + pad + add] = '\0'; - } - } + if (material::IconButton("##em", e.glyph, emojiFont, ImVec2(cell, cell), es)) + insertToken(e.glyph); ImGui::PopID(); if (++shown % perRow != 0) ImGui::SameLine(); } @@ -716,7 +790,7 @@ void RenderChatTab(App* app) material::ApplySmoothScroll(); // wheel-driven lerp scroll, matching the rest of the app if (s_show_emoji_picker) { // Emoji picker overlay takes over the conversation-list pane while open (search + cancel + grid). - renderEmojiPickerOverlay(s_compose, sizeof(s_compose)); + renderEmojiPickerOverlay(s_compose, sizeof(s_compose), app->getDrgxEmojiTexture()); } else { // "New chat" (accented) + "Show hidden (N)" toggle, side by side. Show-hidden only appears when // something is hidden; otherwise New chat spans the row. @@ -1194,6 +1268,7 @@ void RenderChatTab(App* app) const float icfSz = scaledSize(icf); std::int64_t prevTs = -1; int prevDir = -1; std::int64_t prevDay = -1; bool anyMsgClicked = false; // did a left-click this frame land on a bubble / copy button? + const ImTextureID drgxTex = app->getDrgxEmojiTexture(); // ":drgx:" inline emoji for (std::size_t mi = 0; mi < messages.size(); ++mi) { const auto& m = messages[mi]; ImGui::PushID(static_cast(mi)); @@ -1249,9 +1324,13 @@ void RenderChatTab(App* app) // ── Direction-aligned bubble (rounding/fill/accent from settings; grouped corners). // Body is laid out paragraph-by-paragraph so explicit newlines get extra leading. + // A body containing ":drgx:" uses the richer inline-image flow (drgx tile → mark); + // plain bodies keep the tighter text-only path so they're unaffected. const float paraGap = bodySz * 0.35f; - const ImVec2 tsz = layoutChatBody(nullptr, nameFont, bodySz, m.body, innerW, paraGap, - ImVec2(0, 0), 0); + const bool hasDrgx = m.body.find(kDrgxToken) != std::string::npos; + const ImVec2 tsz = hasDrgx + ? layoutChatBodyRich(nullptr, nameFont, bodySz, m.body, innerW, paraGap, ImVec2(0, 0), 0, drgxTex) + : layoutChatBody(nullptr, nameFont, bodySz, m.body, innerW, paraGap, ImVec2(0, 0), 0); const float bw = std::min(maxBubbleW, tsz.x + 2.0f * bpad); const float bh = tsz.y + 2.0f * bpad; const ImVec2 cur = ImGui::GetCursorScreenPos(); @@ -1274,9 +1353,12 @@ void RenderChatTab(App* app) dl->AddRectFilled(bmin, bmax, bubCol, bround, rf); // ── Text selection: click-drag inside the bubble selects a byte range of the body. + // Skipped for ":drgx:" messages — their inline-image layout (layoutChatBodyRich) doesn't + // match the plain-text hit-test/highlight geometry, so a selection would misalign. + // (Right-click "copy" still copies the whole message.) const ImVec2 bodyOrigin(bx + bpad, cur.y + bpad); - const bool selfSel = (s_msgsel_cid == s_selected_cid && s_msgsel_index == static_cast(mi)); - if (ImGui::IsMouseHoveringRect(bmin, bmax) && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + const bool selfSel = (!hasDrgx && s_msgsel_cid == s_selected_cid && s_msgsel_index == static_cast(mi)); + if (!hasDrgx && ImGui::IsMouseHoveringRect(bmin, bmax) && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { s_msgsel_cid = s_selected_cid; s_msgsel_index = static_cast(mi); s_msgsel_anchor = s_msgsel_head = static_cast( chatBodyHitTest(nameFont, bodySz, m.body, innerW, paraGap, bodyOrigin, ImGui::GetMousePos())); @@ -1305,8 +1387,12 @@ void RenderChatTab(App* app) material::WithAlpha(material::Primary(), 80)); } } - layoutChatBody(dl, nameFont, bodySz, m.body, innerW, paraGap, - ImVec2(bx + bpad, cur.y + bpad), material::OnSurface()); + if (hasDrgx) + layoutChatBodyRich(dl, nameFont, bodySz, m.body, innerW, paraGap, + ImVec2(bx + bpad, cur.y + bpad), material::OnSurface(), drgxTex); + else + layoutChatBody(dl, nameFont, bodySz, m.body, innerW, paraGap, + ImVec2(bx + bpad, cur.y + bpad), material::OnSurface()); // Peer avatar beside the LAST incoming bubble of a run (Tier 3). if (!outgoing && lastInGroup) {