feat(chat): custom DragonX emoji via the :drgx: shortcode

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-21 13:42:30 -05:00
parent 17525003c5
commit 3a2be661ea
3 changed files with 121 additions and 22 deletions

View File

@@ -1361,6 +1361,13 @@ void App::handleGlobalShortcuts()
// theme accent, so it re-rasterizes whenever the accent or the dark↔light variant changes. // theme accent, so it re-rasterizes whenever the accent or the dark↔light variant changes.
void App::ensureLogoTexture() 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 bool wantDark = ui::material::IsDarkTheme();
const ImU32 logoAccent = ui::material::Primary(); const ImU32 logoAccent = ui::material::Primary();
if (logo_loaded_ && wantDark == logo_is_dark_variant_ && logoAccent == logo_accent_) if (logo_loaded_ && wantDark == logo_is_dark_variant_ && logoAccent == logo_accent_)

View File

@@ -479,6 +479,10 @@ public:
// Coin logo texture accessor (DragonX currency icon for balance tab) // Coin logo texture accessor (DragonX currency icon for balance tab)
ImTextureID getCoinLogoTexture() const { return coin_logo_tex_; } 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 * @brief Reload theme images (background gradient + logo) from new paths
* @param bgPath Path to background image override (empty = use default) * @param bgPath Path to background image override (empty = use default)
@@ -1085,6 +1089,8 @@ private:
bool logo_loaded_ = false; bool logo_loaded_ = false;
bool logo_is_dark_variant_ = true; // tracks which variant is currently loaded 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) 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) // Coin logo texture (DragonX currency icon, separate from wallet branding)
ImTextureID coin_logo_tex_ = 0; ImTextureID coin_logo_tex_ = 0;

View File

