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.
|
// 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_)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user