Offer JPEG and PNG clipboard attachments with automatic selection
This commit is contained in:
1 parent
4d30fa6bcc
commit
4e3c1182db
7 files changed
+316
-79
No files matched your search
+120
-39
@@ -36,9 +36,12 @@
|
||||
#include <QFile>
|
||||
#include <QImageWriter>
|
||||
#include <QPixmap>
|
||||
#include <QPainter>
|
||||
#include <QRadioButton>
|
||||
#include <QRunnable>
|
||||
#include <QTemporaryFile>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <functional>
|
||||
|
||||
extern "C" {
|
||||
@@ -86,25 +89,48 @@ private:
|
||||
};
|
||||
}
|
||||
|
||||
/* Не QObject: результат worker и GUI совместно держат путь, последний владелец удаляет только свой PNG. */
|
||||
struct ClipboardPng {
|
||||
/* Не QObject: результат worker и GUI совместно держат путь, последний владелец удаляет только своё изображение. */
|
||||
struct ClipboardImage {
|
||||
QString path, name;
|
||||
QImage thumbnail;
|
||||
QSize dimensions;
|
||||
qint64 bytes = 0;
|
||||
~ClipboardPng() {
|
||||
~ClipboardImage() {
|
||||
if (path.isEmpty()) return;
|
||||
QFile file(path);
|
||||
if (!file.remove()) GUI_WARN("clipboard image: cleanup failed path=%s error=%s", qPrintable(path), qPrintable(file.errorString()));
|
||||
else GUI_DEBUG("clipboard image: temporary PNG removed path=%s", qPrintable(path));
|
||||
else GUI_DEBUG("clipboard image: temporary image removed path=%s", qPrintable(path));
|
||||
}
|
||||
};
|
||||
|
||||
/* Оба файла принадлежат черновику до очистки; worker заранее выбирает вариант по правилу размеров. */
|
||||
struct ClipboardImages {
|
||||
std::shared_ptr<ClipboardImage> png, jpeg;
|
||||
bool preferJpeg = false;
|
||||
};
|
||||
|
||||
/* Возврат происходит после destructor QTemporaryFile: его native handle уже закрыт, в том числе в Windows. */
|
||||
static std::shared_ptr<ClipboardPng> encodeClipboardPng(QImage image, const QByteArray& encoded,
|
||||
const QString& name, QString& error) {
|
||||
static std::shared_ptr<ClipboardImage> writeClipboardImage(const QImage& image, const char* format, int quality,
|
||||
QString& error) {
|
||||
auto result = std::make_shared<ClipboardImage>();
|
||||
const QString suffix = !std::strcmp(format, "jpeg") ? "jpg" : "png";
|
||||
QTemporaryFile file(QDir::tempPath() + "/vibechat-clipboard-XXXXXX." + suffix);
|
||||
if (!file.open()) { error = file.errorString(); return {}; }
|
||||
result->path = file.fileName(); file.setAutoRemove(false);
|
||||
QImageWriter writer(&file, format);
|
||||
if (quality >= 0) writer.setQuality(quality);
|
||||
if (!writer.write(image)) { error = writer.errorString(); return {}; }
|
||||
if (!file.flush()) { error = file.errorString(); return {}; }
|
||||
result->bytes = file.size();
|
||||
return result;
|
||||
}
|
||||
|
||||
/* Оба кодека работают в worker; JPEG (85) выбирается только при выигрыше минимум вдвое. */
|
||||
static std::shared_ptr<ClipboardImages> encodeClipboardImage(QImage image, const QByteArray& encoded,
|
||||
const QString& name, QString& error) {
|
||||
if (image.isNull()) {
|
||||
QBuffer buffer; buffer.setData(encoded); buffer.open(QIODevice::ReadOnly);
|
||||
QBuffer buffer; buffer.setData(encoded);
|
||||
if (!buffer.open(QIODevice::ReadOnly)) { error = buffer.errorString(); return {}; }
|
||||
QImageReader reader(&buffer); reader.setAutoTransform(true);
|
||||
if (!reader.canRead()) error = reader.errorString();
|
||||
else if (!validClipboardSize(reader.size())) error = QStringLiteral("invalid dimensions or more than 32 megapixels");
|
||||
@@ -114,22 +140,37 @@ static std::shared_ptr<ClipboardPng> encodeClipboardPng(QImage image, const QByt
|
||||
error = QStringLiteral("image exceeds 16384 pixels per side, 32 megapixels or 128 MiB");
|
||||
if (!error.isEmpty()) return {};
|
||||
image.setDevicePixelRatio(1);
|
||||
auto result = std::make_shared<ClipboardPng>();
|
||||
QTemporaryFile file(QDir::tempPath() + "/vibechat-clipboard-XXXXXX.png");
|
||||
if (!file.open()) { error = file.errorString(); return {}; }
|
||||
result->path = file.fileName(); file.setAutoRemove(false);
|
||||
QImageWriter writer(&file, "png");
|
||||
if (!writer.write(image)) { error = writer.errorString(); return {}; }
|
||||
if (!file.flush()) { error = file.errorString(); return {}; }
|
||||
result->name = name; result->dimensions = image.size(); result->bytes = file.size();
|
||||
result->thumbnail = image.scaled(80, 60, Qt::KeepAspectRatio, Qt::SmoothTransformation);
|
||||
QString pngError, jpegError;
|
||||
const auto png = writeClipboardImage(image, "png", -1, pngError);
|
||||
QImage jpegImage = image;
|
||||
if (image.hasAlphaChannel()) {
|
||||
jpegImage = QImage(image.size(), QImage::Format_RGB32);
|
||||
if (jpegImage.isNull()) jpegError = QStringLiteral("cannot allocate JPEG background");
|
||||
else { jpegImage.fill(Qt::white); QPainter painter(&jpegImage); painter.drawImage(0, 0, image); }
|
||||
}
|
||||
const auto jpeg = jpegError.isEmpty() ? writeClipboardImage(jpegImage, "jpeg", 85, jpegError) : nullptr;
|
||||
if (!png) GUI_WARN("clipboard image: PNG encoding failed name=%s reason=%s", qPrintable(name), qPrintable(pngError));
|
||||
if (!jpeg) GUI_WARN("clipboard image: JPEG encoding failed name=%s reason=%s", qPrintable(name), qPrintable(jpegError));
|
||||
if (!png && !jpeg) { error = QString("PNG: %1; JPEG: %2").arg(pngError, jpegError); return {}; }
|
||||
auto result = std::make_shared<ClipboardImages>(); result->png = png; result->jpeg = jpeg;
|
||||
result->preferJpeg = jpeg && (!png || png->bytes >= 2 * jpeg->bytes);
|
||||
if (png) {
|
||||
png->name = name + ".png"; png->dimensions = image.size();
|
||||
png->thumbnail = image.scaled(80, 60, Qt::KeepAspectRatio, Qt::SmoothTransformation);
|
||||
}
|
||||
if (jpeg) {
|
||||
jpeg->name = name + ".jpg"; jpeg->dimensions = image.size();
|
||||
jpeg->thumbnail = jpegImage.scaled(80, 60, Qt::KeepAspectRatio, Qt::SmoothTransformation);
|
||||
}
|
||||
GUI_INFO("clipboard image: compression name=%s png_bytes=%lld jpeg_bytes=%lld jpeg_quality=85 selected=%s alpha_background=white",
|
||||
qPrintable(name), (long long)(png ? png->bytes : -1), (long long)(jpeg ? jpeg->bytes : -1), result->preferJpeg ? "jpeg" : "png");
|
||||
return result;
|
||||
}
|
||||
|
||||
/* Независимый source для worker: удаление/замена черновика не влияет на запрос, а отказ не мешает повтору. */
|
||||
static bool copyClipboardPng(const QString& source, struct attachment_send_req* request, QString& error) {
|
||||
ClipboardPng temporary;
|
||||
QTemporaryFile output(QDir::tempPath() + "/vibechat-clipboard-send-XXXXXX.png");
|
||||
static bool copyClipboardImage(const QString& source, struct attachment_send_req* request, QString& error) {
|
||||
ClipboardImage temporary;
|
||||
QTemporaryFile output(QDir::tempPath() + "/vibechat-clipboard-send-XXXXXX." + QFileInfo(source).suffix());
|
||||
if (!output.open()) { error = output.errorString(); return false; }
|
||||
temporary.path = output.fileName(); output.setAutoRemove(false);
|
||||
const QByteArray path = temporary.path.toUtf8();
|
||||
@@ -227,8 +268,19 @@ InputBar::InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder, QWidget *par
|
||||
prevLay->addWidget(m_attachThumbnail);
|
||||
m_attachPreviewLabel = new QLabel(m_attachPreview);
|
||||
m_attachPreviewLabel->setTextFormat(Qt::PlainText);
|
||||
m_attachPreviewLabel->setMinimumWidth(0); m_attachPreviewLabel->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred);
|
||||
m_attachPreviewLabel->setStyleSheet("background: palette(light); border-radius: 6px; padding: 2px 8px;");
|
||||
prevLay->addWidget(m_attachPreviewLabel, 1);
|
||||
m_clipboardFormats = new QWidget(m_attachPreview); m_clipboardFormats->hide();
|
||||
auto* formatLayout = new QHBoxLayout(m_clipboardFormats); formatLayout->setContentsMargins(0, 0, 0, 0);
|
||||
m_clipboardJpeg = new QRadioButton(m_clipboardFormats); m_clipboardJpeg->setObjectName("clipboardJpegChoice");
|
||||
m_clipboardPng = new QRadioButton(m_clipboardFormats); m_clipboardPng->setObjectName("clipboardPngChoice");
|
||||
m_clipboardJpeg->setToolTip(QCoreApplication::translate("ChatGui", "JPEG quality: 85. Transparent areas use a white background."));
|
||||
m_clipboardPng->setToolTip(QCoreApplication::translate("ChatGui", "PNG preserves the original pixels and transparency."));
|
||||
for (auto* button : {m_clipboardJpeg, m_clipboardPng}) { formatLayout->addWidget(button); button->installEventFilter(this); }
|
||||
connect(m_clipboardJpeg, &QRadioButton::clicked, this, [this] { selectClipboardFormat(true); });
|
||||
connect(m_clipboardPng, &QRadioButton::clicked, this, [this] { selectClipboardFormat(false); });
|
||||
prevLay->addWidget(m_clipboardFormats);
|
||||
m_encryptCheck = new QCheckBox(QCoreApplication::translate("ChatGui", "Encrypt attachment"), m_attachPreview);
|
||||
m_encryptCheck->setObjectName("encryptAttachmentCheckBox");
|
||||
m_encryptCheck->setToolTip(QCoreApplication::translate("ChatGui", "Encrypt attachment; approve each recipient's key request"));
|
||||
@@ -360,6 +412,7 @@ InputBar::~InputBar() {
|
||||
cancelClipboardPreparation();
|
||||
m_clipboardPool.waitForDone();
|
||||
m_clipboardDraft.reset();
|
||||
m_clipboardChoices.reset();
|
||||
GUI_DEBUG("InputBar: removing file drop targets count=%d", int(m_dropTargets.size()));
|
||||
for (auto* target : m_dropTargets) {
|
||||
target->removeEventFilter(this);
|
||||
@@ -464,6 +517,7 @@ void InputBar::cancelEdit() {
|
||||
m_replyPreview->setVisible(!m_replyTo.isEmpty()); m_attachPreview->setVisible(!m_pendingAttachSrc.isEmpty());
|
||||
m_attachBtn->setEnabled(m_mediaEnabled); m_pttBtn->setEnabled(m_mediaEnabled);
|
||||
m_sendBtn->setToolTip(QCoreApplication::translate("ChatGui", "Send"));
|
||||
showPreparation();
|
||||
}
|
||||
|
||||
/* Qt доставляет drop в viewport, а не в QTextEdit/QListView. Родитель composer принимает drop на кнопках. */
|
||||
@@ -528,37 +582,57 @@ void InputBar::pasteImage(const QMimeData* source) {
|
||||
rejectAttachment(QCoreApplication::translate("ChatGui", "Cannot paste image: invalid data or more than 32 megapixels / 128 MiB."));
|
||||
return;
|
||||
}
|
||||
const QString name = "clipboard-" + QDateTime::currentDateTime().toString("yyyyMMdd-HHmmss-zzz") + ".png";
|
||||
const QString name = "clipboard-" + QDateTime::currentDateTime().toString("yyyyMMdd-HHmmss-zzz");
|
||||
++m_attachmentRevision;
|
||||
GUI_INFO("clipboard image: preparing PNG target=%s generation=%llu representation=%s dimensions=%dx%d encoded_bytes=%lld",
|
||||
GUI_INFO("clipboard image: preparing target=%s generation=%llu representation=%s dimensions=%dx%d encoded_bytes=%lld",
|
||||
qPrintable(target), (unsigned long long)generation, representation.constData(), image.width(), image.height(),
|
||||
(long long)encoded.size());
|
||||
m_clipboardPool.start(QRunnable::create([this, image, encoded, name, target, context, generation] {
|
||||
QString error;
|
||||
const auto result = encodeClipboardPng(image, encoded, name, error);
|
||||
if (!result) GUI_ERROR("clipboard image: PNG preparation failed target=%s reason=%s", qPrintable(target), qPrintable(error));
|
||||
const auto result = encodeClipboardImage(image, encoded, name, error);
|
||||
if (!result) GUI_ERROR("clipboard image: preparation failed target=%s reason=%s", qPrintable(target), qPrintable(error));
|
||||
if (!QMetaObject::invokeMethod(this, [this, result, error, target, context, generation] {
|
||||
if (context != m_attachmentContext || generation != m_clipboardGeneration) {
|
||||
GUI_DEBUG("clipboard image: stale PNG discarded target=%s generation=%llu", qPrintable(target),
|
||||
GUI_DEBUG("clipboard image: stale image discarded target=%s generation=%llu", qPrintable(target),
|
||||
(unsigned long long)generation); return;
|
||||
}
|
||||
m_clipboardPreparing = false; showPreparation();
|
||||
if (!result) {
|
||||
rejectAttachment(QCoreApplication::translate("ChatGui", "Could not prepare clipboard image: %1").arg(error)); return;
|
||||
}
|
||||
if (!prepareAttachment(result->path, context)) return;
|
||||
m_clipboardDraft = result;
|
||||
m_pendingAttachName = result->name;
|
||||
m_attachPreviewLabel->setText(QString("%1 (%2, %3×%4)").arg(result->name, formatFileSize(result->bytes))
|
||||
.arg(result->dimensions.width()).arg(result->dimensions.height()));
|
||||
m_attachThumbnail->setPixmap(QPixmap::fromImage(result->thumbnail)); m_attachThumbnail->show();
|
||||
GUI_INFO("clipboard image: preview ready target=%s source=%s dimensions=%dx%d bytes=%lld",
|
||||
qPrintable(target), qPrintable(result->path), result->dimensions.width(), result->dimensions.height(),
|
||||
(long long)result->bytes);
|
||||
}, Qt::QueuedConnection)) GUI_ERROR("clipboard image: cannot queue PNG result target=%s", qPrintable(target));
|
||||
const auto selected = result->preferJpeg ? result->jpeg : result->png;
|
||||
if (!prepareAttachment(selected->path, context)) return;
|
||||
m_clipboardChoices = result;
|
||||
m_clipboardJpeg->setText(QString("JPEG (%1)").arg(result->jpeg ? formatFileSize(result->jpeg->bytes) : QString::fromUtf8("—")));
|
||||
m_clipboardPng->setText(QString("PNG (%1)").arg(result->png ? formatFileSize(result->png->bytes) : QString::fromUtf8("—")));
|
||||
m_clipboardJpeg->setEnabled(bool(result->jpeg)); m_clipboardPng->setEnabled(bool(result->png));
|
||||
m_clipboardFormats->show(); selectClipboardFormat(result->preferJpeg);
|
||||
(result->preferJpeg ? m_clipboardJpeg : m_clipboardPng)->setFocus(Qt::OtherFocusReason);
|
||||
}, Qt::QueuedConnection)) GUI_ERROR("clipboard image: cannot queue result target=%s", qPrintable(target));
|
||||
}));
|
||||
}
|
||||
|
||||
void InputBar::selectClipboardFormat(bool jpeg) {
|
||||
const auto selected = m_clipboardChoices ? (jpeg ? m_clipboardChoices->jpeg : m_clipboardChoices->png) : nullptr;
|
||||
if (!selected || !canAttachFile() || m_clipboardPreparing) {
|
||||
GUI_WARN("clipboard image: format selection unavailable target=%s jpeg=%d", qPrintable(m_channelIdForRecord), jpeg);
|
||||
m_clipboardJpeg->setChecked(m_clipboardChoices && m_clipboardDraft == m_clipboardChoices->jpeg);
|
||||
m_clipboardPng->setChecked(m_clipboardChoices && m_clipboardDraft == m_clipboardChoices->png);
|
||||
return;
|
||||
}
|
||||
if (selected != m_clipboardDraft) ++m_attachmentRevision;
|
||||
m_clipboardDraft = selected;
|
||||
m_pendingAttachSrc = selected->path; m_pendingAttachDest = selected->path;
|
||||
m_pendingAttachName = selected->name; m_pendingAttachSize = selected->bytes;
|
||||
m_clipboardJpeg->setChecked(jpeg); m_clipboardPng->setChecked(!jpeg);
|
||||
m_attachPreviewLabel->setText(QString("%1 (%2, %3×%4)").arg(selected->name, formatFileSize(selected->bytes))
|
||||
.arg(selected->dimensions.width()).arg(selected->dimensions.height()));
|
||||
m_attachThumbnail->setPixmap(QPixmap::fromImage(selected->thumbnail)); m_attachThumbnail->show();
|
||||
GUI_INFO("clipboard image: format selected target=%s format=%s source=%s dimensions=%dx%d bytes=%lld",
|
||||
qPrintable(m_channelIdForRecord), jpeg ? "jpeg" : "png", qPrintable(selected->path),
|
||||
selected->dimensions.width(), selected->dimensions.height(), (long long)selected->bytes);
|
||||
}
|
||||
|
||||
bool InputBar::eventFilter(QObject *obj, QEvent *event) {
|
||||
if (m_dropTargets.contains(obj)) {
|
||||
if (event->type() == QEvent::DragLeave) {
|
||||
@@ -606,12 +680,13 @@ bool InputBar::eventFilter(QObject *obj, QEvent *event) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (obj == m_input && event->type() == QEvent::KeyPress) {
|
||||
if ((obj == m_input || obj == m_clipboardJpeg || obj == m_clipboardPng) && event->type() == QEvent::KeyPress) {
|
||||
auto *ke = static_cast<QKeyEvent *>(event);
|
||||
if (ke->key() == Qt::Key_Escape && !m_editId.isEmpty()) { cancelEdit(); return true; }
|
||||
if (ke->key() == Qt::Key_Escape && !m_replyTo.isEmpty()) { clearReply(); return true; }
|
||||
if (ke->key() == Qt::Key_Return || ke->key() == Qt::Key_Enter) {
|
||||
if (ke->modifiers() & Qt::ControlModifier) {
|
||||
m_input->setFocus();
|
||||
m_input->insertPlainText("\n");
|
||||
return true;
|
||||
} else {
|
||||
@@ -655,7 +730,7 @@ void InputBar::onSendClicked() {
|
||||
emit saveEdit(m_channelIdForRecord, m_editId, m_editVersion, text, m_editRequest); return;
|
||||
}
|
||||
if (m_clipboardPreparing) {
|
||||
GUI_WARN("send: clipboard PNG still preparing target=%s", qPrintable(m_channelIdForRecord)); return;
|
||||
GUI_WARN("send: clipboard image still preparing target=%s", qPrintable(m_channelIdForRecord)); return;
|
||||
}
|
||||
if (submissionPending()) {
|
||||
GUI_WARN("send: submission still pending ch=%s", qPrintable(m_channelIdForRecord)); return;
|
||||
@@ -680,7 +755,7 @@ void InputBar::onSendClicked() {
|
||||
QStringList{"jpg", "jpeg", "png"}.contains(QFileInfo(attachmentSource).suffix().toLower());
|
||||
if (typedImage) {
|
||||
QByteArray format; QSize size; QString readError; bool readable;
|
||||
{ // Reader должен закрыть исходник до синхронного submissionResult, который может удалить PNG черновика.
|
||||
{ // Reader должен закрыть исходник до синхронного submissionResult, который может удалить черновик изображения.
|
||||
QImageReader reader(attachmentSource); reader.setAutoTransform(true);
|
||||
format = reader.format().toLower(); size = reader.size(); readable = reader.canRead(); readError = reader.errorString();
|
||||
if (reader.transformation() & QImageIOHandler::TransformationRotate90) size.transpose();
|
||||
@@ -705,7 +780,7 @@ void InputBar::onSendClicked() {
|
||||
request->request_id = requestId;
|
||||
if (m_clipboardDraft) {
|
||||
QString error;
|
||||
if (!copyClipboardPng(attachmentSource, request, error)) {
|
||||
if (!copyClipboardImage(attachmentSource, request, error)) {
|
||||
GUI_ERROR("clipboard image: send snapshot failed source=%s reason=%s", qPrintable(attachmentSource), qPrintable(error));
|
||||
attachment_send_discard(request); submissionResult(requestId, false); return;
|
||||
}
|
||||
@@ -820,13 +895,14 @@ void InputBar::setMediaEnabled(bool enabled) {
|
||||
if (m_pttBtn) m_pttBtn->setEnabled(enabled && m_editId.isEmpty());
|
||||
if (m_attachBtn) m_attachBtn->setEnabled(enabled && m_editId.isEmpty());
|
||||
if (!enabled) cancelClipboardPreparation();
|
||||
else showPreparation();
|
||||
if (!enabled && m_dropHint) m_dropHint->hide();
|
||||
}
|
||||
|
||||
void InputBar::onPttPressed() {
|
||||
if (!m_editId.isEmpty()) { GUI_WARN("voice: blocked while editing"); return; }
|
||||
if (!isEnabled() || m_channelIdForRecord.isEmpty()) { GUI_WARN("voice: composer unavailable"); return; }
|
||||
if (submissionPending() || m_clipboardPreparing) { GUI_WARN("send: recording blocked while submission/PNG pending"); return; }
|
||||
if (submissionPending() || m_clipboardPreparing) { GUI_WARN("send: recording blocked while submission/image pending"); return; }
|
||||
if (!m_recorder || !m_recorder->isInitialized()) return;
|
||||
m_recorder->startRecording();
|
||||
m_pttActive = m_recorder->isRecording();
|
||||
@@ -931,6 +1007,7 @@ bool InputBar::prepareAttachment(const QString& srcPath, quint64 context) {
|
||||
m_encryptCheck->setChecked(false);
|
||||
cancelClipboardPreparation();
|
||||
m_clipboardDraft.reset();
|
||||
m_clipboardChoices.reset(); m_clipboardFormats->hide();
|
||||
m_attachThumbnail->clear(); m_attachThumbnail->hide();
|
||||
m_pendingAttachSrc = srcPath;
|
||||
++m_attachmentRevision;
|
||||
@@ -954,6 +1031,9 @@ bool InputBar::prepareAttachment(const QString& srcPath, quint64 context) {
|
||||
void InputBar::clearPendingAttachment() {
|
||||
cancelClipboardPreparation();
|
||||
m_clipboardDraft.reset();
|
||||
m_clipboardChoices.reset();
|
||||
if (m_clipboardFormats->isAncestorOf(QApplication::focusWidget())) m_input->setFocus();
|
||||
m_clipboardFormats->hide();
|
||||
m_attachThumbnail->clear(); m_attachThumbnail->hide();
|
||||
m_encryptCheck->setChecked(false);
|
||||
++m_attachmentRevision;
|
||||
@@ -983,6 +1063,7 @@ void InputBar::updatePreparation(const QString& target, const QByteArray& job, i
|
||||
|
||||
void InputBar::showPreparation() {
|
||||
QString status;
|
||||
m_clipboardFormats->setEnabled(!m_clipboardPreparing && canAttachFile());
|
||||
if (m_clipboardPreparing || !m_preparations.value(m_channelIdForRecord).isEmpty())
|
||||
status = QCoreApplication::translate("ChatGui", "Preparing media…");
|
||||
else if (m_preparationErrors.contains(m_channelIdForRecord)) status = QCoreApplication::translate("ChatGui", "Failed to prepare attachment");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* GUI-composer канала/PM: текст, emoji, ответ/редактирование, голосовая запись и preview одного вложения.
|
||||
* Сам не отправляет данные в сеть: владелец обрабатывает сигналы в GUI и передаёт работу ядру.
|
||||
* Для submitAttachment нужен ровно один обработчик, принимающий request вместе с PCM/compressor даже при отказе.
|
||||
* Канальный текст/файлы/ответы/правки и PNG из буфера в PM ждут submissionResult; остальные PM-вложения очищаются сразу.
|
||||
* Канальный текст/файлы/ответы/правки и изображения из буфера в PM ждут submissionResult; остальные PM-вложения очищаются сразу.
|
||||
* emojiPanel/recorder заимствованы, могут быть nullptr и должны жить во время использования; остальные виджеты — дочерние. */
|
||||
#pragma once
|
||||
|
||||
@@ -21,8 +21,10 @@ class QTimer;
|
||||
class EmojiPanel;
|
||||
class AudioRecorder;
|
||||
class QMimeData;
|
||||
class QRadioButton;
|
||||
struct attachment_send_req;
|
||||
struct ClipboardPng;
|
||||
struct ClipboardImage;
|
||||
struct ClipboardImages;
|
||||
|
||||
class InputBar : public QWidget {
|
||||
Q_OBJECT
|
||||
@@ -31,10 +33,10 @@ public:
|
||||
* Для PTT recorder должен быть init; его жизненным циклом и остановкой при закрытии владеет вызывающий. */
|
||||
explicit InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder,
|
||||
QWidget *parent = nullptr);
|
||||
~InputBar() override; // Ждёт PNG-worker, удаляет свой временный PNG, снимает drop-фильтры; заимствованные объекты не уничтожает.
|
||||
~InputBar() override; // Ждёт image-worker, удаляет свой временный файл, снимает drop-фильтры; заимствованные объекты не уничтожает.
|
||||
|
||||
/* mediaDirBase — каталог media; channelId — канал либо dm:<conv_id>, dmGeneration — поколение PM (для канала 0).
|
||||
* Изменение контекста отменяет edit/reply/preview, PNG-worker и отложенный drop; обычный текст сохраняется.
|
||||
* Изменение контекста отменяет edit/reply/preview, image-worker и отложенный drop; обычный текст сохраняется.
|
||||
* Запись уже фиксирует прежний target/generation; удаление текущего поколения PM отдельно очищает его черновик. */
|
||||
void setRecordingContext(const QString& mediaDirBase, const QString& channelId, quint64 dmGeneration = 0);
|
||||
/* Очистить текст/reply/preview и остановить запись только при совпадении target/generation.
|
||||
@@ -56,7 +58,7 @@ public:
|
||||
* PM/пустая цель/неверный ключ отклоняются; edit сначала отменяется. Новый ответ имеет собственное поколение. */
|
||||
void setReply(const QByteArray& messageId, const QString& author, const QString& preview);
|
||||
void clearReply(); // Снять ответ и сменить поколение; текст/preview вложения не очищаются, старые результаты не применяются.
|
||||
/* Подтвердить канальный текст/файл, PNG из буфера в PM, правку либо одну часть ответа по request.
|
||||
/* Подтвердить канальный текст/файл, изображение из буфера в PM, правку либо одну часть ответа по request.
|
||||
* Ошибка сохраняет черновик и показывает отказ. Успех очищает лишь неизменённую ревизию текста/вложения;
|
||||
* ответ снимается после успеха всех частей и очистки черновика. Старое поколение/другая цель не затрагиваются. */
|
||||
void submissionResult(quint64 request, bool success);
|
||||
@@ -94,7 +96,7 @@ private slots:
|
||||
void onPttPressed(); // Запускает recorder и фиксирует target/generation/reply только при успешном старте.
|
||||
void onPttReleased(); // Stop; запись от 500 мс передаётся через takeRecording/submitAttachment, меньшая отбрасывается.
|
||||
void onAttachClicked(); // QFileDialog → та же подготовка preview, что у drop; контекст проверяется после модального выбора.
|
||||
void clearPendingAttachment(); // Снять preview/encrypt и подготовку PNG; удалить только собственный временный исходник.
|
||||
void clearPendingAttachment(); // Снять preview/encrypt и подготовку изображения; удалить только собственный временный исходник.
|
||||
void updateRecordingUI(int ms); // Длительность и RMS recorder (-70..0 dB → 0..100), чтение peakLevel потребляет метрику.
|
||||
void showRecordOverlay(); // Показать overlay и начальную длительность/уровень по recordingStarted.
|
||||
void hideRecordOverlay(); // Скрыть по recordingStopped; не выполняет отправку записи.
|
||||
@@ -137,8 +139,10 @@ private:
|
||||
QPushButton *m_cancelRecBtn = nullptr; // Stop без отправки: снимает m_pttActive до остановки recorder.
|
||||
QProgressBar *m_levelBar = nullptr; // RMS последнего обработанного блока recorder, 0..100.
|
||||
QWidget *m_attachPreview = nullptr; // Preview выбранного файла, скрыт до успешной подготовки выбора.
|
||||
QLabel *m_attachPreviewLabel = nullptr; // Plain text: имя/размер файла, для PNG из буфера также размеры в пикселях.
|
||||
QLabel *m_attachThumbnail = nullptr; // Миниатюра PNG из буфера; скрыта для обычного файла.
|
||||
QLabel *m_attachPreviewLabel = nullptr; // Plain text: имя/размер файла, для изображения из буфера также размеры в пикселях.
|
||||
QLabel *m_attachThumbnail = nullptr; // Миниатюра изображения из буфера; скрыта для обычного файла.
|
||||
QWidget *m_clipboardFormats = nullptr; // Выбор JPEG/PNG с размерами; скрыт для обычного файла.
|
||||
QRadioButton *m_clipboardJpeg = nullptr, *m_clipboardPng = nullptr; // Взаимоисключающие варианты; выбранный получает фокус.
|
||||
QPushButton *m_attachClearBtn = nullptr; // Снять preview без удаления исходника.
|
||||
bool m_pttActive = false; // Composer начал запись, которую release ещё может отправить.
|
||||
bool m_dmMode = false; // PM: общий формат JPEG/PNG/voice/video, групповое шифрование скрыто.
|
||||
@@ -147,23 +151,25 @@ private:
|
||||
QString m_mediaDirBase; // База планируемых путей вложений; файлы PM без transcode остаются по исходному пути.
|
||||
QString m_channelIdForRecord; // Текущий target composer: канал либо dm:<id>.
|
||||
QString m_recordTarget; // Target, зафиксированный при старте PTT, независимо от дальнейшего выбора канала.
|
||||
QString m_pendingAttachSrc; // Локальный source preview; только PNG из буфера принадлежит composer.
|
||||
QString m_pendingAttachSrc; // Локальный source preview; только изображение из буфера принадлежит composer.
|
||||
QString m_pendingAttachDest; // Планируемый mediaDirBase/target/имя либо исходный путь PM.
|
||||
QString m_pendingAttachName; // Исходное имя для отображения/метаданных; видео при отправке получает суффикс mp4.
|
||||
qint64 m_pendingAttachSize = 0; // Размер на момент выбора, bytes.
|
||||
bool m_pendingAttachAsVideo = false; // Пользователь выбрал подготовку/transcode видео.
|
||||
bool m_pendingAttachAsImage = false; // Картинка в канале; в PM только JPEG/PNG.
|
||||
std::shared_ptr<ClipboardPng> m_clipboardDraft; // Собственный PNG черновика; каждой отправке принадлежит отдельная копия.
|
||||
QThreadPool m_clipboardPool; // Один PNG-worker; destructor ждёт завершения, queued-результат владеет своим файлом.
|
||||
std::shared_ptr<ClipboardImage> m_clipboardDraft; // Собственное изображение черновика; каждой отправке принадлежит отдельная копия.
|
||||
std::shared_ptr<ClipboardImages> m_clipboardChoices; // Оба готовых файла; переключение не запускает повторное кодирование.
|
||||
QThreadPool m_clipboardPool; // Один image-worker; destructor ждёт завершения, queued-результат владеет своим файлом.
|
||||
quint64 m_clipboardGeneration = 0; // Номер вставки/отмены; устаревший результат не меняет preview.
|
||||
bool m_clipboardPreparing = false; // Пока последний PNG кодируется, отправка заблокирована, старый preview сохраняется.
|
||||
bool m_clipboardPreparing = false; // Пока последнее изображение кодируется, отправка заблокирована, старый preview сохраняется.
|
||||
QLabel* m_preparationLabel = nullptr; // Статус подготовки текущего target.
|
||||
QHash<QString, QSet<QByteArray>> m_preparations; // Target → UUID активных задач ядра.
|
||||
QSet<QString> m_preparationErrors; // Target с последней ошибкой, снимается при новом start или forget.
|
||||
QSet<QByteArray> m_discardedPreparations; /* Завершения задач удалённой истории, ещё ожидаемые из ядра. */
|
||||
void showPreparation(); // Обновить индикатор для текущей цели по кешу задач/ошибок.
|
||||
void pasteImage(const QMimeData* source); // Синхронно забрать изображение/байты буфера, проверить лимиты, запустить PNG-worker.
|
||||
void cancelClipboardPreparation(); // Сменить поколение, снять очередь PNG и индикатор; активный worker закончит без применения.
|
||||
void pasteImage(const QMimeData* source); // Забрать изображение/байты буфера, проверить лимиты, запустить сравнение PNG/JPEG в worker.
|
||||
void cancelClipboardPreparation(); // Сменить поколение, снять очередь и индикатор; активный worker закончит без применения.
|
||||
void selectClipboardFormat(bool jpeg); // Выбрать готовый вариант, обновить preview/ревизию; сохранить текст и выбор шифрования.
|
||||
bool canAttachFile() const; // Enabled, выбран target, скрепка разрешена, нет edit/PTT/записи recorder.
|
||||
/* Проверить source/context, выбрать режим видео и подготовить preview; true — выбор принят, false — отмена/отказ.
|
||||
* Старый preview заменяется лишь после успеха; source остаётся неизменным, для групп/видео создаётся каталог назначения. */
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/* Изображение с градиентом и воспроизводимым шумом для сравнения PNG/JPEG в GUI и настоящем ядре. */
|
||||
#pragma once
|
||||
#include <QImage>
|
||||
|
||||
// density: 0..1024, больше — хуже сжатие PNG; alpha добавляет прозрачность и полупрозрачность.
|
||||
inline QImage clipboardCodecFixture(int density, bool alpha = false) {
|
||||
QImage image(320, 240, alpha ? QImage::Format_ARGB32 : QImage::Format_RGB32);
|
||||
unsigned state = 1;
|
||||
for (int y = 0; y < image.height(); ++y) for (int x = 0; x < image.width(); ++x) {
|
||||
state = state * 1664525u + 1013904223u;
|
||||
const int noise = int((state >> 16) & 1023) < density ? int(state >> 31) : 0;
|
||||
image.setPixel(x, y, qRgba((x / 2 + noise) & 255, (y / 2 + noise) & 255, ((x + y) / 4 + noise) & 255,
|
||||
alpha ? (x < 16 && y < 16 ? 0 : 127) : 255));
|
||||
}
|
||||
return image;
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
#include "../src/inputbar.h"
|
||||
#include "../src/chatview.h"
|
||||
#include "../src/emojipanel.h"
|
||||
#include "clipboard_fixture.h"
|
||||
#include "../../../lib/debug_config.h"
|
||||
extern "C" {
|
||||
#include "media_async/attachment_send.h"
|
||||
@@ -39,14 +40,25 @@ extern "C" {
|
||||
#include <QDir>
|
||||
#include <QProcess>
|
||||
#include <QStandardPaths>
|
||||
#include <QRadioButton>
|
||||
#include <QImageWriter>
|
||||
#include <QImageReader>
|
||||
#include <QPainter>
|
||||
#include <QRegularExpression>
|
||||
|
||||
static QSet<QString> clipboardPaths() {
|
||||
QSet<QString> paths;
|
||||
const QDir directory(QDir::tempPath());
|
||||
for (const auto& name : directory.entryList({"vibechat-clipboard-*.png"}, QDir::Files)) paths.insert(directory.filePath(name));
|
||||
for (const auto& name : directory.entryList({"vibechat-clipboard-*.png", "vibechat-clipboard-*.jpg"}, QDir::Files))
|
||||
paths.insert(directory.filePath(name));
|
||||
return paths;
|
||||
}
|
||||
|
||||
static QString clipboardPngPath(const QSet<QString>& paths) {
|
||||
for (const auto& path : paths) if (path.endsWith(".png") && !path.contains("clipboard-send-")) return path;
|
||||
return {};
|
||||
}
|
||||
|
||||
static void copyImage(const QImage& image, bool encoded = false) {
|
||||
auto* mime = new QMimeData;
|
||||
if (encoded) {
|
||||
@@ -84,6 +96,96 @@ class FileDropTest : public QObject {
|
||||
private slots:
|
||||
void cleanup() { QApplication::clipboard()->clear(); }
|
||||
|
||||
void clipboardFormatChoice_data() {
|
||||
QTest::addColumn<int>("density"); QTest::addColumn<bool>("alpha"); QTest::addColumn<bool>("dm");
|
||||
QTest::addColumn<bool>("encrypted"); QTest::addColumn<QString>("manual"); QTest::addColumn<bool>("automaticJpeg");
|
||||
QTest::newRow("png-smaller") << 0 << false << false << false << QString() << false;
|
||||
QTest::newRow("png-bigger-but-below-twofold") << 16 << false << false << false << QString() << false;
|
||||
QTest::newRow("jpeg-group") << 64 << false << false << false << QString() << true;
|
||||
QTest::newRow("jpeg-pm") << 64 << false << true << false << QString() << true;
|
||||
QTest::newRow("jpeg-encrypted") << 64 << false << false << true << QString() << true;
|
||||
QTest::newRow("manual-jpeg") << 0 << false << false << false << QString("jpeg") << false;
|
||||
QTest::newRow("manual-png-encrypted") << 64 << false << false << true << QString("png") << true;
|
||||
QTest::newRow("transparent-manual-png") << 1024 << true << true << false << QString("png") << true;
|
||||
QTest::newRow("transparent-manual-jpeg") << 0 << true << false << false << QString("jpeg") << false;
|
||||
}
|
||||
void clipboardFormatChoice() {
|
||||
QFETCH(int, density); QFETCH(bool, alpha); QFETCH(bool, dm); QFETCH(bool, encrypted);
|
||||
QFETCH(QString, manual); QFETCH(bool, automaticJpeg);
|
||||
const QImage image = clipboardCodecFixture(density, alpha);
|
||||
QImage jpegImage = image;
|
||||
if (alpha) {
|
||||
jpegImage = QImage(image.size(), QImage::Format_RGB32); jpegImage.fill(Qt::white);
|
||||
QPainter painter(&jpegImage); painter.drawImage(0, 0, image);
|
||||
}
|
||||
QByteArray pngBytes, jpegBytes; QBuffer pngBuffer(&pngBytes), jpegBuffer(&jpegBytes);
|
||||
QVERIFY(pngBuffer.open(QIODevice::WriteOnly)); QVERIFY(jpegBuffer.open(QIODevice::WriteOnly));
|
||||
QVERIFY(image.save(&pngBuffer, "PNG")); QVERIFY(jpegImage.save(&jpegBuffer, "JPEG", 85));
|
||||
if (automaticJpeg) QVERIFY(pngBytes.size() >= 2 * jpegBytes.size());
|
||||
else {
|
||||
QVERIFY(pngBytes.size() < 2 * jpegBytes.size());
|
||||
if (density == 16) QVERIFY(pngBytes.size() > jpegBytes.size());
|
||||
}
|
||||
const auto before = clipboardPaths(); QTemporaryDir directory;
|
||||
InputBar bar(nullptr, nullptr); bar.setRecordingContext(directory.path(), dm ? "dm:456" : "123", dm ? 7 : 0);
|
||||
bar.setDmMode(dm); bar.show();
|
||||
auto* editor = bar.findChild<QTextEdit*>(); auto* thumbnail = bar.findChild<QLabel*>("clipboardImageThumbnail");
|
||||
auto* jpeg = bar.findChild<QRadioButton*>("clipboardJpegChoice"); auto* png = bar.findChild<QRadioButton*>("clipboardPngChoice");
|
||||
QVERIFY(editor); QVERIFY(thumbnail); QVERIFY(jpeg); QVERIFY(png);
|
||||
copyImage(image); editor->paste(); QTRY_VERIFY(!thumbnail->isHidden());
|
||||
QVERIFY(jpeg->isVisible() && png->isVisible()); QVERIFY(jpeg->isEnabled() && png->isEnabled());
|
||||
QCOMPARE(jpeg->isChecked(), automaticJpeg); QCOMPARE(png->isChecked(), !automaticJpeg);
|
||||
QCOMPARE(bar.focusWidget(), static_cast<QWidget*>(automaticJpeg ? jpeg : png));
|
||||
QVERIFY(jpeg->text().startsWith("JPEG (")); QVERIFY(png->text().startsWith("PNG ("));
|
||||
QVERIFY(jpeg->text().contains(QRegularExpression("[0-9]+(\\.[0-9]+)?(B|KB|MB)")));
|
||||
QVERIFY(png->text().contains(QRegularExpression("[0-9]+(\\.[0-9]+)?(B|KB|MB)")));
|
||||
QCOMPARE(clipboardPaths().size(), before.size() + 2);
|
||||
auto* encrypt = bar.findChild<QCheckBox*>(); QVERIFY(encrypt); if (encrypted) encrypt->setChecked(true);
|
||||
const bool useJpeg = manual.isEmpty() ? automaticJpeg : manual == "jpeg";
|
||||
if (!manual.isEmpty()) {
|
||||
auto* current = automaticJpeg ? jpeg : png;
|
||||
QTest::keyClick(current, automaticJpeg ? Qt::Key_Right : Qt::Key_Left);
|
||||
QCOMPARE(jpeg->isChecked(), useJpeg); QCOMPARE(png->isChecked(), !useJpeg);
|
||||
QCOMPARE(bar.focusWidget(), static_cast<QWidget*>(useJpeg ? jpeg : png));
|
||||
}
|
||||
attachment_send_req* pending = nullptr;
|
||||
const auto discard = qScopeGuard([&] { attachment_send_discard(pending); });
|
||||
connect(&bar, &InputBar::submitAttachment, &bar, [&](const QString&, attachment_send_req* request) { pending = request; });
|
||||
QTest::keyClick(useJpeg ? jpeg : png, Qt::Key_Return); QVERIFY(pending);
|
||||
QCOMPARE(pending->info.kind, useJpeg ? ATTACHMENT_JPEG : ATTACHMENT_PNG); QCOMPARE(pending->encrypted, int(encrypted));
|
||||
QCOMPARE(pending->info.width, image.width()); QCOMPARE(pending->info.height, image.height());
|
||||
QVERIFY(QString::fromUtf8(pending->info.name).endsWith(useJpeg ? ".jpg" : ".png"));
|
||||
const QString source = QString::fromUtf8(pending->source); QVERIFY(source.endsWith(useJpeg ? ".jpg" : ".png"));
|
||||
QFile payload(source); QVERIFY(payload.open(QIODevice::ReadOnly)); QCOMPARE(payload.readAll(), useJpeg ? jpegBytes : pngBytes); payload.close();
|
||||
const QImage decoded(source); QCOMPARE(decoded.size(), image.size());
|
||||
if (useJpeg) QVERIFY(!decoded.hasAlphaChannel()); else QCOMPARE(decoded.pixelColor(0, 0), image.pixelColor(0, 0));
|
||||
bar.submissionResult(pending->request_id, true); QVERIFY(jpeg->isHidden() || !jpeg->isVisible());
|
||||
attachment_send_discard(pending); pending = nullptr; QCOMPARE(clipboardPaths(), before);
|
||||
}
|
||||
|
||||
void clipboardManualChoiceOutlivesPendingResult() {
|
||||
const auto before = clipboardPaths(); QTemporaryDir directory;
|
||||
InputBar bar(nullptr, nullptr); bar.setRecordingContext(directory.path(), "123"); bar.show();
|
||||
auto* editor = bar.findChild<QTextEdit*>(); auto* thumbnail = bar.findChild<QLabel*>("clipboardImageThumbnail");
|
||||
auto* jpeg = bar.findChild<QRadioButton*>("clipboardJpegChoice"); auto* png = bar.findChild<QRadioButton*>("clipboardPngChoice");
|
||||
QVERIFY(editor); QVERIFY(thumbnail); QVERIFY(jpeg); QVERIFY(png);
|
||||
copyImage(clipboardCodecFixture(0)); editor->paste(); QTRY_VERIFY(!thumbnail->isHidden()); QVERIFY(png->isChecked());
|
||||
attachment_send_req* pending = nullptr;
|
||||
const auto discard = qScopeGuard([&] { attachment_send_discard(pending); });
|
||||
connect(&bar, &InputBar::submitAttachment, &bar, [&](const QString&, attachment_send_req* request) { pending = request; });
|
||||
QTest::keyClick(png, Qt::Key_Return, Qt::ControlModifier); QVERIFY(!pending);
|
||||
QCOMPARE(editor->toPlainText(), QString("\n")); QCOMPARE(bar.focusWidget(), static_cast<QWidget*>(editor)); editor->clear();
|
||||
QTest::keyClick(png, Qt::Key_Return); QVERIFY(pending); QCOMPARE(pending->info.kind, ATTACHMENT_PNG);
|
||||
QTest::mouseClick(jpeg, Qt::LeftButton); QVERIFY(jpeg->isChecked());
|
||||
bar.submissionResult(pending->request_id, true); QVERIFY(jpeg->isVisible());
|
||||
attachment_send_discard(pending); pending = nullptr;
|
||||
QTest::keyClick(jpeg, Qt::Key_Return); QVERIFY(pending); QCOMPARE(pending->info.kind, ATTACHMENT_JPEG);
|
||||
const auto request = pending->request_id;
|
||||
attachment_send_discard(pending); pending = nullptr; bar.submissionResult(request, false);
|
||||
QVERIFY(jpeg->isChecked()); QVERIFY(jpeg->isVisible()); // Ошибка сохраняет ручной выбор и оба варианта.
|
||||
bar.setRecordingContext(directory.path(), "456"); QCOMPARE(clipboardPaths(), before);
|
||||
}
|
||||
|
||||
void clipboardImageUsesPngPipeline_data() {
|
||||
QTest::addColumn<bool>("dm"); QTest::addColumn<bool>("encrypted"); QTest::addColumn<QString>("method");
|
||||
QTest::newRow("group-ctrl-v") << false << false << QString("ctrl");
|
||||
@@ -118,7 +220,7 @@ private slots:
|
||||
QApplication::clipboard()->setText("clipboard has changed");
|
||||
QTRY_VERIFY(!thumbnail->isHidden());
|
||||
QCOMPARE(errors.size(), 0); QCOMPARE(editor->toPlainText(), QString("existing draft"));
|
||||
const auto drafts = clipboardPaths() - before; QCOMPARE(drafts.size(), 1); const QString draft = *drafts.begin();
|
||||
const auto drafts = clipboardPaths() - before; QCOMPARE(drafts.size(), 2); const QString draft = clipboardPngPath(drafts);
|
||||
QImage decoded(draft); QCOMPARE(decoded.size(), original.size()); QCOMPARE(decoded.pixelColor(0, 0), original.pixelColor(0, 0));
|
||||
editor->clear(); const QByteArray reply(32, 'r'); if (!dm) bar.setReply(reply, "Member", "Original");
|
||||
if (encrypted) bar.findChild<QCheckBox*>()->setChecked(true);
|
||||
@@ -149,7 +251,7 @@ private slots:
|
||||
connect(&bar, &InputBar::submitAttachment, &bar, [&](const QString&, attachment_send_req* request) { pending = request; });
|
||||
QImage image(32, 24, QImage::Format_RGB32); image.fill(Qt::green); copyImage(image); editor->paste();
|
||||
QTRY_VERIFY(!thumbnail->isHidden());
|
||||
const auto drafts = clipboardPaths() - before; QCOMPARE(drafts.size(), 1); const QString draft = *drafts.begin();
|
||||
const auto drafts = clipboardPaths() - before; QCOMPARE(drafts.size(), 2); const QString draft = clipboardPngPath(drafts);
|
||||
QTest::keyClick(editor, Qt::Key_Return); QVERIFY(pending); const auto first = pending->request_id; QVERIFY(first);
|
||||
QTest::keyClick(editor, Qt::Key_Return); QCOMPARE(pending->request_id, first);
|
||||
attachment_send_discard(pending); pending = nullptr;
|
||||
@@ -170,13 +272,13 @@ private slots:
|
||||
connect(&bar, &InputBar::submitAttachment, &bar, [&](const QString&, attachment_send_req* request) { pending = request; });
|
||||
QImage image(32, 24, QImage::Format_RGB32); image.fill(Qt::red); copyImage(image); editor->paste();
|
||||
QTRY_VERIFY(!thumbnail->isHidden()); QTest::keyClick(editor, Qt::Key_Return); QVERIFY(pending);
|
||||
const auto oldDrafts = clipboardPaths() - before; QCOMPARE(oldDrafts.size(), 2);
|
||||
const auto oldDrafts = clipboardPaths() - before; QCOMPARE(oldDrafts.size(), 3);
|
||||
image.fill(Qt::blue); copyImage(image); editor->paste();
|
||||
bar.submissionResult(pending->request_id, true); // Успех A не должен отменить кодирование B.
|
||||
const QString sendSource = QString::fromUtf8(pending->source);
|
||||
auto* status = bar.findChild<QLabel*>("mediaPreparationStatus"); QVERIFY(status); QTRY_VERIFY(status->isHidden());
|
||||
const auto newDrafts = clipboardPaths() - before - QSet<QString>{sendSource}; QCOMPARE(newDrafts.size(), 1);
|
||||
QCOMPARE(QImage(*newDrafts.begin()).pixelColor(0, 0), QColor(Qt::blue));
|
||||
const auto newDrafts = clipboardPaths() - before - QSet<QString>{sendSource}; QCOMPARE(newDrafts.size(), 2);
|
||||
QCOMPARE(QImage(clipboardPngPath(newDrafts)).pixelColor(0, 0), QColor(Qt::blue));
|
||||
QVERIFY(!thumbnail->isHidden()); QVERIFY(QFile::exists(sendSource));
|
||||
attachment_send_discard(pending); pending = nullptr;
|
||||
bar.setRecordingContext(directory.path(), "456"); QCOMPARE(clipboardPaths(), before);
|
||||
@@ -201,8 +303,9 @@ private slots:
|
||||
if (action == "file") QVERIFY(dropUrls(editor->viewport(), {QUrl::fromLocalFile(makeFile(directory, "file.txt"))}));
|
||||
image = QImage(60, 40, QImage::Format_RGB32); image.fill(Qt::blue); copyImage(image); editor->paste();
|
||||
auto* thumbnail = bar.findChild<QLabel*>("clipboardImageThumbnail"); QVERIFY(thumbnail); QTRY_VERIFY(!thumbnail->isHidden());
|
||||
const auto drafts = clipboardPaths() - before; QCOMPARE(drafts.size(), 1);
|
||||
const QImage decoded(*drafts.begin()); QCOMPARE(decoded.size(), image.size()); QCOMPARE(decoded.pixelColor(0, 0), QColor(Qt::blue));
|
||||
const auto drafts = clipboardPaths() - before; QCOMPARE(drafts.size(), 2);
|
||||
const QImage decoded(clipboardPngPath(drafts));
|
||||
QCOMPARE(decoded.size(), image.size()); QCOMPARE(decoded.pixelColor(0, 0), QColor(Qt::blue));
|
||||
}
|
||||
}
|
||||
QCOMPARE(clipboardPaths(), before);
|
||||
@@ -218,14 +321,14 @@ private slots:
|
||||
auto* editor = bar.findChild<QTextEdit*>(); auto* thumbnail = bar.findChild<QLabel*>("clipboardImageThumbnail");
|
||||
QVERIFY(editor); QVERIFY(thumbnail);
|
||||
QImage original(32, 24, QImage::Format_RGB32); original.fill(Qt::green); copyImage(original); editor->paste();
|
||||
QTRY_VERIFY(!thumbnail->isHidden()); const auto drafts = clipboardPaths() - before; QCOMPARE(drafts.size(), 1);
|
||||
QTRY_VERIFY(!thumbnail->isHidden()); const auto drafts = clipboardPaths() - before; QCOMPARE(drafts.size(), 2);
|
||||
QSignalSpy errors(&bar, &InputBar::attachmentRejected);
|
||||
if (kind == "corrupt-png") {
|
||||
auto* mime = new QMimeData; mime->setData("image/png", "invalid PNG"); QApplication::clipboard()->setMimeData(mime);
|
||||
} else copyImage(QImage(kind == "too-wide" ? 16385 : 8192, kind == "too-wide" ? 1 : 4097, QImage::Format_Mono));
|
||||
editor->paste(); QTRY_COMPARE(errors.size(), 1);
|
||||
QCOMPARE(clipboardPaths() - before, drafts); QVERIFY(!thumbnail->isHidden());
|
||||
QCOMPARE(QImage(*drafts.begin()).pixelColor(0, 0), QColor(Qt::green));
|
||||
QCOMPARE(QImage(clipboardPngPath(drafts)).pixelColor(0, 0), QColor(Qt::green));
|
||||
bar.setRecordingContext(directory.path(), "456"); QCOMPARE(clipboardPaths(), before);
|
||||
}
|
||||
|
||||
@@ -270,8 +373,8 @@ private slots:
|
||||
QTRY_VERIFY(QApplication::clipboard()->mimeData()->hasFormat("image/png"));
|
||||
auto* editor = bar.findChild<QTextEdit*>(); QVERIFY(editor); editor->paste();
|
||||
auto* thumbnail = bar.findChild<QLabel*>("clipboardImageThumbnail"); QVERIFY(thumbnail); QTRY_VERIFY(!thumbnail->isHidden());
|
||||
const auto drafts = clipboardPaths() - before; QCOMPARE(drafts.size(), 1);
|
||||
QCOMPARE(QImage(*drafts.begin()).pixelColor(0, 0), original.pixelColor(0, 0));
|
||||
const auto drafts = clipboardPaths() - before; QCOMPARE(drafts.size(), 2);
|
||||
QCOMPARE(QImage(clipboardPngPath(drafts)).pixelColor(0, 0), original.pixelColor(0, 0));
|
||||
bar.setRecordingContext(directory.path(), "456"); QCOMPARE(clipboardPaths(), before);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include "../db/db_manager.h"
|
||||
#include "../transport/utun_node.h"
|
||||
#include "../transport/gui_bridge.h"
|
||||
#include "clipboard_fixture.h"
|
||||
#include <QtTest>
|
||||
#include <QTemporaryDir>
|
||||
#include <QFile>
|
||||
@@ -23,6 +24,7 @@
|
||||
#include <QImage>
|
||||
#include <QLabel>
|
||||
#include <QDir>
|
||||
#include <QRadioButton>
|
||||
#include <future>
|
||||
#include <memory>
|
||||
#include <atomic>
|
||||
@@ -126,11 +128,18 @@ static int inCore(std::function<int(UTUN_INSTANCE*)> operation) {
|
||||
class GroupEditTest : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void clipboardPngCommitsThroughCore_data() {
|
||||
QTest::addColumn<bool>("dm"); QTest::newRow("group-reply") << false; QTest::newRow("pm") << true;
|
||||
void clipboardImageCommitsThroughCore_data() {
|
||||
QTest::addColumn<bool>("dm"); QTest::addColumn<bool>("jpeg"); QTest::addColumn<bool>("manual");
|
||||
QTest::newRow("png-group-reply") << false << false << false;
|
||||
QTest::newRow("png-pm") << true << false << false;
|
||||
QTest::newRow("jpeg-group-reply") << false << true << false;
|
||||
QTest::newRow("jpeg-pm") << true << true << false;
|
||||
QTest::newRow("manual-jpeg-group") << false << true << true;
|
||||
QTest::newRow("manual-png-pm") << true << false << true;
|
||||
}
|
||||
void clipboardPngCommitsThroughCore() {
|
||||
QFETCH(bool, dm); QTemporaryDir directory; QVERIFY(directory.isValid()); SendCore core;
|
||||
void clipboardImageCommitsThroughCore() {
|
||||
QFETCH(bool, dm); QFETCH(bool, jpeg); QFETCH(bool, manual);
|
||||
QTemporaryDir directory; QVERIFY(directory.isValid()); SendCore core;
|
||||
QVERIFY(core.start(!dm, directory.path())); QString conversation;
|
||||
if (dm) {
|
||||
SC_MYKEYS peer{}; uint8_t ed[32]; QCOMPARE(sc_generate_keypair(&peer), 0);
|
||||
@@ -143,35 +152,41 @@ private slots:
|
||||
core.history->show();
|
||||
auto* thumbnail = core.input()->findChild<QLabel*>("clipboardImageThumbnail"); QVERIFY(thumbnail);
|
||||
QSignalSpy errors(core.input(), &InputBar::attachmentRejected);
|
||||
const auto before = QDir(QDir::tempPath()).entryList({"vibechat-clipboard-*.png"}, QDir::Files);
|
||||
const QStringList patterns{"vibechat-clipboard-*.png", "vibechat-clipboard-*.jpg"};
|
||||
const auto before = QDir(QDir::tempPath()).entryList(patterns, QDir::Files);
|
||||
const auto clear = qScopeGuard([] { QApplication::clipboard()->clear(); });
|
||||
QImage image(120, 80, QImage::Format_ARGB32); image.fill(qRgba(17, 61, 95, 127));
|
||||
const QImage image = clipboardCodecFixture(jpeg != manual ? 64 : 0);
|
||||
QApplication::clipboard()->setImage(image); core.editor()->paste(); QTRY_VERIFY(!thumbnail->isHidden());
|
||||
auto* choice = core.input()->findChild<QRadioButton*>(jpeg ? "clipboardJpegChoice" : "clipboardPngChoice"); QVERIFY(choice);
|
||||
if (manual) QTest::mouseClick(choice, Qt::LeftButton);
|
||||
QVERIFY(choice->isChecked());
|
||||
gui_bridge_set_inst(nullptr); QVERIFY(core.send()); QCOMPARE(errors.size(), 1); QVERIFY(!thumbnail->isHidden());
|
||||
core.attach(); QVERIFY(core.send());
|
||||
QTRY_VERIFY_WITH_TIMEOUT((core.pump(), thumbnail->isHidden()), 5000);
|
||||
QCOMPARE(errors.size(), 1);
|
||||
QCOMPARE(QDir(QDir::tempPath()).entryList({"vibechat-clipboard-*.png"}, QDir::Files), before);
|
||||
QCOMPARE(QDir(QDir::tempPath()).entryList(patterns, QDir::Files), before);
|
||||
if (dm) {
|
||||
char json[8192]; size_t length = 0;
|
||||
QCOMPARE(dm_list_messages_json(core.instance, qPrintable(conversation), 20, 0, json, sizeof(json), &length), 0);
|
||||
const QByteArray messages(json, int(length));
|
||||
QVERIFY(messages.contains("\"content_type\":\"image/png\""));
|
||||
QVERIFY(messages.contains("\"width\":120,\"height\":80")); QVERIFY(messages.contains("clipboard-"));
|
||||
QVERIFY(messages.contains(jpeg ? "\"content_type\":\"image/jpeg\"" : "\"content_type\":\"image/png\""));
|
||||
QVERIFY(messages.contains("\"width\":320,\"height\":240")); QVERIFY(messages.contains("clipboard-"));
|
||||
QVERIFY(messages.contains(jpeg ? ".jpg" : ".png"));
|
||||
} else {
|
||||
const auto messages = core.db.getMessages(core.channel, 10); QCOMPARE(messages.size(), 2);
|
||||
bool found = false;
|
||||
for (const auto& message : messages) if (message.contentType == "image/png") {
|
||||
for (const auto& message : messages) if (message.contentType == (jpeg ? "image/jpeg" : "image/png")) {
|
||||
found = true; QCOMPARE(message.replyTo, core.original);
|
||||
const int separator = message.data.indexOf('|'); QVERIFY(separator > 1); QVERIFY(message.data.startsWith('*'));
|
||||
QVERIFY(QByteArray::fromBase64(message.data.mid(1, separator - 1)).startsWith("clipboard-"));
|
||||
QVERIFY(message.data.endsWith("|w=120|h=80"));
|
||||
const auto name = QByteArray::fromBase64(message.data.mid(1, separator - 1)); QVERIFY(name.startsWith("clipboard-"));
|
||||
QVERIFY(name.endsWith(jpeg ? ".jpg" : ".png")); QVERIFY(message.data.endsWith("|w=320|h=240"));
|
||||
}
|
||||
QVERIFY(found);
|
||||
const QDir media(directory.filePath("media/" + core.channel));
|
||||
const auto files = media.entryList({"*.png"}, QDir::Files); QCOMPARE(files.size(), 1);
|
||||
const auto files = media.entryList({jpeg ? "*.jpg" : "*.png"}, QDir::Files); QCOMPARE(files.size(), 1);
|
||||
const QImage decoded(media.filePath(files.front()));
|
||||
QCOMPARE(decoded.size(), image.size()); QCOMPARE(decoded.pixelColor(0, 0), image.pixelColor(0, 0));
|
||||
QCOMPARE(decoded.size(), image.size());
|
||||
if (!jpeg) QCOMPARE(decoded.pixelColor(0, 0), image.pixelColor(0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2827,5 +2827,13 @@ See log:
|
||||
<source>Could not prepare clipboard image: %1</source>
|
||||
<translation>Could not prepare clipboard image: %1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>JPEG quality: 85. Transparent areas use a white background.</source>
|
||||
<translation>JPEG quality: 85. Transparent areas use a white background.</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>PNG preserves the original pixels and transparency.</source>
|
||||
<translation>PNG preserves the original pixels and transparency.</translation>
|
||||
</message>
|
||||
</context>
|
||||
</TS>
|
||||
@@ -2836,5 +2836,13 @@ See log:
|
||||
<source>Could not prepare clipboard image: %1</source>
|
||||
<translation>Не удалось подготовить изображение из буфера: %1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>JPEG quality: 85. Transparent areas use a white background.</source>
|
||||
<translation>Качество JPEG: 85. Прозрачные области заменяются белым фоном.</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>PNG preserves the original pixels and transparency.</source>
|
||||
<translation>PNG сохраняет исходные пиксели и прозрачность.</translation>
|
||||
</message>
|
||||
</context>
|
||||
</TS>
|
||||
Reference in new issue
Block a user