chatgui: rlottie animated emoji with hover-to-animate, drag-to-select
- Add LottieIcon class (TGS gzip decompress + rlottie C API render) - Add AnimTimer singleton (33ms tick, activate/deactivate refcount) - Add EmojiButton (hover-triggered animation in emoji picker) - Add AnimatedEmoji support to EmojiData (5 TGS mappings) - MessageDelegate: QTextLayout-based animated emoji overlay (text + reactions) - ChatView: drag-to-select (click deselects, 5px drag starts selection) - ChatView: hoveredIndexChanged signal for hover-triggered animations - MessageList: wire AnimTimer tick to viewport update, hover → activate - CMake: AUTORCC, rlottie, zlib dependencies - Add 5 TGS animations as Qt resources
This commit is contained in:
1 parent
0e2b0e28d8
commit
62b3695a77
42 files changed
+286658
-73
No files matched your search
@@ -1,9 +1,16 @@
|
||||
#include "messagedelegate.h"
|
||||
#include "chatview.h"
|
||||
#include "debug_ui.h"
|
||||
#include "emoji.h"
|
||||
#include "animtimer.h"
|
||||
#include "lottieicon.h"
|
||||
#include <cmath>
|
||||
#include <QPainter>
|
||||
#include <QPainterPath>
|
||||
#include <QApplication>
|
||||
#include <QAbstractItemView>
|
||||
#include <QFontMetrics>
|
||||
#include <QTextLayout>
|
||||
|
||||
MessageDelegate::MessageDelegate(QObject *parent)
|
||||
: QStyledItemDelegate(parent) {}
|
||||
@@ -21,6 +28,30 @@ static int measureTextHeight(const QFont &font, const QString &text, int width)
|
||||
return r.height();
|
||||
}
|
||||
|
||||
static int calcMaxLineWidth(const QFont &font, const QString &text, int availW) {
|
||||
int maxW = 0;
|
||||
int pos = 0;
|
||||
while (pos <= text.length()) {
|
||||
int end = text.indexOf('\n', pos);
|
||||
if (end < 0) end = text.length();
|
||||
QString para = text.mid(pos, end - pos);
|
||||
if (!para.isEmpty()) {
|
||||
QTextLayout layout(para, font);
|
||||
layout.beginLayout();
|
||||
while (true) {
|
||||
QTextLine line = layout.createLine();
|
||||
if (!line.isValid()) break;
|
||||
line.setLineWidth(availW);
|
||||
line.setPosition(QPointF(0, 0));
|
||||
maxW = qMax(maxW, (int)line.naturalTextWidth());
|
||||
}
|
||||
layout.endLayout();
|
||||
}
|
||||
pos = end + 1;
|
||||
}
|
||||
return maxW;
|
||||
}
|
||||
|
||||
MessageDelegate::Layout MessageDelegate::calcLayout(
|
||||
const QStyleOptionViewItem &option, const QModelIndex &index) const
|
||||
{
|
||||
@@ -36,26 +67,50 @@ MessageDelegate::Layout MessageDelegate::calcLayout(
|
||||
QString text = index.data(MsgTextRole).toString();
|
||||
QString qText = hasQuote ? index.data(MsgQuoteTextRole).toString() : QString();
|
||||
|
||||
int bubbleW = L.viewWidth - kMarginH - kAvatar - kGap - kMarginH;
|
||||
if (bubbleW < 100) bubbleW = 100;
|
||||
L.bubbleWidth = bubbleW;
|
||||
int availW = L.viewWidth - kMarginH - kAvatar - kGap - kMarginH;
|
||||
L.narrow = (availW < 400);
|
||||
if (L.narrow) availW = L.viewWidth - 2 * kMarginH;
|
||||
int textLimitW = availW - 2 * kPadH;
|
||||
int limitW = text.contains(' ') ? qMin(textLimitW, 700)
|
||||
: qMin(textLimitW, 1200);
|
||||
|
||||
int textW = bubbleW - 2 * kPadH;
|
||||
QFontMetrics fm(option.font);
|
||||
int contentW = calcMaxLineWidth(option.font, text, limitW);
|
||||
|
||||
QString author = index.data(MsgAuthorRole).toString();
|
||||
QFont boldFm = option.font;
|
||||
boldFm.setBold(true);
|
||||
int authorW = QFontMetrics(boldFm).horizontalAdvance(author);
|
||||
contentW = qMax(contentW, authorW);
|
||||
|
||||
if (hasQuote) {
|
||||
QString qAuthor = index.data(MsgQuoteAuthorRole).toString();
|
||||
int qAuthorW = QFontMetrics(boldFm).horizontalAdvance(qAuthor);
|
||||
contentW = qMax(contentW, qAuthorW + kQuoteBarW + kQuotePadH + 4);
|
||||
int qTextW = qMin(contentW + 8, limitW);
|
||||
int qWidth = calcMaxLineWidth(option.font, qText, qTextW);
|
||||
contentW = qMax(contentW, qWidth + kQuoteBarW + kQuotePadH + 4);
|
||||
}
|
||||
|
||||
int textW = qMax(200 - 2 * kPadH, contentW);
|
||||
textW = qMin(textW, textLimitW);
|
||||
int bubbleW = textW + 2 * kPadH;
|
||||
L.bubbleWidth = bubbleW;
|
||||
|
||||
int qH = 0;
|
||||
if (hasQuote) {
|
||||
int qTextW = bubbleW - 2 * kPadH - kQuoteBarW - kQuotePadH;
|
||||
int qTextH = measureTextHeight(option.font, qText, qTextW);
|
||||
int qOneLineH = QFontMetrics(option.font).height();
|
||||
int qOneLineH = fm.height();
|
||||
qH = kQuotePadV + qOneLineH + (qText.isEmpty() ? 0 : 2 + qTextH) + kQuotePadV;
|
||||
}
|
||||
|
||||
int textH = measureTextHeight(option.font, text, textW);
|
||||
int authorH = QFontMetrics(option.font).height();
|
||||
int authorH = fm.height();
|
||||
int bubbleH = kPadTop + authorH + 2 + (hasQuote ? qH + kQuoteGap : 0) + textH + kStatusH + kPadBot;
|
||||
L.totalHeight = qMax(bubbleH, kAvatar) + kItemGap;
|
||||
|
||||
int contentLeft = kMarginH + kAvatar + kGap;
|
||||
int contentLeft = L.narrow ? kMarginH : (kMarginH + kAvatar + kGap);
|
||||
|
||||
L.bubbleRect = QRect(contentLeft, 0, bubbleW, bubbleH);
|
||||
|
||||
@@ -85,12 +140,207 @@ MessageDelegate::Layout MessageDelegate::calcLayout(
|
||||
contentTop + textH + 2,
|
||||
textW, kStatusH);
|
||||
|
||||
int avatarY = L.bubbleRect.bottom() - kAvatar;
|
||||
L.avatarRect = QRect(kMarginH, avatarY, kAvatar, kAvatar);
|
||||
if (!L.narrow) {
|
||||
int avatarY = L.bubbleRect.bottom() - kAvatar;
|
||||
L.avatarRect = QRect(kMarginH, avatarY, kAvatar, kAvatar);
|
||||
}
|
||||
|
||||
return L;
|
||||
}
|
||||
|
||||
static int paraLayoutHeight(const QFont &font, const QString &text,
|
||||
int textWidth, int globalChar, qreal globalY,
|
||||
int yTop, int yBot, int xTop, int xBot,
|
||||
bool &foundStart, bool &foundEnd,
|
||||
int &startChar, int &endChar) {
|
||||
if (text.isEmpty()) return QFontMetrics(font).lineSpacing();
|
||||
|
||||
QTextLayout layout(text, font);
|
||||
layout.beginLayout();
|
||||
qreal localY = 0;
|
||||
while (true) {
|
||||
QTextLine line = layout.createLine();
|
||||
if (!line.isValid()) break;
|
||||
line.setLineWidth(textWidth);
|
||||
line.setPosition(QPointF(0, globalY + localY));
|
||||
localY += line.height();
|
||||
|
||||
if (!foundStart && globalY + localY > yTop) {
|
||||
startChar = globalChar + line.xToCursor(xTop);
|
||||
foundStart = true;
|
||||
}
|
||||
if (!foundEnd && globalY >= yBot) break;
|
||||
if (!foundEnd && globalY + localY >= yBot) {
|
||||
endChar = globalChar + line.xToCursor(xBot);
|
||||
foundEnd = true;
|
||||
}
|
||||
}
|
||||
layout.endLayout();
|
||||
return (int)ceil(localY);
|
||||
}
|
||||
|
||||
static void calcTextSelection(const QFont &font, const QString &text,
|
||||
int textWidth, const QPoint &p1, const QPoint &p2,
|
||||
int &startChar, int &endChar) {
|
||||
startChar = 0;
|
||||
endChar = 0;
|
||||
if (text.isEmpty() || textWidth <= 0) return;
|
||||
|
||||
int yTop = qMin(p1.y(), p2.y());
|
||||
int yBot = qMax(p1.y(), p2.y());
|
||||
int xTop = (p1.y() < p2.y()) ? p1.x() : p2.x();
|
||||
int xBot = (p1.y() < p2.y()) ? p2.x() : p1.x();
|
||||
|
||||
int globalChar = 0;
|
||||
qreal globalY = 0;
|
||||
bool foundStart = false, foundEnd = false;
|
||||
int paraStart = 0;
|
||||
|
||||
while (paraStart <= text.length()) {
|
||||
int paraEnd = text.indexOf('\n', paraStart);
|
||||
if (paraEnd < 0) paraEnd = text.length();
|
||||
QString para = text.mid(paraStart, paraEnd - paraStart);
|
||||
|
||||
int paraH = paraLayoutHeight(font, para, textWidth,
|
||||
globalChar, globalY,
|
||||
yTop, yBot, xTop, xBot,
|
||||
foundStart, foundEnd,
|
||||
startChar, endChar);
|
||||
globalChar += para.length();
|
||||
globalY += paraH;
|
||||
|
||||
if (paraEnd < text.length())
|
||||
globalChar++;
|
||||
|
||||
paraStart = paraEnd + 1;
|
||||
if (foundStart && foundEnd) break;
|
||||
}
|
||||
|
||||
if (!foundStart) startChar = 0;
|
||||
if (!foundEnd) endChar = text.length();
|
||||
if (startChar == endChar && endChar < text.length()) endChar = startChar + 1;
|
||||
if (startChar > endChar) qSwap(startChar, endChar);
|
||||
}
|
||||
|
||||
static void drawTextSelectionHighlight(QPainter *p, const QFont &font,
|
||||
const QString &text, int textWidth,
|
||||
const QRect &textRect,
|
||||
const QColor &selColor,
|
||||
int startChar, int endChar) {
|
||||
if (startChar >= endChar) return;
|
||||
|
||||
int globalChar = 0;
|
||||
qreal globalY = 0;
|
||||
int paraStart = 0;
|
||||
|
||||
while (paraStart <= text.length()) {
|
||||
int paraEnd = text.indexOf('\n', paraStart);
|
||||
if (paraEnd < 0) paraEnd = text.length();
|
||||
QString para = text.mid(paraStart, paraEnd - paraStart);
|
||||
|
||||
if (para.isEmpty()) {
|
||||
globalY += QFontMetrics(font).lineSpacing();
|
||||
} else {
|
||||
QTextLayout layout(para, font);
|
||||
layout.beginLayout();
|
||||
qreal localY = 0;
|
||||
while (true) {
|
||||
QTextLine line = layout.createLine();
|
||||
if (!line.isValid()) break;
|
||||
line.setLineWidth(textWidth);
|
||||
line.setPosition(QPointF(0, globalY + localY));
|
||||
|
||||
int ls = globalChar + line.textStart();
|
||||
int le = ls + line.textLength();
|
||||
localY += line.height();
|
||||
|
||||
if (ls >= endChar || le <= startChar) continue;
|
||||
|
||||
int cs = qMax(startChar, ls);
|
||||
int ce = qMin(endChar, le);
|
||||
|
||||
qreal x1 = line.cursorToX(cs - globalChar);
|
||||
qreal x2 = line.cursorToX(ce - globalChar);
|
||||
|
||||
QRectF hr(textRect.left() + x1,
|
||||
textRect.top() + globalY + line.y(),
|
||||
x2 - x1, line.height());
|
||||
p->fillRect(hr, selColor);
|
||||
}
|
||||
layout.endLayout();
|
||||
globalY += localY;
|
||||
}
|
||||
|
||||
globalChar += para.length();
|
||||
if (paraEnd < text.length())
|
||||
globalChar++;
|
||||
|
||||
paraStart = paraEnd + 1;
|
||||
}
|
||||
}
|
||||
|
||||
static void drawAnimatedEmojiOverlay(QPainter *p, const QFont &font,
|
||||
const QString &text, int textWidth,
|
||||
const QRect &textRect) {
|
||||
if (g_animatedEmojiMap.isEmpty()) return;
|
||||
|
||||
int globalChar = 0;
|
||||
qreal globalY = 0;
|
||||
int paraStart = 0;
|
||||
|
||||
while (paraStart <= text.length()) {
|
||||
int paraEnd = text.indexOf('\n', paraStart);
|
||||
if (paraEnd < 0) paraEnd = text.length();
|
||||
QString para = text.mid(paraStart, paraEnd - paraStart);
|
||||
|
||||
if (!para.isEmpty()) {
|
||||
QTextLayout layout(para, font);
|
||||
layout.beginLayout();
|
||||
qreal localY = 0;
|
||||
while (true) {
|
||||
QTextLine line = layout.createLine();
|
||||
if (!line.isValid()) break;
|
||||
line.setLineWidth((qreal)textWidth);
|
||||
line.setPosition(QPointF(0, globalY + localY));
|
||||
localY += line.height();
|
||||
|
||||
int ls = globalChar + line.textStart();
|
||||
int le = ls + line.textLength();
|
||||
|
||||
for (auto it = g_animatedEmojiMap.begin(); it != g_animatedEmojiMap.end(); ++it) {
|
||||
const QString &unicode = it.key();
|
||||
int pos = text.indexOf(unicode, ls);
|
||||
while (pos >= 0 && pos + unicode.length() <= le) {
|
||||
int linePos = pos - globalChar - line.textStart();
|
||||
int cp1 = linePos, cp2 = linePos + unicode.length();
|
||||
qreal x1 = line.cursorToX(&cp1);
|
||||
qreal x2 = line.cursorToX(&cp2);
|
||||
|
||||
LottieIcon *icon = AnimTimer::instance()->icon(it.value());
|
||||
if (icon && icon->isValid()) {
|
||||
int eW = qMax(1, (int)((x2 - x1) * 1.3));
|
||||
int eH = qMax(1, (int)(line.height() * 1.3));
|
||||
QImage frame = icon->renderFrame(icon->currentFrame(), QSize(eW, eH));
|
||||
qreal cx = x1 + (x2 - x1 - eW) * 0.5;
|
||||
qreal cy = (line.height() - eH) * 0.5;
|
||||
p->drawImage(QPointF(textRect.left() + cx, textRect.top() + line.y() + cy), frame);
|
||||
}
|
||||
pos = text.indexOf(unicode, pos + unicode.length());
|
||||
}
|
||||
}
|
||||
}
|
||||
layout.endLayout();
|
||||
globalY += localY;
|
||||
} else {
|
||||
globalY += QFontMetrics(font).lineSpacing();
|
||||
}
|
||||
|
||||
globalChar += para.length();
|
||||
if (paraEnd < text.length()) globalChar++;
|
||||
paraStart = paraEnd + 1;
|
||||
}
|
||||
}
|
||||
|
||||
void MessageDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option,
|
||||
const QModelIndex &index) const {
|
||||
painter->save();
|
||||
@@ -99,13 +349,35 @@ void MessageDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
|
||||
|
||||
Layout L = calcLayout(option, index);
|
||||
|
||||
auto *cv = qobject_cast<const ChatView *>(option.widget);
|
||||
bool singleTextSel = false;
|
||||
bool multiRowSel = false;
|
||||
if (cv && cv->hasSelection()) {
|
||||
if (cv->isRowSelected(index)) {
|
||||
if (cv->selStart() == cv->selEnd()) {
|
||||
singleTextSel = true;
|
||||
} else {
|
||||
multiRowSel = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drawBubble(painter, L);
|
||||
|
||||
if (multiRowSel) {
|
||||
painter->save();
|
||||
painter->setPen(Qt::NoPen);
|
||||
painter->setBrush(QColor(0x33, 0x90, 0xEC, 40));
|
||||
painter->drawRect(L.bubbleRect);
|
||||
painter->restore();
|
||||
}
|
||||
|
||||
if (index.data(MsgHasQuoteRole).toBool()) {
|
||||
drawQuote(painter, L, index, option);
|
||||
}
|
||||
|
||||
drawAvatar(painter, L, index);
|
||||
if (!L.narrow)
|
||||
drawAvatar(painter, L, index);
|
||||
|
||||
QFont authorFont = option.font;
|
||||
authorFont.setBold(true);
|
||||
@@ -118,8 +390,28 @@ void MessageDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
|
||||
QFont textFont = option.font;
|
||||
textFont.setPointSize(textFont.pointSize());
|
||||
painter->setFont(textFont);
|
||||
painter->drawText(L.textRect, Qt::AlignLeft | Qt::TextWordWrap,
|
||||
index.data(MsgTextRole).toString());
|
||||
|
||||
QString msgText = index.data(MsgTextRole).toString();
|
||||
painter->drawText(L.textRect, Qt::AlignLeft | Qt::TextWordWrap, msgText);
|
||||
|
||||
if (singleTextSel && cv) {
|
||||
const auto &ts = cv->textSelection();
|
||||
QPoint localP1(ts.clickPos.x() - L.textRect.left(),
|
||||
ts.clickPos.y() - L.textRect.top());
|
||||
QPoint localP2(ts.dragPos.x() - L.textRect.left(),
|
||||
ts.dragPos.y() - L.textRect.top());
|
||||
int startChar, endChar;
|
||||
calcTextSelection(textFont, msgText, L.textRect.width(),
|
||||
localP1, localP2, startChar, endChar);
|
||||
const_cast<ChatView *>(cv)->setTextSelChars(startChar, endChar);
|
||||
drawTextSelectionHighlight(painter, textFont, msgText,
|
||||
L.textRect.width(), L.textRect,
|
||||
QColor(0x33, 0x90, 0xEC, 60),
|
||||
startChar, endChar);
|
||||
}
|
||||
|
||||
drawAnimatedEmojiOverlay(painter, textFont, msgText,
|
||||
L.textRect.width(), L.textRect);
|
||||
|
||||
drawStatusBar(painter, L, index, option);
|
||||
|
||||
@@ -211,13 +503,24 @@ void MessageDelegate::drawStatusBar(QPainter *p, const Layout &L,
|
||||
}
|
||||
rx += colors.size() * 11 + 8;
|
||||
} else {
|
||||
QFont emojiFont = opt.font;
|
||||
emojiFont.setPointSize(emojiFont.pointSize() - 1);
|
||||
p->setFont(emojiFont);
|
||||
p->setPen(opt.palette.text().color());
|
||||
QRect emojiRect(rx, r.top(), 20, r.height());
|
||||
p->drawText(emojiRect, Qt::AlignLeft | Qt::AlignVCenter, emoji);
|
||||
rx += 20;
|
||||
LottieIcon *rIcon = nullptr;
|
||||
if (g_animatedEmojiMap.contains(emoji))
|
||||
rIcon = AnimTimer::instance()->icon(g_animatedEmojiMap[emoji]);
|
||||
|
||||
if (rIcon && rIcon->isValid()) {
|
||||
int iconSz = qMin((int)(20 * 1.3), r.height());
|
||||
QImage frame = rIcon->renderFrame(rIcon->currentFrame(), QSize(iconSz, iconSz));
|
||||
p->drawImage(rx, r.top() + (r.height() - iconSz) / 2, frame);
|
||||
rx += iconSz + 2;
|
||||
} else {
|
||||
QFont emojiFont = opt.font;
|
||||
emojiFont.setPointSize(emojiFont.pointSize() - 1);
|
||||
p->setFont(emojiFont);
|
||||
p->setPen(opt.palette.text().color());
|
||||
QRect emojiRect(rx, r.top(), 20, r.height());
|
||||
p->drawText(emojiRect, Qt::AlignLeft | Qt::AlignVCenter, emoji);
|
||||
rx += 20;
|
||||
}
|
||||
|
||||
QFont countFont = opt.font;
|
||||
countFont.setPointSize(countFont.pointSize() - 2);
|
||||
|
||||
Reference in new issue
Block a user