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:
@@ -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_)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
#include <cmath> // std::cos/std::sin — radial byte gauge in the composer
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <functional>
|
||||
#include <ctime>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -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<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).
|
||||
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<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; }
|
||||
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<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';
|
||||
}
|
||||
}
|
||||
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<int>(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<int>(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<int>(mi));
|
||||
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_anchor = s_msgsel_head = static_cast<int>(
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user