Files
utun2/tools/chatgui/src/messagedelegate.cpp
T

1589 lines
65 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "messagedelegate.h"
#include "chatview.h"
#include "debug_ui.h"
#include "emoji.h"
#include "animtimer.h"
#include "lottieicon.h"
#include "voiceplayback.h"
#include "sound_manager.h"
#include <cmath>
#include <QPainter>
#include <QPainterPath>
#include <QApplication>
#include <QAbstractItemView>
#include <QFontMetrics>
#include <QTextLayout>
#include <QMouseEvent>
#include <QMap>
#include <QDateTime>
#include <QTimer>
#include <QFile>
#include <QFileInfo>
#include <QDesktopServices>
#include <QUrl>
#include <QDebug>
#include <QImageReader>
#include <QPersistentModelIndex>
#include <QRegularExpression>
/* Один layout для размеров, отрисовки и hit-test длинных invite-ссылок. */
static qreal layoutInviteText(QTextLayout& layout, int width) {
QTextOption option; option.setWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere);
layout.setTextOption(option);
static const QRegularExpression pattern(QStringLiteral("utun://[A-Za-z0-9+/]+={0,2}"));
QVector<QTextLayout::FormatRange> formats;
auto matches = pattern.globalMatch(layout.text());
while (matches.hasNext()) {
auto match = matches.next();
QTextLayout::FormatRange range;
range.start = match.capturedStart(); range.length = match.capturedLength();
range.format.setForeground(QColor(0x33, 0x90, 0xEC)); range.format.setFontUnderline(true);
formats.append(range);
}
layout.setFormats(formats);
layout.beginLayout();
qreal y = 0;
while (true) {
auto line = layout.createLine();
if (!line.isValid()) break;
line.setLineWidth(qMax(1, width)); line.setPosition(QPointF(0, y)); y += line.height();
}
layout.endLayout();
return y;
}
QString MessageDelegate::inviteLinkAt(const QStyleOptionViewItem& option, const QModelIndex& index, const QPoint& point) const {
const QString text = index.data(MsgTextRole).toString();
if (!text.contains(QStringLiteral("utun://"))) return {};
const Layout geometry = calcLayout(option, index);
if (geometry.isVoice || geometry.isFile || geometry.isVideo || geometry.isImage || geometry.emojiOnly) return {};
const QPoint local = point - option.rect.topLeft() - geometry.textRect.topLeft();
if (!QRect(QPoint(0, 0), geometry.textRect.size()).contains(local)) return {};
QString display = text; display.replace('\n', QChar(0x2028));
QTextLayout layout(display, option.font);
layoutInviteText(layout, geometry.textRect.width());
for (int i = 0; i < layout.lineCount(); ++i) {
const auto line = layout.lineAt(i);
if (local.y() < line.y() || local.y() >= line.y() + line.height()) continue;
if (local.x() < line.x() || local.x() >= line.x() + line.naturalTextWidth()) return {};
const int position = line.xToCursor(local.x(), QTextLine::CursorOnCharacter);
for (const auto& range : layout.formats()) {
if (position >= range.start && position < range.start + range.length) return text.mid(range.start, range.length);
}
}
return {};
}
MessageDelegate::MessageDelegate(QObject *parent)
: QStyledItemDelegate(parent) {}
struct VoicePlayState {
uint64_t playbackId = 0;
bool loading = false;
bool playing = false;
bool paused = false;
qint64 startMs = 0;
float durationSec = 0.0f;
float pausedPosSec = 0.0f;
float displayFrac = 0.0f;
float displayElapsed = 0.0f;
};
static QMap<QString, VoicePlayState> s_voiceStates;
static QTimer* s_voiceTimer = nullptr;
static QAbstractItemView* s_voiceView = nullptr;
static void ensureVoiceTimer() {
if (!s_voiceTimer) {
s_voiceTimer = new QTimer();
s_voiceTimer->setInterval(16);
QObject::connect(s_voiceTimer, &QTimer::timeout, []() {
bool anyActive = false;
SoundManager* sm = SoundManager::instance();
for (auto it = s_voiceStates.begin(); it != s_voiceStates.end(); ++it) {
if (it->loading) { anyActive = true; continue; }
if (!it->playing && !it->paused) {
if (it->displayFrac > 0.001f || it->displayElapsed > 0.001f) anyActive = true;
continue;
}
if (it->paused) { anyActive = true; continue; }
if (!sm->isPcmPlaying(it->playbackId)) {
it->playing = false;
it->paused = false;
anyActive = true;
} else {
anyActive = true;
}
}
if (!anyActive) s_voiceTimer->stop();
if (s_voiceView) s_voiceView->viewport()->update();
});
}
}
bool MessageDelegate::editorEvent(QEvent *event, QAbstractItemModel *model,
const QStyleOptionViewItem &option,
const QModelIndex &index) {
if (event->type() != QEvent::MouseButtonRelease) return false;
Layout L = calcLayout(option, index);
if (L.isVideo) {
int dlState = index.data(MsgFileDownloadStateRole).toInt();
if (dlState == 2) { /* скачано — открыть плеер */
QString fp = index.data(MsgVoiceFileRole).toString();
if (!fp.isEmpty() && QFile::exists(fp) && m_videoOpenCb) {
m_videoOpenCb(fp);
return true;
}
/* файл пропал (вычистка кеша/удаление) — перекачиваем ниже */
}
if (dlState == 1) return false; /* скачивается */
return m_downloadCb && m_downloadCb(index);
}
if (L.isImage) {
int dlState = index.data(MsgFileDownloadStateRole).toInt();
if (dlState == 2) { /* скачано — открыть просмотрщик */
QString fp = index.data(MsgVoiceFileRole).toString();
if (!fp.isEmpty() && QFile::exists(fp) && m_imageOpenCb) {
m_imageOpenCb(fp);
return true;
}
/* файл пропал (вычистка кеша/удаление) — перекачиваем ниже */
}
if (dlState == 1) return false; /* скачивается */
return m_downloadCb && m_downloadCb(index);
}
if (L.isFile) {
int dlState = index.data(MsgFileDownloadStateRole).toInt();
qint64 msgId = index.data(MsgIdRole).toLongLong();
QString chId = index.data(MsgChannelIdRole).toString();
if (chId.startsWith(QStringLiteral("dm:"))) return false; /* PM сохраняется через контекстное меню. */
if (dlState == 2) { /* downloaded: open file */
QString fp = index.data(MsgVoiceFileRole).toString();
if (!fp.isEmpty() && QFile::exists(fp)) {
QDesktopServices::openUrl(QUrl::fromLocalFile(fp));
return true;
}
/* файл пропал (вычистка кеша/удаление) — перекачиваем ниже */
}
if (dlState == 1) return false; /* downloading — no action */
return m_downloadCb && m_downloadCb(index);
}
if (!L.isVoice) return false;
QMouseEvent* me = static_cast<QMouseEvent*>(event);
QPoint localPos = me->pos() - option.rect.topLeft();
bool onPlayBtn = L.voicePlayBtnRect.contains(localPos);
bool onWaveform = L.voiceWaveformRect.contains(localPos);
if (!onPlayBtn && !onWaveform) return false;
QString filePath = index.data(MsgVoiceFileRole).toString();
int dlState = index.data(MsgFileDownloadStateRole).toInt();
qint64 msgId = index.data(MsgIdRole).toLongLong();
QString chId = index.data(MsgChannelIdRole).toString();
GUI_DEBUG("editorEvent voice: playBtn=%d wave=%d file='%s' exists=%d dlState=%d msgId=%lld",
(int)onPlayBtn, (int)onWaveform, qPrintable(filePath), QFile::exists(filePath),
dlState, (long long)msgId);
/* not downloaded (or file evicted): start download (play will start on completion) */
bool fileMissing = filePath.isEmpty() || !QFile::exists(filePath);
if (fileMissing && dlState != 1) {
if (!m_downloadCb || !m_downloadCb(index)) return false;
model->setData(index, true, MsgVoiceDownloadPendingRole);
return true;
}
if (fileMissing) return false;
GUI_DEBUG("editorEvent voice click: filePath=%s exists=%d wave=%d",
qPrintable(filePath), QFile::exists(filePath), (int)onWaveform);
if (filePath.isEmpty()) return false;
ensureVoiceTimer();
VoicePlayState& st = s_voiceStates[filePath];
if (st.loading) return true;
QString durStr = index.data(MsgVoiceDurationRole).toString();
durStr.remove('s');
float dur = durStr.toFloat();
if (dur <= 0) dur = 1.0f;
if (onWaveform) {
float clickFrac = (float)(localPos.x() - L.voiceWaveformRect.x()) / L.voiceWaveformRect.width();
if (clickFrac < 0) clickFrac = 0; if (clickFrac > 1) clickFrac = 1;
float targetSec = clickFrac * dur;
qint64 now = QDateTime::currentMSecsSinceEpoch();
if (!st.playing && !st.paused) {
/* Start playback then seek */
st.loading = true;
QPersistentModelIndex persistent(index);
VoicePlayer::playOpusFile(filePath, this, [filePath, clickFrac, persistent](float actualDur, uint64_t playbackId) {
s_voiceStates[filePath].loading = false;
if (actualDur <= 0) return;
MessageDelegate::startAutoPlay(filePath, actualDur, playbackId);
float position = clickFrac * actualDur;
auto& state = s_voiceStates[filePath];
state.startMs -= (qint64)(position * 1000); state.pausedPosSec = position;
state.displayFrac = clickFrac; state.displayElapsed = position;
int rate = SoundManager::instance()->pcmSampleRate(playbackId);
if (rate > 0) SoundManager::instance()->seekPcm(playbackId, (unsigned long long)(position * rate));
if (persistent.isValid()) const_cast<QAbstractItemModel*>(persistent.model())->setData(persistent, 1, MsgVoicePlayStateRole);
});
s_voiceTimer->start();
} else {
/* Currently playing or paused — seek */
st.startMs = now - (qint64)(targetSec * 1000.0);
if (st.paused)
st.pausedPosSec = targetSec;
else if (!st.playing)
{ st.playing = true; s_voiceTimer->start(); }
st.displayFrac = (targetSec > 0 && dur > 0) ? (targetSec / dur) : 0;
st.displayElapsed = targetSec;
int sr = SoundManager::instance()->pcmSampleRate(st.playbackId);
if (sr > 0)
SoundManager::instance()->seekPcm(st.playbackId, (unsigned long long)(targetSec * sr));
}
return true;
}
/* --- Play button click (original logic) --- */
bool isOtherPlaying = false;
for (auto it = s_voiceStates.begin(); it != s_voiceStates.end(); ++it) {
if (it.key() != filePath && (it->playing || it->paused))
isOtherPlaying = true;
}
if (st.playing) {
/* Playing → Pause */
qint64 now = QDateTime::currentMSecsSinceEpoch();
st.pausedPosSec = (now - st.startMs) / 1000.0f;
st.playing = false;
st.paused = true;
SoundManager::instance()->pausePcm(st.playbackId);
model->setData(index, 2, MsgVoicePlayStateRole);
return true;
}
if (st.paused) {
/* Paused → Resume */
st.paused = false;
st.playing = true;
qint64 now = QDateTime::currentMSecsSinceEpoch();
st.startMs = now - (qint64)(st.pausedPosSec * 1000.0);
st.displayFrac = (dur > 0) ? (st.pausedPosSec / dur) : 0;
st.displayElapsed = st.pausedPosSec;
SoundManager::instance()->resumePcm(st.playbackId);
s_voiceTimer->start();
model->setData(index, 1, MsgVoicePlayStateRole);
return true;
}
/* Stopped or different file → stop old, play new */
if (isOtherPlaying) {
for (auto it = s_voiceStates.begin(); it != s_voiceStates.end(); ++it) {
it->playing = false; it->paused = false;
}
VoicePlayer::stop();
}
st.loading = true;
QPersistentModelIndex persistent(index);
VoicePlayer::playOpusFile(filePath, this, [filePath, persistent](float actualDur, uint64_t playbackId) {
s_voiceStates[filePath].loading = false;
if (actualDur <= 0) return;
MessageDelegate::startAutoPlay(filePath, actualDur, playbackId);
if (persistent.isValid()) const_cast<QAbstractItemModel*>(persistent.model())->setData(persistent, 1, MsgVoicePlayStateRole);
});
s_voiceTimer->start();
return true;
}
QSize MessageDelegate::sizeHint(const QStyleOptionViewItem &option,
const QModelIndex &index) const {
Layout L = calcLayout(option, index);
return QSize(L.viewWidth, L.totalHeight);
}
static int measureTextHeight(const QFont &font, const QString &text, int width) {
if (text.isEmpty() || width <= 0) return 0;
if (text.contains(QStringLiteral("utun://"))) {
QString display = text; display.replace('\n', QChar(0x2028));
QTextLayout layout(display, font);
return (int)std::ceil(layoutInviteText(layout, width));
}
QRect r = QFontMetrics(font).boundingRect(QRect(0, 0, width, 100000),
Qt::AlignLeft | Qt::TextWordWrap, text);
return r.height();
}
static QString formatFileSize(qint64 bytes) {
if (bytes < 1024) return QString("%1B").arg(bytes);
double kb = bytes / 1024.0;
if (kb < 1024.0) return QString("%1KB").arg(kb, 0, 'f', kb < 10.0 ? 1 : 0);
double mb = kb / 1024.0;
if (mb < 1024.0) return QString("%1MB").arg(mb, 0, 'f', mb < 10.0 ? 1 : 0);
double gb = mb / 1024.0;
return QString("%1GB").arg(gb, 0, 'f', 1);
}
static QString formatBitrate(qint64 bytes, qint64 durMs) {
if (bytes <= 0 || durMs <= 0) return QString();
qint64 kbps = bytes * 8 / durMs;
if (kbps >= 1000) return QString("%1Mbps").arg(kbps / 1000.0, 0, 'f', 1);
return QString("%1kbps").arg(kbps);
}
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
{
Layout L{};
L.viewWidth = 300;
if (option.widget) {
auto *v = qobject_cast<const QAbstractItemView *>(option.widget);
if (v) L.viewWidth = v->viewport()->width();
}
if (L.viewWidth <= 0) L.viewWidth = 300;
bool hasQuote = index.data(MsgHasQuoteRole).toBool();
QString text = index.data(MsgTextRole).toString();
L.isOutgoing = index.data(MsgIsOutgoingRole).toBool();
/* Voice message detection */
QString contentType = index.data(MsgContentTypeRole).toString();
L.isVoice = (contentType == "audio/opus");
if (L.isVoice) {
L.voiceDuration = index.data(MsgVoiceDurationRole).toString();
int pbs = 16; // play button size
int wh = 25; // waveform height
int th = 14; // text line height
L.bubbleWidth = 220;
int barW = 3, barPad = 4;
int bubbleH = kPadTop + wh + 4 + th + kPadBot;
L.totalHeight = qMax(bubbleH, kAvatar) + kItemGap;
int contentEdge = L.isOutgoing
? L.viewWidth - (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap))
: (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap));
int bx = L.isOutgoing ? (contentEdge - L.bubbleWidth - barW - barPad) : contentEdge;
L.bubbleRect = QRect(bx, 0, L.bubbleWidth, bubbleH);
L.mediaBarRect = QRect(L.isOutgoing ? bx + L.bubbleWidth + 1 : bx - barW - 1, 0, barW, bubbleH);
int dlState = index.data(MsgFileDownloadStateRole).toInt();
int bd = index.data(MsgMediaBlocksDoneRole).toInt();
int nb = index.data(MsgMediaNumBlocksRole).toInt();
L.mediaBarColor = (dlState == 2) ? 2 : (dlState == 1 || (bd > 0 && nb > 0)) ? 1 : 0;
if (dlState == 1 && nb > 0) L.progressFrac = (float)bd / nb;
if (dlState == 1) { L.progressBarRect = QRect(bx, bubbleH - 3, L.bubbleWidth, 3); }
L.voicePlayBtnRect = QRect(bx + kPadH, kPadTop + 4, pbs, pbs);
int wx = bx + kPadH + pbs + 4;
int ww = L.bubbleWidth - 2*kPadH - pbs - 4;
L.voiceWaveformRect = QRect(wx, kPadTop + 1, ww, wh);
L.voiceDurationRect = QRect(wx, kPadTop + wh + 4, ww - 40, th);
L.voiceTimeRect = QRect(wx + ww - 40, kPadTop + wh + 4, 40, th);
if (!index.data(MsgDeliveryStateRole).toString().isEmpty()) {
L.deliveryRect = QRect(bx, bubbleH + 2, L.bubbleWidth, th);
L.totalHeight += th + 2;
}
if (!L.narrow) {
int avatarY = L.bubbleRect.bottom() - kAvatar;
if (!L.isOutgoing)
L.avatarRect = QRect(kMarginH, avatarY, kAvatar, kAvatar);
else
L.avatarRect = QRect(L.viewWidth - kMarginH - kAvatar, avatarY, kAvatar, kAvatar);
}
return L;
}
/* Video message detection */
L.isVideo = (contentType == "video/mp4");
if (L.isVideo) {
int barW = 3, barPad = 4;
int thW = 200, thH = 112; /* 16:9 превью */
int infoH = 16; /* строка под пузырём: res·size·bitrate + время */
L.bubbleWidth = thW;
L.totalHeight = qMax(thH, kAvatar) + infoH + kItemGap;
int contentEdge = L.isOutgoing
? L.viewWidth - (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap))
: (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap));
int bx = L.isOutgoing ? (contentEdge - thW - barW - barPad) : contentEdge;
L.bubbleRect = QRect(bx, 0, thW, thH);
L.videoThumbRect = L.bubbleRect;
L.mediaBarRect = QRect(L.isOutgoing ? bx + thW + 1 : bx - barW - 1, 0, barW, thH);
L.statusRect = QRect(bx, thH + 2, thW, infoH - 2);
if (!index.data(MsgDeliveryStateRole).toString().isEmpty()) {
L.deliveryRect = QRect(bx, thH + infoH + 2, thW, infoH - 2);
L.totalHeight += infoH;
}
int dlState = index.data(MsgFileDownloadStateRole).toInt();
int bd = index.data(MsgMediaBlocksDoneRole).toInt();
int nb = index.data(MsgMediaNumBlocksRole).toInt();
L.mediaBarColor = (dlState == 2) ? 2 : (dlState == 1 || (bd > 0 && nb > 0)) ? 1 : 0;
if (dlState == 1 && nb > 0) L.progressFrac = (float)bd / nb;
if (dlState == 1) { L.progressBarRect = QRect(bx, thH - 3, thW, 3); }
L.videoPlayBtnRect = QRect(bx + thW / 2 - 24, thH / 2 - 24, 48, 48);
L.videoDurBadgeRect = QRect(bx + thW - 66, thH - 26, 58, 18);
if (!L.narrow) {
int avatarY = L.bubbleRect.bottom() - kAvatar;
if (!L.isOutgoing)
L.avatarRect = QRect(kMarginH, avatarY, kAvatar, kAvatar);
else
L.avatarRect = QRect(L.viewWidth - kMarginH - kAvatar, avatarY, kAvatar, kAvatar);
}
return L;
}
/* Image message detection */
L.isImage = contentType.startsWith("image/");
if (L.isImage) {
int barW = 3, barPad = 4;
int thW = 220, thH = 160;
L.bubbleWidth = thW;
L.totalHeight = qMax(thH, kAvatar) + kItemGap;
int contentEdge = L.isOutgoing
? L.viewWidth - (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap))
: (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap));
int bx = L.isOutgoing ? (contentEdge - thW - barW - barPad) : contentEdge;
L.bubbleRect = QRect(bx, 0, thW, thH);
L.imageThumbRect = L.bubbleRect;
L.mediaBarRect = QRect(L.isOutgoing ? bx + thW + 1 : bx - barW - 1, 0, barW, thH);
int dlState = index.data(MsgFileDownloadStateRole).toInt();
int bd = index.data(MsgMediaBlocksDoneRole).toInt();
int nb = index.data(MsgMediaNumBlocksRole).toInt();
L.mediaBarColor = (dlState == 2) ? 2 : (dlState == 1 || (bd > 0 && nb > 0)) ? 1 : 0;
if (dlState == 1 && nb > 0) L.progressFrac = (float)bd / nb;
if (dlState == 1) { L.progressBarRect = QRect(bx, thH - 3, thW, 3); }
L.imageBadgeRect = QRect(bx + thW - 150, thH - 26, 142, 18);
if (!L.narrow) {
int avatarY = L.bubbleRect.bottom() - kAvatar;
if (!L.isOutgoing)
L.avatarRect = QRect(kMarginH, avatarY, kAvatar, kAvatar);
else
L.avatarRect = QRect(L.viewWidth - kMarginH - kAvatar, avatarY, kAvatar, kAvatar);
}
return L;
}
/* File attachment detection */
L.isFile = (contentType == "application/octet-stream");
if (L.isFile) {
QFontMetrics fm(option.font);
int th = fm.height();
int ih = 24; /* icon height */
L.bubbleWidth = 230;
int barW = 3, barPad = 4;
int bubbleH = kPadTop + ih + 2 + th + kPadBot;
L.totalHeight = qMax(bubbleH, kAvatar) + kItemGap;
int contentEdge = L.isOutgoing
? L.viewWidth - (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap))
: (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap));
int bx = L.isOutgoing ? (contentEdge - L.bubbleWidth - barW - barPad) : contentEdge;
L.bubbleRect = QRect(bx, 0, L.bubbleWidth, bubbleH);
L.mediaBarRect = QRect(L.isOutgoing ? bx + L.bubbleWidth + 1 : bx - barW - 1, 0, barW, bubbleH);
int dlState = index.data(MsgFileDownloadStateRole).toInt();
int bd = index.data(MsgMediaBlocksDoneRole).toInt();
int nb = index.data(MsgMediaNumBlocksRole).toInt();
L.mediaBarColor = (dlState == 2) ? 2 : (dlState == 1 || (bd > 0 && nb > 0)) ? 1 : 0;
if (dlState == 1 && nb > 0) L.progressFrac = (float)bd / nb;
if (dlState == 1) { L.progressBarRect = QRect(bx, bubbleH - 3, L.bubbleWidth, 3); }
L.textRect = QRect(bx + kPadH + ih + 8, kPadTop, L.bubbleWidth - 2*kPadH - ih - 8, th);
L.statusRect = QRect(bx + kPadH + ih + 8 - 48, kPadTop + th + 2, L.bubbleWidth - 2*kPadH - ih - 8 + 48 - 40, th);
if (!L.narrow) {
int avatarY = L.bubbleRect.bottom() - kAvatar;
if (!L.isOutgoing)
L.avatarRect = QRect(kMarginH, avatarY, kAvatar, kAvatar);
else
L.avatarRect = QRect(L.viewWidth - kMarginH - kAvatar, avatarY, kAvatar, kAvatar);
}
return L;
}
L.emojiCount = detectEmojiOnly(text);
L.emojiOnly = (L.emojiCount >= 1);
if (L.emojiOnly) {
QFontMetrics fm(option.font);
int lineH = fm.lineSpacing();
int availW = L.viewWidth - kMarginH - kAvatar - kGap - kMarginH;
bool narrow = (availW < 400);
int contentEdge = !L.isOutgoing
? (narrow ? kMarginH : (kMarginH + kAvatar + kGap))
: L.viewWidth - (narrow ? kMarginH : (kMarginH + kAvatar + kGap));
int eSize = (L.emojiCount == 1) ? lineH * 4 : lineH * 2;
int emojiGap = lineH / 2;
int totalW = L.emojiCount * eSize + (L.emojiCount - 1) * emojiGap;
int x = !L.isOutgoing
? (contentEdge + kPadH)
: (contentEdge - kPadH - totalW);
L.emojiArea = QRect(x, kGap * 2, totalW, eSize);
L.totalHeight = eSize + kGap * 2 + kItemGap;
L.bubbleRect = QRect();
return L;
}
QString qText = hasQuote ? index.data(MsgQuoteTextRole).toString() : QString();
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);
QFontMetrics fm(option.font);
int contentW = calcMaxLineWidth(option.font, text, limitW);
if (!L.isOutgoing) {
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();
QFont boldFm = option.font;
boldFm.setBold(true);
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 = fm.height();
qH = kQuotePadV + qOneLineH + (qText.isEmpty() ? 0 : 2 + qTextH) + kQuotePadV;
}
int textH = measureTextHeight(option.font, text, textW);
int authorH = fm.height();
int bubbleH = L.isOutgoing
? kPadTop + (hasQuote ? qH + kQuoteGap : 0) + textH + kStatusH + kPadBot
: kPadTop + authorH + 2 + (hasQuote ? qH + kQuoteGap : 0) + textH + kStatusH + kPadBot;
L.totalHeight = qMax(bubbleH, kAvatar) + kItemGap;
int contentEdge = L.isOutgoing
? L.viewWidth - (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap))
: (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap));
if (!L.isOutgoing)
L.bubbleRect = QRect(contentEdge, 0, bubbleW, bubbleH);
else
L.bubbleRect = QRect(contentEdge - bubbleW, 0, bubbleW, bubbleH);
int contentTop = kPadTop;
if (!L.isOutgoing) {
L.authorRect = QRect(contentEdge + kPadH, contentTop, textW, authorH);
contentTop += authorH + 2;
}
if (hasQuote) {
int qX = !L.isOutgoing
? (contentEdge + kPadH)
: (contentEdge - kPadH - (bubbleW - 2 * kPadH));
L.quoteRect = QRect(qX, contentTop, bubbleW - 2 * kPadH, qH);
if (!L.isOutgoing)
L.quoteBarRect = QRect(L.quoteRect.left(), L.quoteRect.top(), kQuoteBarW, qH);
else
L.quoteBarRect = QRect(L.quoteRect.right() - kQuoteBarW, L.quoteRect.top(), kQuoteBarW, qH);
int tx = !L.isOutgoing
? (L.quoteBarRect.right() + kQuotePadH)
: (L.quoteRect.left() + kQuotePadH);
L.quoteAuthorRect = QRect(tx, L.quoteRect.top() + kQuotePadV,
L.quoteRect.width() - kQuoteBarW - kQuotePadH,
QFontMetrics(option.font).height());
L.quoteTextRect = QRect(tx,
L.quoteAuthorRect.bottom() + 2,
L.quoteAuthorRect.width(),
qH - kQuotePadV - QFontMetrics(option.font).height() - 2 - kQuotePadV);
contentTop += qH + kQuoteGap;
}
int txRect = !L.isOutgoing
? (contentEdge + kPadH)
: (contentEdge - kPadH - textW);
L.textRect = QRect(txRect, contentTop, textW, textH);
L.statusRect = QRect(txRect, contentTop + textH + 2, textW, kStatusH);
if (!L.narrow) {
int avatarY = L.bubbleRect.bottom() - kAvatar;
if (!L.isOutgoing)
L.avatarRect = QRect(kMarginH, avatarY, kAvatar, kAvatar);
else
L.avatarRect = QRect(L.viewWidth - kMarginH - kAvatar, 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();
painter->translate(option.rect.topLeft());
painter->setClipRect(QRect(QPoint(0, 0), option.rect.size()));
Layout L = calcLayout(option, index);
if (L.deliveryRect.isValid()) {
QFont font = option.font;
font.setPointSize(qMax(1, font.pointSize() - 2));
painter->setFont(font);
painter->setPen(option.palette.text().color().lighter(150));
const auto text = QFontMetrics(font).elidedText(index.data(MsgDeliveryStateRole).toString(), Qt::ElideRight, L.deliveryRect.width());
painter->drawText(L.deliveryRect, Qt::AlignRight | Qt::AlignVCenter, text);
}
if (L.emojiOnly) {
QString msgText = index.data(MsgTextRole).toString();
QString trimmed = msgText.trimmed();
QFontMetrics fm(option.font);
int lineH = fm.lineSpacing();
int eSize = (L.emojiCount == 1) ? lineH * 4 : lineH * 2;
int x = L.emojiArea.left();
int y = L.emojiArea.top();
int pos = 0;
while (pos < trimmed.length()) {
if (trimmed.at(pos).isSpace()) { ++pos; continue; }
QString emojiStr;
bool found = false;
for (const auto &cat : builtinEmojiSet()) {
for (const auto &e : cat.items) {
if (e.unicode.length() > trimmed.length() - pos) continue;
if (QStringView(trimmed).mid(pos, e.unicode.length()) == e.unicode) {
emojiStr = e.unicode;
pos += e.unicode.length();
found = true;
break;
}
}
if (found) break;
}
if (emojiStr.isEmpty()) { ++pos; continue; }
bool animated = g_animatedEmojiMap.contains(emojiStr);
if (animated) {
LottieIcon *icon = AnimTimer::instance()->icon(g_animatedEmojiMap[emojiStr]);
if (icon && icon->isValid()) {
QImage frame = icon->renderFrame(icon->currentFrame(), QSize(eSize, eSize));
painter->drawImage(x, y, frame);
} else {
QFont big = option.font;
big.setPointSize((int)(big.pointSize() * (L.emojiCount == 1 ? 4.0 : 2.0)));
painter->setFont(big);
painter->setPen(option.palette.text().color());
painter->drawText(QRect(x, y, eSize, eSize), Qt::AlignCenter, emojiStr);
}
} else {
QFont big = option.font;
big.setPointSize((int)(big.pointSize() * (L.emojiCount == 1 ? 4.0 : 2.0)));
painter->setFont(big);
painter->setPen(option.palette.text().color());
painter->drawText(QRect(x, y, eSize, eSize), Qt::AlignCenter, emojiStr);
}
x += eSize + lineH / 2;
}
painter->restore();
return;
}
if (L.isVoice) {
if (option.widget) s_voiceView = (QAbstractItemView*)option.widget;
drawBubble(painter, L, option);
if (!L.narrow) drawAvatar(painter, L, index);
QString filePath = index.data(MsgVoiceFileRole).toString();
VoicePlayState copySt;
VoicePlayState* st = &copySt;
if (!filePath.isEmpty() && s_voiceStates.contains(filePath)) {
st = &s_voiceStates[filePath];
} else {
int stateVal = index.data(MsgVoicePlayStateRole).toInt();
copySt.playing = (stateVal == 1);
st = &copySt;
}
bool isPlaying = st->playing;
bool isPaused = st->paused;
/* compute elapsed/frac for progress */
float elapsed = 0, targetFrac = 0, durSec = st->durationSec;
if (durSec <= 0) {
QString ds = index.data(MsgVoiceDurationRole).toString();
ds.remove('s'); durSec = ds.toFloat();
}
if (isPaused) {
elapsed = st->pausedPosSec;
} else if (isPlaying) {
SoundManager* sm = SoundManager::instance();
if (sm->isPcmPlaying(st->playbackId)) {
int sr = sm->pcmSampleRate(st->playbackId);
if (sr > 0) elapsed = (float)((double)sm->pcmCursor(st->playbackId) / sr);
}
if (elapsed <= 0) {
qint64 now = QDateTime::currentMSecsSinceEpoch();
elapsed = (now - st->startMs) / 1000.0f;
}
if (elapsed > durSec) elapsed = durSec;
}
targetFrac = (durSec > 0) ? (elapsed / durSec) : 0;
if (targetFrac < 0) targetFrac = 0; if (targetFrac > 1) targetFrac = 1;
st->displayFrac += (targetFrac - st->displayFrac) * 0.15f;
st->displayElapsed += (elapsed - st->displayElapsed) * 0.15f;
float frac = st->displayFrac;
QRectF playR = L.voicePlayBtnRect;
/* Play/Pause icon (small — 10px triangle) */
painter->setPen(Qt::NoPen);
painter->setBrush(QColor(0x33, 0x90, 0xEC));
if (isPlaying || isPaused) {
qreal bw = playR.width() / 5.0;
qreal gap = playR.width() / 8.0;
qreal top = playR.center().y() - playR.height() / 3.0;
qreal bot = playR.center().y() + playR.height() / 3.0;
painter->drawRoundedRect(QRectF(playR.center().x() - gap - bw, top, bw, bot - top), 1, 1);
painter->drawRoundedRect(QRectF(playR.center().x() + gap, top, bw, bot - top), 1, 1);
} else {
QPainterPath path;
qreal cx = playR.center().x() - playR.width() / 6.0;
qreal cy = playR.center().y();
qreal hs = playR.height() / 2.5;
path.moveTo(cx, cy - hs);
path.lineTo(cx + hs * 1.5, cy);
path.lineTo(cx, cy + hs);
path.closeSubpath();
painter->drawPath(path);
}
/* Waveform (25px) */
QList<QVariant> wf = index.data(MsgVoiceWaveformRole).toList();
int wfN = wf.size();
QRect wfR = L.voiceWaveformRect;
if (wfN > 0) {
float barW = (float)wfR.width() / wfN;
int ph = wfR.height() - 3;
for (int i = 0; i < wfN; i++) {
float logV = wf[i].toFloat(); /* already 0..1 normalized */
int barH = 2 + (int)(logV * (ph - 2));
int x = wfR.left() + (int)(i * barW);
int y = wfR.top() + ph - barH;
int bw = qMax(2, (int)(barW * 0.8f));
bool before = wfN > 0 && (float)i / wfN <= frac;
QColor played(0x33, 0x90, 0xEC);
QColor unplayed(0x33, 0x90, 0xEC, 80);
QColor idle(0x70, 0x70, 0x78, 130);
painter->setBrush((isPlaying || isPaused) ? (before ? played : unplayed) : idle);
painter->drawRect(QRect(x, y, bw, barH));
}
}
/* Progress bar (3px, at bottom of waveform area) */
int ph = 3;
int pbY = wfR.bottom() - ph;
painter->setBrush(QColor(0x44, 0x44, 0x44));
painter->drawRoundedRect(QRect(wfR.left(), pbY, wfR.width(), ph), 2, 2);
if (frac > 0) {
painter->setBrush(QColor(0x33, 0x90, 0xEC));
painter->drawRoundedRect(QRect(wfR.left(), pbY, (int)(wfR.width() * frac), ph), 2, 2);
}
/* Duration + elapsed on bottom line */
QFont timeFont = option.font;
timeFont.setPointSize(timeFont.pointSize() - 2);
painter->setFont(timeFont);
painter->setPen(option.palette.text().color().lighter(150));
int eSec = (int)st->displayElapsed, dSec = (int)durSec;
QString durText = QString("%1:%2 / %3:%4")
.arg(eSec / 60).arg(eSec % 60, 2, 10, QChar('0'))
.arg(dSec / 60).arg(dSec % 60, 2, 10, QChar('0'));
painter->drawText(L.voiceDurationRect, Qt::AlignLeft | Qt::AlignVCenter, durText);
/* Timestamp */
QFont tsFont = option.font;
tsFont.setPointSize(tsFont.pointSize() - 1);
painter->setFont(tsFont);
painter->drawText(L.voiceTimeRect, Qt::AlignRight | Qt::AlignVCenter,
index.data(MsgTimeRole).toString());
/* media bar (left edge, 3px) */
{ QColor barC = L.mediaBarColor == 2 ? QColor(0x40, 0xC0, 0x40) : L.mediaBarColor == 1 ? QColor(0xF0, 0xC0, 0x40) : QColor(0x80, 0x80, 0x80);
painter->setPen(Qt::NoPen); painter->setBrush(barC); painter->drawRect(L.mediaBarRect); }
/* progress bar (bottom edge, 3px) */
if (L.progressBarRect.isValid()) {
painter->setBrush(QColor(0x44, 0x44, 0x44));
painter->drawRoundedRect(L.progressBarRect, 2, 2);
int pw = (int)(L.progressBarRect.width() * L.progressFrac);
if (pw > 0) { painter->setBrush(QColor(0x33, 0x90, 0xEC)); painter->drawRoundedRect(QRect(L.progressBarRect.left(), L.progressBarRect.top(), pw, 3), 2, 2); }
}
painter->restore();
return;
}
if (L.isVideo) {
drawBubble(painter, L, option);
if (!L.narrow) drawAvatar(painter, L, index);
QString filePath = index.data(MsgVoiceFileRole).toString();
int dlState = index.data(MsgFileDownloadStateRole).toInt();
QString thumbPath = filePath + ".jpg";
painter->save();
painter->setRenderHint(QPainter::Antialiasing);
QPainterPath clip;
clip.addRoundedRect(L.videoThumbRect, kRadius, kRadius);
painter->setClipPath(clip);
QPixmap thumb;
if (!thumbPath.isEmpty() && QFile::exists(thumbPath))
thumb.load(thumbPath);
if (!thumb.isNull()) {
QPixmap scaled = thumb.scaled(L.videoThumbRect.size(), Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation);
int dx = (scaled.width() - L.videoThumbRect.width()) / 2;
int dy = (scaled.height() - L.videoThumbRect.height()) / 2;
painter->drawPixmap(L.videoThumbRect.topLeft(), scaled, QRect(dx, dy, L.videoThumbRect.width(), L.videoThumbRect.height()));
} else {
painter->fillRect(L.videoThumbRect, QColor(30, 30, 35));
QFont iconFont = option.font;
iconFont.setPointSize(iconFont.pointSize() + 10);
painter->setFont(iconFont);
painter->setPen(QColor(150, 150, 150));
painter->drawText(L.videoThumbRect, Qt::AlignCenter, QString::fromUtf8("\xF0\x9F\x8E\xAC"));
}
/* play-оверлей */
if (dlState == 2) {
painter->setPen(Qt::NoPen);
painter->setBrush(QColor(0, 0, 0, 110));
painter->drawEllipse(L.videoPlayBtnRect);
QPainterPath tri;
int cx = L.videoPlayBtnRect.center().x() - L.videoPlayBtnRect.width() / 12;
int cy = L.videoPlayBtnRect.center().y();
int hs = L.videoPlayBtnRect.height() / 4;
tri.moveTo(cx, cy - hs);
tri.lineTo(cx + hs * 2, cy);
tri.lineTo(cx, cy + hs);
tri.closeSubpath();
painter->setBrush(Qt::white);
painter->drawPath(tri);
}
/* бейдж: длительность (m:ss) */
QString durStr = index.data(MsgVoiceDurationRole).toString();
if (!durStr.isEmpty()) {
QFont durFont = option.font;
durFont.setPointSize(durFont.pointSize() - 1);
painter->setFont(durFont);
painter->setPen(Qt::NoPen);
painter->setBrush(QColor(0, 0, 0, 160));
painter->drawRoundedRect(L.videoDurBadgeRect, 5, 5);
painter->setPen(Qt::white);
painter->drawText(L.videoDurBadgeRect, Qt::AlignCenter, durStr);
}
painter->restore();
/* progress overlay (скачивание) */
if (dlState == 1) {
painter->setBrush(QColor(0, 0, 0, 140));
painter->drawRect(L.videoThumbRect);
QFont pfont = option.font;
pfont.setPointSize(pfont.pointSize() + 2);
painter->setFont(pfont);
painter->setPen(Qt::white);
painter->drawText(L.videoThumbRect, Qt::AlignCenter,
QStringLiteral("%1%").arg((int)(L.progressFrac * 100)));
}
/* media bar (левая кромка) */
{ QColor barC = L.mediaBarColor == 2 ? QColor(0x40, 0xC0, 0x40) : L.mediaBarColor == 1 ? QColor(0xF0, 0xC0, 0x40) : QColor(0x80, 0x80, 0x80);
painter->setPen(Qt::NoPen); painter->setBrush(barC); painter->drawRect(L.mediaBarRect); }
if (L.progressBarRect.isValid()) {
painter->setBrush(QColor(0x44, 0x44, 0x44));
painter->drawRoundedRect(L.progressBarRect, 2, 2);
int pw = (int)(L.progressBarRect.width() * L.progressFrac);
if (pw > 0) { painter->setBrush(QColor(0x33, 0x90, 0xEC)); painter->drawRoundedRect(QRect(L.progressBarRect.left(), L.progressBarRect.top(), pw, 3), 2, 2); }
}
/* строка под пузырём: res · size · bitrate + время */
{
qint64 fsz = index.data(MsgFileSizeRole).toLongLong();
qint64 durMs = index.data(MsgVideoDurationMsRole).toLongLong();
int vw = index.data(MsgVideoWidthRole).toInt();
int vh = index.data(MsgVideoHeightRole).toInt();
QStringList info;
if (vw > 0 && vh > 0) info << QString("%1×%2").arg(vw).arg(vh);
QString sz = formatFileSize(fsz);
if (!sz.isEmpty()) info << sz;
QString br = formatBitrate(fsz, durMs);
if (!br.isEmpty()) info << br;
QFont smallFont = option.font;
smallFont.setPointSize(smallFont.pointSize() - 2);
painter->setFont(smallFont);
painter->setPen(QColor(0x80, 0x80, 0x80));
if (!info.isEmpty())
painter->drawText(L.statusRect, Qt::AlignLeft | Qt::AlignVCenter, info.join(" \xC2\xB7 "));
painter->drawText(L.statusRect, Qt::AlignRight | Qt::AlignVCenter,
index.data(MsgTimeRole).toString());
}
painter->restore();
return;
}
if (L.isImage) {
drawBubble(painter, L, option);
if (!L.narrow) drawAvatar(painter, L, index);
int dlState = index.data(MsgFileDownloadStateRole).toInt();
QString filePath = index.data(MsgVoiceFileRole).toString();
painter->save();
painter->setRenderHint(QPainter::Antialiasing);
QPainterPath clip;
clip.addRoundedRect(L.imageThumbRect, kRadius, kRadius);
painter->setClipPath(clip);
QPixmap img;
if (!filePath.isEmpty() && QFile::exists(filePath)) {
QImageReader reader(filePath);
reader.setAutoTransform(true);
reader.setScaledSize(L.imageThumbRect.size() * 2);
QImage im = reader.read();
if (!im.isNull()) img = QPixmap::fromImage(im);
}
if (!img.isNull()) {
painter->fillRect(L.imageThumbRect, QColor(20, 20, 24));
QPixmap scaled = img.scaled(L.imageThumbRect.size(), Qt::KeepAspectRatio, Qt::SmoothTransformation);
int dx = (L.imageThumbRect.width() - scaled.width()) / 2;
int dy = (L.imageThumbRect.height() - scaled.height()) / 2;
painter->drawPixmap(L.imageThumbRect.left() + dx, L.imageThumbRect.top() + dy, scaled);
} else {
painter->fillRect(L.imageThumbRect, QColor(30, 30, 35));
QFont iconFont = option.font;
iconFont.setPointSize(iconFont.pointSize() + 10);
painter->setFont(iconFont);
painter->setPen(QColor(150, 150, 150));
painter->drawText(L.imageThumbRect, Qt::AlignCenter, QString::fromUtf8("\xF0\x9F\x93\xB7"));
}
/* бейдж: разрешение · размер */
qint64 fsz = index.data(MsgFileSizeRole).toLongLong();
int iw = index.data(MsgImageWidthRole).toInt();
int ih = index.data(MsgImageHeightRole).toInt();
QString res = (iw > 0 && ih > 0) ? QString("%1×%2").arg(iw).arg(ih) : QString();
QString sizeStr = formatFileSize(fsz);
QString badge = res.isEmpty() ? sizeStr : res + " \xC2\xB7 " + sizeStr;
if (!badge.isEmpty()) {
QFont durFont = option.font;
durFont.setPointSize(durFont.pointSize() - 1);
painter->setFont(durFont);
painter->setPen(Qt::NoPen);
painter->setBrush(QColor(0, 0, 0, 160));
painter->drawRoundedRect(L.imageBadgeRect, 5, 5);
painter->setPen(Qt::white);
painter->drawText(L.imageBadgeRect, Qt::AlignCenter, badge);
}
painter->restore();
/* progress overlay (скачивание) */
if (dlState == 1) {
painter->setBrush(QColor(0, 0, 0, 140));
painter->drawRect(L.imageThumbRect);
QFont pfont = option.font;
pfont.setPointSize(pfont.pointSize() + 2);
painter->setFont(pfont);
painter->setPen(Qt::white);
painter->drawText(L.imageThumbRect, Qt::AlignCenter,
QStringLiteral("%1%").arg((int)(L.progressFrac * 100)));
}
/* media bar (левая кромка) */
{ QColor barC = L.mediaBarColor == 2 ? QColor(0x40, 0xC0, 0x40) : L.mediaBarColor == 1 ? QColor(0xF0, 0xC0, 0x40) : QColor(0x80, 0x80, 0x80);
painter->setPen(Qt::NoPen); painter->setBrush(barC); painter->drawRect(L.mediaBarRect); }
if (L.progressBarRect.isValid()) {
painter->setBrush(QColor(0x44, 0x44, 0x44));
painter->drawRoundedRect(L.progressBarRect, 2, 2);
int pw = (int)(L.progressBarRect.width() * L.progressFrac);
if (pw > 0) { painter->setBrush(QColor(0x33, 0x90, 0xEC)); painter->drawRoundedRect(QRect(L.progressBarRect.left(), L.progressBarRect.top(), pw, 3), 2, 2); }
}
painter->restore();
return;
}
if (L.isFile) {
drawBubble(painter, L, option);
if (!L.narrow) drawAvatar(painter, L, index);
int dlState = index.data(MsgFileDownloadStateRole).toInt();
/* File icon based on download state: 0=not_started, 1=downloading, 2=downloaded */
QFont iconFont = option.font;
iconFont.setPointSize(iconFont.pointSize() + 6);
painter->setFont(iconFont);
painter->setPen(option.palette.text().color());
QRect iconRect(L.bubbleRect.left() + kPadH, kPadTop, 24, 24);
if (dlState == 2)
painter->drawText(iconRect, Qt::AlignCenter, QString::fromUtf8("\xE2\x9C\x85"));
else
painter->drawText(iconRect, Qt::AlignCenter, QString::fromUtf8("\xE2\xAC\x87\xEF\xB8\x8F"));
QFont fileFont = option.font;
fileFont.setBold(true);
painter->setFont(fileFont);
painter->setPen(option.palette.text().color());
QString displayName = index.data(MsgFileDisplayNameRole).toString();
painter->drawText(L.textRect, Qt::AlignLeft | Qt::AlignVCenter, displayName);
QFont sizeFont = option.font;
sizeFont.setPointSize(sizeFont.pointSize() - 2);
painter->setFont(sizeFont);
painter->setPen(QColor(0x80, 0x80, 0x80));
qint64 fsz = index.data(MsgFileSizeRole).toLongLong();
QString sizeStr = formatFileSize(fsz);
QString stateStr = (dlState == 2) ? QString::fromUtf8("\xE2\x9C\x85") : (dlState == 1) ? "Downloading..." : "Download";
QString deliveryState = index.data(MsgDeliveryStateRole).toString();
if (!deliveryState.isEmpty()) stateStr = deliveryState;
QString info = sizeStr + " \xC2\xB7 " + stateStr;
int nameH = QFontMetrics(fileFont).height();
QRect sizeRect(L.textRect.left(), L.textRect.top() + nameH, L.textRect.width(), QFontMetrics(sizeFont).height());
painter->drawText(sizeRect, Qt::AlignLeft | Qt::AlignVCenter, info);
/* media bar (left edge, 3px) */
{ QColor barC = L.mediaBarColor == 2 ? QColor(0x40, 0xC0, 0x40) : L.mediaBarColor == 1 ? QColor(0xF0, 0xC0, 0x40) : QColor(0x80, 0x80, 0x80);
painter->setPen(Qt::NoPen); painter->setBrush(barC); painter->drawRect(L.mediaBarRect); }
/* progress bar (bottom edge, 3px) */
if (L.progressBarRect.isValid()) {
painter->setBrush(QColor(0x44, 0x44, 0x44));
painter->drawRoundedRect(L.progressBarRect, 2, 2);
int pw = (int)(L.progressBarRect.width() * L.progressFrac);
if (pw > 0) { painter->setBrush(QColor(0x33, 0x90, 0xEC)); painter->drawRoundedRect(QRect(L.progressBarRect.left(), L.progressBarRect.top(), pw, 3), 2, 2); }
}
painter->restore();
return;
}
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, option);
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);
}
if (!L.narrow)
drawAvatar(painter, L, index);
if (!L.isOutgoing) {
QFont authorFont = option.font;
authorFont.setBold(true);
painter->setFont(authorFont);
painter->setPen(QColor(0x33, 0x90, 0xEC));
painter->drawText(L.authorRect, Qt::AlignLeft | Qt::AlignVCenter,
index.data(MsgAuthorRole).toString());
}
painter->setPen(option.palette.text().color());
QFont textFont = option.font;
textFont.setPointSize(textFont.pointSize());
painter->setFont(textFont);
QString msgText = index.data(MsgTextRole).toString();
if (msgText.contains(QStringLiteral("utun://"))) {
QString display = msgText; display.replace('\n', QChar(0x2028));
QTextLayout layout(display, textFont);
layoutInviteText(layout, L.textRect.width());
layout.draw(painter, L.textRect.topLeft());
} else 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);
painter->restore();
}
void MessageDelegate::drawQuote(QPainter *p, const Layout &L,
const QModelIndex &index,
const QStyleOptionViewItem &opt) const {
p->save();
p->setPen(Qt::NoPen);
p->setBrush(QColor(0xFF, 0xEE, 0xEE));
p->drawRoundedRect(L.quoteRect, 5, 5);
p->setPen(Qt::NoPen);
p->setBrush(QColor(0xE0, 0x40, 0x40));
p->drawRect(L.quoteBarRect);
QFont boldFont = opt.font;
boldFont.setBold(true);
p->setFont(boldFont);
p->setPen(QColor(0xE0, 0x40, 0x40));
p->drawText(L.quoteAuthorRect, Qt::AlignLeft | Qt::AlignVCenter,
index.data(MsgQuoteAuthorRole).toString());
p->setFont(opt.font);
p->setPen(QColor(0x99, 0x55, 0x55));
p->drawText(L.quoteTextRect, Qt::AlignLeft | Qt::TextWordWrap,
index.data(MsgQuoteTextRole).toString());
p->restore();
}
void MessageDelegate::drawBubble(QPainter *p, const Layout &L, const QStyleOptionViewItem &opt) const {
p->save();
p->setRenderHint(QPainter::Antialiasing);
QRectF b = L.bubbleRect;
qreal r = kRadius;
qreal t = kTail;
QPainterPath body;
body.addRoundedRect(b, r, r);
QPainterPath tail;
if (!L.isOutgoing) {
qreal tx = b.left();
qreal ty = b.bottom();
tail.moveTo(tx, ty - r);
tail.lineTo(tx - t, ty - t / 2.0);
tail.lineTo(tx + r, ty);
} else {
qreal tx = b.right();
qreal ty = b.bottom();
tail.moveTo(tx, ty - r);
tail.lineTo(tx + t, ty - t / 2.0);
tail.lineTo(tx - r, ty);
}
tail.closeSubpath();
QPainterPath combined = body.united(tail);
QColor bubbleFill;
if (!L.isOutgoing) {
QColor base = opt.palette.window().color();
bubbleFill = base.lighter(135);
} else {
QColor base = opt.palette.window().color();
bubbleFill = base.lighter(135);
bubbleFill.setRed(qMax(0, bubbleFill.red() - 10));
bubbleFill.setGreen(qMin(255, bubbleFill.green() + 5));
bubbleFill.setBlue(qMax(0, bubbleFill.blue() - 10));
}
p->setPen(Qt::NoPen);
p->setBrush(bubbleFill);
p->drawPath(combined);
p->restore();
}
void MessageDelegate::drawStatusBar(QPainter *p, const Layout &L,
const QModelIndex &index,
const QStyleOptionViewItem &opt) const {
p->save();
QRect r = L.statusRect;
QVariant reactionsVar = index.data(MsgReactionsRole);
QVariantList reactions = reactionsVar.toList();
int rx = r.left();
if (!reactions.isEmpty()) {
for (const QVariant &rv : reactions) {
QVariantMap rm = rv.toMap();
QString emoji = rm["emoji"].toString();
int count = rm["count"].toInt();
QVariant colorsVar = rm["colors"];
QList<QColor> colors = colorsVar.value<QList<QColor>>();
if (count < 4 && !colors.isEmpty()) {
int iconSize = 16;
for (int i = colors.size() - 1; i >= 0; --i) {
int cx = rx + i * 11;
p->setPen(Qt::NoPen);
p->setBrush(colors[i]);
p->drawEllipse(cx, r.top() + 1, iconSize, iconSize);
}
rx += colors.size() * 11 + 8;
} else {
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);
p->setFont(countFont);
p->setPen(QColor(0x80, 0x80, 0x80));
QRect cntRect(rx, r.top(), 24, r.height());
p->drawText(cntRect, Qt::AlignLeft | Qt::AlignVCenter,
QString::number(count));
rx = cntRect.right() + 8;
}
}
}
QFont smallFont = opt.font;
smallFont.setPointSize(smallFont.pointSize() - 2);
p->setFont(smallFont);
p->setPen(QColor(0x80, 0x80, 0x80));
QString statusText;
if (index.data(MsgEditedRole).toBool()) {
statusText = QString::fromUtf8("изменено ");
}
statusText += index.data(MsgTimeRole).toString();
p->drawText(QRect(r.left(), r.top(), r.width(), r.height()),
Qt::AlignRight | Qt::AlignVCenter, statusText);
p->restore();
}
void MessageDelegate::drawAvatar(QPainter *p, const Layout &L,
const QModelIndex &index) const {
QPixmap av = index.data(MsgAvatarRole).value<QPixmap>();
if (av.isNull()) return;
p->save();
p->setRenderHint(QPainter::Antialiasing);
QPainterPath clip;
clip.addEllipse(L.avatarRect);
p->setClipPath(clip);
p->drawPixmap(L.avatarRect, av);
p->restore();
}
void MessageDelegate::startAutoPlay(const QString& filePath, float actualDurationSec, uint64_t playbackId) {
if (actualDurationSec <= 0) return;
ensureVoiceTimer();
VoicePlayState& st = s_voiceStates[filePath];
st.playbackId = playbackId;
st.playing = true;
st.paused = false;
st.durationSec = (actualDurationSec > 0) ? actualDurationSec : 1.0f;
st.startMs = QDateTime::currentMSecsSinceEpoch();
st.pausedPosSec = 0.0f;
st.displayFrac = 0.0f;
st.displayElapsed = 0.0f;
s_voiceTimer->start();
if (s_voiceView) s_voiceView->viewport()->update();
}
void MessageDelegate::stopAllAutoPlay() {
VoicePlayer::stop();
for (auto it = s_voiceStates.begin(); it != s_voiceStates.end(); ++it) {
it->playing = false; it->paused = false; it->loading = false;
}
}