Offer JPEG and PNG clipboard attachments with automatic selection

This commit is contained in:
evgeny committed 2026-10-31 22:30:57 +01:00
1 parent 4d30fa6bcc
commit 4e3c1182db
7 files changed
+316 -79

No files matched your search

+120 -39
View File
@@ -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");