diff --git a/tools/chatgui/src/inputbar.cpp b/tools/chatgui/src/inputbar.cpp index e39af4d01..16a93f0f5 100644 --- a/tools/chatgui/src/inputbar.cpp +++ b/tools/chatgui/src/inputbar.cpp @@ -36,9 +36,12 @@ #include #include #include +#include +#include #include #include #include +#include #include 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 png, jpeg; + bool preferJpeg = false; +}; + /* Возврат происходит после destructor QTemporaryFile: его native handle уже закрыт, в том числе в Windows. */ -static std::shared_ptr encodeClipboardPng(QImage image, const QByteArray& encoded, - const QString& name, QString& error) { +static std::shared_ptr writeClipboardImage(const QImage& image, const char* format, int quality, + QString& error) { + auto result = std::make_shared(); + 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 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 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(); - 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(); 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(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"); diff --git a/tools/chatgui/src/inputbar.h b/tools/chatgui/src/inputbar.h index 72e638572..0d1860f6c 100644 --- a/tools/chatgui/src/inputbar.h +++ b/tools/chatgui/src/inputbar.h @@ -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:, 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:. 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 m_clipboardDraft; // Собственный PNG черновика; каждой отправке принадлежит отдельная копия. - QThreadPool m_clipboardPool; // Один PNG-worker; destructor ждёт завершения, queued-результат владеет своим файлом. + std::shared_ptr m_clipboardDraft; // Собственное изображение черновика; каждой отправке принадлежит отдельная копия. + std::shared_ptr 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> m_preparations; // Target → UUID активных задач ядра. QSet m_preparationErrors; // Target с последней ошибкой, снимается при новом start или forget. QSet 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 остаётся неизменным, для групп/видео создаётся каталог назначения. */ diff --git a/tools/chatgui/tests/clipboard_fixture.h b/tools/chatgui/tests/clipboard_fixture.h new file mode 100644 index 000000000..8028dfa03 --- /dev/null +++ b/tools/chatgui/tests/clipboard_fixture.h @@ -0,0 +1,16 @@ +/* Изображение с градиентом и воспроизводимым шумом для сравнения PNG/JPEG в GUI и настоящем ядре. */ +#pragma once +#include + +// 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; +} diff --git a/tools/chatgui/tests/test_file_drop.cpp b/tools/chatgui/tests/test_file_drop.cpp index 070b0e893..9f7fa9200 100644 --- a/tools/chatgui/tests/test_file_drop.cpp +++ b/tools/chatgui/tests/test_file_drop.cpp @@ -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 #include #include +#include +#include +#include +#include +#include static QSet clipboardPaths() { QSet 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& 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("density"); QTest::addColumn("alpha"); QTest::addColumn("dm"); + QTest::addColumn("encrypted"); QTest::addColumn("manual"); QTest::addColumn("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(); auto* thumbnail = bar.findChild("clipboardImageThumbnail"); + auto* jpeg = bar.findChild("clipboardJpegChoice"); auto* png = bar.findChild("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(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(); 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(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(); auto* thumbnail = bar.findChild("clipboardImageThumbnail"); + auto* jpeg = bar.findChild("clipboardJpegChoice"); auto* png = bar.findChild("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(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("dm"); QTest::addColumn("encrypted"); QTest::addColumn("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()->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("mediaPreparationStatus"); QVERIFY(status); QTRY_VERIFY(status->isHidden()); - const auto newDrafts = clipboardPaths() - before - QSet{sendSource}; QCOMPARE(newDrafts.size(), 1); - QCOMPARE(QImage(*newDrafts.begin()).pixelColor(0, 0), QColor(Qt::blue)); + const auto newDrafts = clipboardPaths() - before - QSet{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("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(); auto* thumbnail = bar.findChild("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(); QVERIFY(editor); editor->paste(); auto* thumbnail = bar.findChild("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); } diff --git a/tools/chatgui/tests/test_group_edits.cpp b/tools/chatgui/tests/test_group_edits.cpp index 36e640e0e..2ecff6674 100644 --- a/tools/chatgui/tests/test_group_edits.cpp +++ b/tools/chatgui/tests/test_group_edits.cpp @@ -10,6 +10,7 @@ #include "../db/db_manager.h" #include "../transport/utun_node.h" #include "../transport/gui_bridge.h" +#include "clipboard_fixture.h" #include #include #include @@ -23,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -126,11 +128,18 @@ static int inCore(std::function operation) { class GroupEditTest : public QObject { Q_OBJECT private slots: - void clipboardPngCommitsThroughCore_data() { - QTest::addColumn("dm"); QTest::newRow("group-reply") << false; QTest::newRow("pm") << true; + void clipboardImageCommitsThroughCore_data() { + QTest::addColumn("dm"); QTest::addColumn("jpeg"); QTest::addColumn("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("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(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)); } } diff --git a/tools/chatgui/translations/chatgui_en.ts b/tools/chatgui/translations/chatgui_en.ts index 56d801ded..76d524fd8 100644 --- a/tools/chatgui/translations/chatgui_en.ts +++ b/tools/chatgui/translations/chatgui_en.ts @@ -2827,5 +2827,13 @@ See log: Could not prepare clipboard image: %1 Could not prepare clipboard image: %1 + + JPEG quality: 85. Transparent areas use a white background. + JPEG quality: 85. Transparent areas use a white background. + + + PNG preserves the original pixels and transparency. + PNG preserves the original pixels and transparency. + diff --git a/tools/chatgui/translations/chatgui_ru.ts b/tools/chatgui/translations/chatgui_ru.ts index f198d3b51..848cd8eee 100644 --- a/tools/chatgui/translations/chatgui_ru.ts +++ b/tools/chatgui/translations/chatgui_ru.ts @@ -2836,5 +2836,13 @@ See log: Could not prepare clipboard image: %1 Не удалось подготовить изображение из буфера: %1 + + JPEG quality: 85. Transparent areas use a white background. + Качество JPEG: 85. Прозрачные области заменяются белым фоном. + + + PNG preserves the original pixels and transparency. + PNG сохраняет исходные пиксели и прозрачность. +