@@ -29,6 +29,7 @@
#include <cmath> // std::cos/std::sin — radial byte gauge in the composer #include <cmath> // std::cos/std::sin — radial byte gauge in the composer
#include <cstdint> #include <cstdint>
#include <cstring> #include <cstring>
#include <functional>
#include <ctime> #include <ctime>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -288,6 +289,65 @@ std::size_t chatBodyHitTest(ImFont* font, float size, const std::string& body, f
return best; 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<void(float, float)>& 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). // Outgoing-bubble accent base color for a chat_bubble_accent preset (0 = theme primary).
ImU32 bubbleAccentColor(int accent) { ImU32 bubbleAccentColor(int accent) {
switch (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 // 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. // 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<unsigned char>(buf[cur - 1]) > ' ';
const std::size_t pad = needsSpace ? 1 : 0;
if (cur + pad + add <= static_cast<std::size_t>(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; } if (ImGui::SmallButton(TR("chat_cancel"))) { s_show_emoji_picker = false; s_emoji_search[0] = '\0'; return; }
ImGui::SameLine(); ImGui::SameLine();
ImGui::SetNextItemWidth(-FLT_MIN); ImGui::SetNextItemWidth(-FLT_MIN);
@@ -590,23 +662,25 @@ void renderEmojiPickerOverlay(char* buf, std::size_t bufSize) {
es.bgRounding = 8.0f * dp; es.bgRounding = 8.0f * dp;
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(spacing, spacing)); ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(spacing, spacing));
int shown = 0; 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) { for (const auto& e : kEmoji) {
if (!q.empty() && !containsCI(e.keywords, q)) continue; if (!q.empty() && !containsCI(e.keywords, q)) continue;
ImGui::PushID(shown); ImGui::PushID(shown);
if (material::IconButton("##em", e.glyph, emojiFont, ImVec2(cell, cell), es)) { if (material::IconButton("##em", e.glyph, emojiFont, ImVec2(cell, cell), es))
const std::size_t cur = std::strlen(buf), add = std::strlen(e.glyph); insertToken(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<unsigned char>(buf[cur - 1]) > ' ';
const std::size_t pad = needsSpace ? 1 : 0;
if (cur + pad + add <= static_cast<std::size_t>(kChatBodyMaxBytes) && cur + pad + add < bufSize) {
if (needsSpace) buf[cur] = ' ';
std::memcpy(buf + cur + pad, e.glyph, add);
buf[cur + pad + add] = '\0';
}
}
ImGui::PopID(); ImGui::PopID();
if (++shown % perRow != 0) ImGui::SameLine(); 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 material::ApplySmoothScroll(); // wheel-driven lerp scroll, matching the rest of the app
if (s_show_emoji_picker) { if (s_show_emoji_picker) {
// Emoji picker overlay takes over the conversation-list pane while open (search + cancel + grid). // 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 { } else {
// "New chat" (accented) + "Show hidden (N)" toggle, side by side. Show-hidden only appears when // "New chat" (accented) + "Show hidden (N)" toggle, side by side. Show-hidden only appears when
// something is hidden; otherwise New chat spans the row. // something is hidden; otherwise New chat spans the row.
@@ -1194,6 +1268,7 @@ void RenderChatTab(App* app)
const float icfSz = scaledSize(icf); const float icfSz = scaledSize(icf);
std::int64_t prevTs = -1; int prevDir = -1; std::int64_t prevDay = -1; 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? 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) { for (std::size_t mi = 0; mi < messages.size(); ++mi) {
const auto& m = messages[mi]; const auto& m = messages[mi];
ImGui::PushID(static_cast<int>(mi)); ImGui::PushID(static_cast<int>(mi));
@@ -1249,9 +1324,13 @@ void RenderChatTab(App* app)
// ── Direction-aligned bubble (rounding/fill/accent from settings; grouped corners). // ── Direction-aligned bubble (rounding/fill/accent from settings; grouped corners).
// Body is laid out paragraph-by-paragraph so explicit newlines get extra leading. // 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 float paraGap = bodySz * 0.35f;
const ImVec2 tsz = layoutChatBody(nullptr, nameFont, bodySz, m.body, innerW, paraGap, const bool hasDrgx = m.body.find(kDrgxToken) != std::string::npos;
ImVec2(0, 0), 0); 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 bw = std::min(maxBubbleW, tsz.x + 2.0f * bpad);
const float bh = tsz.y + 2.0f * bpad; const float bh = tsz.y + 2.0f * bpad;
const ImVec2 cur = ImGui::GetCursorScreenPos(); const ImVec2 cur = ImGui::GetCursorScreenPos();
@@ -1274,9 +1353,12 @@ void RenderChatTab(App* app)
dl->AddRectFilled(bmin, bmax, bubCol, bround, rf); dl->AddRectFilled(bmin, bmax, bubCol, bround, rf);
// ── Text selection: click-drag inside the bubble selects a byte range of the body. // ── 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 ImVec2 bodyOrigin(bx + bpad, cur.y + bpad);
const bool selfSel = (s_msgsel_cid == s_selected_cid && s_msgsel_index == static_cast<int>(mi)); const bool selfSel = (!hasDrgx && s_msgsel_cid == s_selected_cid && s_msgsel_index == static_cast<int>(mi));
if (ImGui::IsMouseHoveringRect(bmin, bmax) && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { if (!hasDrgx && ImGui::IsMouseHoveringRect(bmin, bmax) && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
s_msgsel_cid = s_selected_cid; s_msgsel_index = static_cast<int>(mi); s_msgsel_cid = s_selected_cid; s_msgsel_index = static_cast<int>(mi);
s_msgsel_anchor = s_msgsel_head = static_cast<int>( s_msgsel_anchor = s_msgsel_head = static_cast<int>(
chatBodyHitTest(nameFont, bodySz, m.body, innerW, paraGap, bodyOrigin, ImGui::GetMousePos())); chatBodyHitTest(nameFont, bodySz, m.body, innerW, paraGap, bodyOrigin, ImGui::GetMousePos()));
@@ -1305,8 +1387,12 @@ void RenderChatTab(App* app)
material::WithAlpha(material::Primary(), 80)); material::WithAlpha(material::Primary(), 80));
} }
} }
layoutChatBody(dl, nameFont, bodySz, m.body, innerW, paraGap, if (hasDrgx)
ImVec2(bx + bpad, cur.y + bpad), material::OnSurface()); 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). // Peer avatar beside the LAST incoming bubble of a run (Tier 3).
if (!outgoing && lastInGroup) { if (!outgoing && lastInGroup) {