Refactor attachment handling system with enhanced UI (#12085)

* Renamed NewEntryAttachmentsDialog to EditEntryAttachmentsDialog for clarity.
* Introduced EditEntryAttachmentsDialog class to manage editing of existing attachments.
* Added functionality to preview attachments while editing them.
* Enhanced EntryAttachmentsModel with rowByKey method for better key management.
* Add image attachment support with zoom functionality.
* Add html and markdown detection.
* Improve button layout on the attachment section when editing an entry
This commit is contained in:
Kuznetsov Oleg 2025-06-19 20:27:23 +03:00 committed by GitHub
parent c4b4be48a5
commit f2a4cc7e66
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 3254 additions and 388 deletions

View file

@ -663,6 +663,17 @@
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>AttachmentWidget</name>
<message>
<source>Attachment Viewer</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Unknown attachment type</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>AutoType</name>
<message>
@ -2661,10 +2672,6 @@ This is definitely a bug, please report it to the developers.</source>
<source>No Results</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Save</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Enter a unique name or overwrite an existing search from the list:</source>
<translation type="unfinished"></translation>
@ -2833,6 +2840,17 @@ Disable safe saves and try again?</source>
<source>Failed to save backup database: %1</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Save</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>EditEntryAttachmentsDialog</name>
<message>
<source>Edit: %1</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>EditEntryWidget</name>
@ -3904,21 +3922,6 @@ This may cause the affected plugins to malfunction.</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>EntryAttachmentsDialog</name>
<message>
<source>Form</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>File name</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>File contents...</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>EntryAttachmentsModel</name>
<message>
@ -3944,10 +3947,6 @@ This may cause the affected plugins to malfunction.</source>
<source>Add new attachment</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Add</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Remove selected attachment</source>
<translation type="unfinished"></translation>
@ -3968,10 +3967,6 @@ This may cause the affected plugins to malfunction.</source>
<source>Save selected attachment to disk</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Save</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Select files</source>
<translation type="unfinished"></translation>
@ -4065,16 +4060,28 @@ Error: %1</source>
Would you like to overwrite the existing attachment?</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>New</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Preview</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Failed to preview an attachment: Attachment not found</source>
<source>Edit</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>New Text Document</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Add file</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Load from Disk</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Save</source>
<translation type="unfinished"></translation>
</message>
</context>
@ -4640,6 +4647,13 @@ You can enable the DuckDuckGo website icon service in the security section of th
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>ImageAttachmentsWidget</name>
<message>
<source>Zoom:</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>ImportWizard</name>
<message>
@ -6466,25 +6480,6 @@ Expect some bugs and minor issues, this version is meant for testing purposes.</
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>NewEntryAttachmentsDialog</name>
<message>
<source>Attachment name cannot be empty</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Attachment with the same name already exists</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Save attachment</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>New entry attachment</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>NixUtils</name>
<message>
@ -7253,15 +7248,15 @@ Do you want to overwrite it?</source>
<context>
<name>PreviewEntryAttachmentsDialog</name>
<message>
<source>Preview entry attachment</source>
<source>Form</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>No preview available</source>
<source>Preview: %1</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Image format not supported</source>
<source>Save</source>
<translation type="unfinished"></translation>
</message>
</context>
@ -9248,6 +9243,10 @@ This option is deprecated, use --set-key-file instead.</source>
<source>Tags</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Fit</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>QtIOCompressor</name>
@ -10182,6 +10181,24 @@ This option is deprecated, use --set-key-file instead.</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>TextAttachmentsEditWidget</name>
<message>
<source>Preview</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>TextAttachmentsPreviewWidget</name>
<message>
<source>Form</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Type:</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>TotpDialog</name>
<message>

View file

@ -159,8 +159,14 @@ set(gui_SOURCES
gui/entry/EntryAttachmentsModel.cpp
gui/entry/EntryAttachmentsWidget.cpp
gui/entry/EntryAttributesModel.cpp
gui/entry/NewEntryAttachmentsDialog.cpp
gui/entry/EditEntryAttachmentsDialog.cpp
gui/entry/PreviewEntryAttachmentsDialog.cpp
gui/entry/attachments/TextAttachmentsWidget.cpp
gui/entry/attachments/ImageAttachmentsWidget.cpp
gui/entry/attachments/ImageAttachmentsView.cpp
gui/entry/attachments/TextAttachmentsPreviewWidget.cpp
gui/entry/attachments/TextAttachmentsEditWidget.cpp
gui/entry/attachments/AttachmentWidget.cpp
gui/entry/EntryHistoryModel.cpp
gui/entry/EntryModel.cpp
gui/entry/EntryView.cpp

View file

@ -35,6 +35,7 @@
#include <QIODevice>
#include <QLocale>
#include <QMetaProperty>
#include <QMimeDatabase>
#include <QRegularExpression>
#include <QStringList>
#include <QUrl>
@ -478,29 +479,57 @@ namespace Tools
MimeType toMimeType(const QString& mimeName)
{
static QStringList textFormats = {
"text/",
"application/json",
"application/xml",
"application/soap+xml",
"application/x-yaml",
"application/protobuf",
};
static QStringList imageFormats = {"image/"};
const static QStringList TextFormats = {"text/",
"application/json",
"application/xml",
"application/soap+xml",
"application/x-yaml",
"application/protobuf",
"application/x-zerosize"};
const static QStringList HtmlFormats = {"text/html"};
const static QStringList MarkdownFormats = {"text/markdown"};
const static QStringList ImageFormats = {"image/"};
static auto isCompatible = [](const QString& format, const QStringList& list) {
return std::any_of(
list.cbegin(), list.cend(), [&format](const auto& item) { return format.startsWith(item); });
};
if (isCompatible(mimeName, imageFormats)) {
if (isCompatible(mimeName, ImageFormats)) {
return MimeType::Image;
}
if (isCompatible(mimeName, textFormats)) {
if (isCompatible(mimeName, TextFormats)) {
if (isCompatible(mimeName, HtmlFormats)) {
return MimeType::Html;
} else if (isCompatible(mimeName, MarkdownFormats)) {
return MimeType::Markdown;
}
return MimeType::PlainText;
}
return MimeType::Unknown;
}
MimeType getMimeType(const QByteArray& data)
{
QMimeDatabase mimeDb;
const auto mime = mimeDb.mimeTypeForData(data);
return toMimeType(mime.name());
}
MimeType getMimeType(const QFileInfo& fileInfo)
{
QMimeDatabase mimeDb;
const auto mime = mimeDb.mimeTypeForFile(fileInfo);
return toMimeType(mime.name());
}
bool isTextMimeType(MimeType mimeType)
{
return mimeType == Tools::MimeType::PlainText || mimeType == Tools::MimeType::Html
|| mimeType == Tools::MimeType::Markdown;
}
} // namespace Tools

View file

@ -22,6 +22,7 @@
#include "core/Global.h"
#include <QDateTime>
#include <QFileInfo>
#include <QList>
#include <QProcessEnvironment>
#include <QSet>
@ -119,10 +120,16 @@ namespace Tools
{
Image,
PlainText,
Html,
Markdown,
Unknown
};
MimeType toMimeType(const QString& mimeName);
MimeType getMimeType(const QByteArray& data);
MimeType getMimeType(const QFileInfo& fileInfo);
bool isTextMimeType(MimeType mimeType);
} // namespace Tools
#endif // KEEPASSX_TOOLS_H

View file

@ -0,0 +1,52 @@
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include "EditEntryAttachmentsDialog.h"
#include "ui_EditEntryAttachmentsDialog.h"
#include <core/EntryAttachments.h>
#include <QDebug>
#include <QMessageBox>
#include <QMimeDatabase>
#include <QPushButton>
EditEntryAttachmentsDialog::EditEntryAttachmentsDialog(QWidget* parent)
: QDialog(parent)
, m_ui(new Ui::EditEntryAttachmentsDialog)
{
m_ui->setupUi(this);
m_ui->dialogButtons->setStandardButtons(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
connect(m_ui->dialogButtons, &QDialogButtonBox::accepted, this, &EditEntryAttachmentsDialog::accept);
connect(m_ui->dialogButtons, &QDialogButtonBox::rejected, this, &EditEntryAttachmentsDialog::reject);
}
EditEntryAttachmentsDialog::~EditEntryAttachmentsDialog() = default;
void EditEntryAttachmentsDialog::setAttachment(attachments::Attachment attachment)
{
setWindowTitle(tr("Edit: %1").arg(attachment.name));
m_ui->attachmentWidget->openAttachment(std::move(attachment), attachments::OpenMode::ReadWrite);
}
attachments::Attachment EditEntryAttachmentsDialog::getAttachment() const
{
return m_ui->attachmentWidget->getAttachment();
}

View file

@ -17,32 +17,29 @@
#pragma once
#include "attachments/AttachmentTypes.h"
#include <QDialog>
#include <QPointer>
namespace Ui
{
class EntryAttachmentsDialog;
class EditEntryAttachmentsDialog;
}
class QByteArray;
class EntryAttachments;
class NewEntryAttachmentsDialog : public QDialog
class EditEntryAttachmentsDialog : public QDialog
{
Q_OBJECT
public:
explicit NewEntryAttachmentsDialog(QPointer<EntryAttachments> attachments, QWidget* parent = nullptr);
~NewEntryAttachmentsDialog() override;
explicit EditEntryAttachmentsDialog(QWidget* parent = nullptr);
~EditEntryAttachmentsDialog() override;
private slots:
void saveAttachment();
void fileNameTextChanged(const QString& fileName);
void setAttachment(attachments::Attachment attachment);
attachments::Attachment getAttachment() const;
private:
bool validateFileName(const QString& fileName, QString& error) const;
QPointer<EntryAttachments> m_attachments;
QScopedPointer<Ui::EntryAttachmentsDialog> m_ui;
QScopedPointer<Ui::EditEntryAttachmentsDialog> m_ui;
};

View file

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>EditEntryAttachmentsDialog</class>
<widget class="QDialog" name="EditEntryAttachmentsDialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>447</width>
<height>424</height>
</rect>
</property>
<property name="windowTitle">
<string notr="true"/>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="AttachmentWidget" name="attachmentWidget" native="true">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
<item>
<widget class="QDialogButtonBox" name="dialogButtons">
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
</property>
</widget>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>AttachmentWidget</class>
<extends>QWidget</extends>
<header location="global">gui/entry/attachments/AttachmentWidget.h</header>
<container>1</container>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View file

@ -1,55 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>EntryAttachmentsDialog</class>
<widget class="QDialog" name="EntryAttachmentsDialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>402</width>
<height>300</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QLineEdit" name="titleEdit">
<property name="placeholderText">
<string>File name</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="errorLabel">
<property name="enabled">
<bool>true</bool>
</property>
<property name="styleSheet">
<string notr="true">color: #FF9696</string>
</property>
<property name="text">
<string/>
</property>
</widget>
</item>
<item>
<widget class="QTextEdit" name="attachmentTextEdit">
<property name="placeholderText">
<string>File contents...</string>
</property>
</widget>
</item>
<item>
<widget class="QDialogButtonBox" name="dialogButtons">
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View file

@ -130,6 +130,15 @@ QString EntryAttachmentsModel::keyByIndex(const QModelIndex& index) const
return m_entryAttachments->keys().at(index.row());
}
int EntryAttachmentsModel::rowByKey(const QString& key) const
{
if (!m_entryAttachments) {
return -1;
}
return m_entryAttachments->keys().indexOf(key);
}
void EntryAttachmentsModel::attachmentChange(const QString& key)
{
int row = m_entryAttachments->keys().indexOf(key);

View file

@ -44,6 +44,7 @@ public:
bool setData(const QModelIndex& index, const QVariant& value, int role = Qt::EditRole) override;
Qt::ItemFlags flags(const QModelIndex& index) const override;
QString keyByIndex(const QModelIndex& index) const;
int rowByKey(const QString& key) const;
private slots:
void attachmentChange(const QString& key);

View file

@ -17,23 +17,41 @@
#include "EntryAttachmentsWidget.h"
#include "EditEntryAttachmentsDialog.h"
#include "EntryAttachmentsModel.h"
#include "NewEntryAttachmentsDialog.h"
#include "PreviewEntryAttachmentsDialog.h"
#include "ui_EntryAttachmentsWidget.h"
#include <QDebug>
#include <QDropEvent>
#include <QLineEdit>
#include <QMenu>
#include <QMimeData>
#include <QStandardPaths>
#include <QTemporaryFile>
#include "EntryAttachmentsModel.h"
#include "core/EntryAttachments.h"
#include "core/Tools.h"
#include "gui/FileDialog.h"
#include "gui/MessageBox.h"
namespace
{
constexpr const char* DefaultName = "New Attachment";
constexpr const char* Suffix = ".txt";
QString generateUniqueName(const QString& name, const QStringList& existingNames)
{
uint64_t i = 0;
QString newName = QStringLiteral("%1%2").arg(name).arg(Suffix);
while (existingNames.contains(newName)) {
newName = QStringLiteral("%1_%2%3").arg(name).arg(++i).arg(Suffix);
}
return newName;
}
} // namespace
EntryAttachmentsWidget::EntryAttachmentsWidget(QWidget* parent)
: QWidget(parent)
, m_ui(new Ui::EntryAttachmentsWidget)
@ -67,14 +85,30 @@ EntryAttachmentsWidget::EntryAttachmentsWidget(QWidget* parent)
// clang-format on
connect(this, SIGNAL(readOnlyChanged(bool)), m_attachmentsModel, SLOT(setReadOnly(bool)));
connect(m_ui->attachmentsView, SIGNAL(doubleClicked(QModelIndex)), SLOT(previewSelectedAttachment()));
connect(m_ui->attachmentsView, &QAbstractItemView::doubleClicked, [this](const QModelIndex&) {
m_readOnly ? previewSelectedAttachment() : editSelectedAttachment();
});
connect(m_ui->attachmentsView->itemDelegate(), &QAbstractItemDelegate::commitData, [this](QWidget* editor) {
if (auto lineEdit = qobject_cast<QLineEdit*>(editor)) {
auto index = m_attachmentsModel->rowByKey(lineEdit->text());
m_ui->attachmentsView->setCurrentIndex(m_attachmentsModel->index(index, 0));
}
});
connect(m_ui->saveAttachmentButton, SIGNAL(clicked()), SLOT(saveSelectedAttachments()));
connect(m_ui->openAttachmentButton, SIGNAL(clicked()), SLOT(openSelectedAttachments()));
connect(m_ui->addAttachmentButton, SIGNAL(clicked()), SLOT(insertAttachments()));
connect(m_ui->newAttachmentButton, SIGNAL(clicked()), SLOT(newAttachments()));
connect(m_ui->editAttachmentButton, SIGNAL(clicked()), SLOT(editSelectedAttachment()));
connect(m_ui->previewAttachmentButton, SIGNAL(clicked()), SLOT(previewSelectedAttachment()));
connect(m_ui->removeAttachmentButton, SIGNAL(clicked()), SLOT(removeSelectedAttachments()));
auto addButtonMenu = new QMenu(this);
addButtonMenu->addAction(tr("New Text Document"), this, &EntryAttachmentsWidget::newAttachments);
addButtonMenu->addAction(tr("Load from Disk…"), this, QOverload<>::of(&EntryAttachmentsWidget::insertAttachments));
m_ui->addAttachmentButton->setMenu(addButtonMenu);
updateButtonsVisible();
updateButtonsEnabled();
}
@ -175,19 +209,34 @@ void EntryAttachmentsWidget::newAttachments()
return;
}
NewEntryAttachmentsDialog newEntryDialog(m_entryAttachments, this);
if (newEntryDialog.exec() == QDialog::Accepted) {
emit widgetUpdated();
}
// Create a temporary file to allow the user to edit the attachment
auto newFileName = generateUniqueName(DefaultName, m_entryAttachments->keys());
m_entryAttachments->set(newFileName, QByteArray());
auto currentIndex = m_attachmentsModel->index(m_attachmentsModel->rowByKey(newFileName), 0);
m_ui->attachmentsView->setCurrentIndex(currentIndex);
m_ui->attachmentsView->edit(currentIndex);
}
void EntryAttachmentsWidget::previewSelectedAttachment()
{
Q_ASSERT(m_entryAttachments);
const auto index = m_ui->attachmentsView->selectionModel()->selectedIndexes().first();
const auto selectionModel = m_ui->attachmentsView->selectionModel();
if (!selectionModel) {
qWarning() << "Failed to preview an attachment: No selection model";
return;
}
auto indexes = selectionModel->selectedIndexes();
if (indexes.empty()) {
qWarning() << "Failed to edit an attachment: No attachment selected";
return;
}
const auto index = indexes.first();
if (!index.isValid()) {
qWarning() << tr("Failed to preview an attachment: Attachment not found");
qWarning() << "Failed to preview an attachment: Attachment not found";
return;
}
@ -198,7 +247,7 @@ void EntryAttachmentsWidget::previewSelectedAttachment()
auto data = m_entryAttachments->value(name);
PreviewEntryAttachmentsDialog previewDialog(this);
previewDialog.setAttachment(name, data);
previewDialog.setAttachment({name, data});
connect(&previewDialog, SIGNAL(openAttachment(QString)), SLOT(openSelectedAttachments()));
connect(&previewDialog, SIGNAL(saveAttachment(QString)), SLOT(saveSelectedAttachments()));
@ -208,7 +257,7 @@ void EntryAttachmentsWidget::previewSelectedAttachment()
&previewDialog,
[&previewDialog, &name, this](const QString& key) {
if (key == name) {
previewDialog.setAttachment(name, m_entryAttachments->value(name));
previewDialog.setAttachment({name, m_entryAttachments->value(name)});
}
});
@ -218,6 +267,51 @@ void EntryAttachmentsWidget::previewSelectedAttachment()
setFocus();
}
void EntryAttachmentsWidget::editSelectedAttachment()
{
Q_ASSERT(m_entryAttachments);
const auto selectionModel = m_ui->attachmentsView->selectionModel();
if (!selectionModel) {
qWarning() << "Failed to edit an attachment: No selection model";
return;
}
const auto selectedIndexes = selectionModel->selectedIndexes();
if (selectedIndexes.isEmpty()) {
qWarning() << "Failed to edit an attachment: No attachment selected";
return;
}
const auto index = selectedIndexes.first();
if (!index.isValid()) {
qWarning() << "Failed to edit an attachment: Attachment not found";
return;
}
// Set selection to the first
m_ui->attachmentsView->setCurrentIndex(index);
auto name = m_attachmentsModel->keyByIndex(index);
auto data = m_entryAttachments->value(name);
EditEntryAttachmentsDialog editDialog(this);
editDialog.setAttachment({name, data});
if (editDialog.exec() == QDialog::Accepted) {
auto attachment = editDialog.getAttachment();
// Edit dialog cannot change the name of the attachment
if (attachment.name == name) {
m_entryAttachments->set(attachment.name, attachment.data);
}
}
// Set focus back to the widget to allow keyboard navigation
setFocus();
}
void EntryAttachmentsWidget::removeSelectedAttachments()
{
Q_ASSERT(m_entryAttachments);
@ -346,35 +440,38 @@ void EntryAttachmentsWidget::openSelectedAttachments()
void EntryAttachmentsWidget::updateButtonsEnabled()
{
const bool hasSelection = m_ui->attachmentsView->selectionModel()->hasSelection();
const auto selectionModel = m_ui->attachmentsView->selectionModel();
const bool hasSelection = selectionModel && selectionModel->hasSelection();
m_ui->addAttachmentButton->setEnabled(!m_readOnly);
m_ui->newAttachmentButton->setEnabled(!m_readOnly);
m_ui->removeAttachmentButton->setEnabled(hasSelection && !m_readOnly);
m_ui->editAttachmentButton->setEnabled(hasSelection && !m_readOnly);
if (const auto indexes = selectionModel ? selectionModel->selectedIndexes() : QModelIndexList{}; !indexes.empty()) {
auto mimeType = Tools::getMimeType(m_entryAttachments->value(m_attachmentsModel->keyByIndex(indexes.first())));
m_ui->editAttachmentButton->setEnabled(hasSelection && !m_readOnly && Tools::isTextMimeType(mimeType));
}
m_ui->saveAttachmentButton->setEnabled(hasSelection);
m_ui->previewAttachmentButton->setEnabled(hasSelection);
m_ui->openAttachmentButton->setEnabled(hasSelection);
updateSpacers();
updateLinesVisibility();
}
void EntryAttachmentsWidget::updateSpacers()
void EntryAttachmentsWidget::updateLinesVisibility()
{
if (m_buttonsVisible && !m_readOnly) {
m_ui->previewVSpacer->changeSize(20, 40, QSizePolicy::Fixed, QSizePolicy::Expanding);
} else {
m_ui->previewVSpacer->changeSize(0, 0, QSizePolicy::Fixed, QSizePolicy::Fixed);
}
m_ui->editPreviewLine->setVisible(m_buttonsVisible && !m_readOnly);
m_ui->previewRemoveLine->setVisible(m_buttonsVisible && !m_readOnly);
}
void EntryAttachmentsWidget::updateButtonsVisible()
{
m_ui->addAttachmentButton->setVisible(m_buttonsVisible && !m_readOnly);
m_ui->newAttachmentButton->setVisible(m_buttonsVisible && !m_readOnly);
m_ui->editAttachmentButton->setVisible(m_buttonsVisible && !m_readOnly);
m_ui->removeAttachmentButton->setVisible(m_buttonsVisible && !m_readOnly);
updateSpacers();
updateLinesVisibility();
}
bool EntryAttachmentsWidget::insertAttachments(const QStringList& filenames, QString& errorMessage)

View file

@ -58,6 +58,7 @@ signals:
private slots:
void insertAttachments();
void newAttachments();
void editSelectedAttachment();
void previewSelectedAttachment();
void removeSelectedAttachments();
void saveSelectedAttachments();
@ -68,7 +69,7 @@ private slots:
void attachmentModifiedExternally(const QString& key, const QString& filePath);
private:
void updateSpacers();
void updateLinesVisibility();
bool insertAttachments(const QStringList& fileNames, QString& errorMessage);

View file

@ -6,8 +6,8 @@
<rect>
<x>0</x>
<y>0</y>
<width>337</width>
<height>258</height>
<width>332</width>
<height>312</height>
</rect>
</property>
<property name="windowTitle">
@ -47,7 +47,7 @@
</item>
<item>
<widget class="QWidget" name="actionsWidget" native="true">
<layout class="QVBoxLayout" name="verticalLayout" stretch="0,0,1,0,0,0,1,0,0">
<layout class="QVBoxLayout" name="verticalLayout" stretch="0,0,0,0,0,0,0,0,0">
<property name="leftMargin">
<number>0</number>
</property>
@ -60,16 +60,6 @@
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QPushButton" name="newAttachmentButton">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string>New</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="addAttachmentButton">
<property name="enabled">
@ -79,22 +69,26 @@
<string>Add new attachment</string>
</property>
<property name="text">
<string>Add</string>
<string>Add file…</string>
</property>
</widget>
</item>
<item>
<spacer name="previewVSpacer">
<widget class="QPushButton" name="editAttachmentButton">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string>Edit</string>
</property>
</widget>
</item>
<item>
<widget class="Line" name="editPreviewLine">
<property name="orientation">
<enum>Qt::Vertical</enum>
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</widget>
</item>
<item>
<widget class="QPushButton" name="previewAttachmentButton">
@ -128,22 +122,19 @@
<string>Save selected attachment to disk</string>
</property>
<property name="text">
<string>Save</string>
<string>Save</string>
</property>
</widget>
</item>
<item>
<spacer name="verticalSpacer_2">
<widget class="Line" name="previewRemoveLine">
<property name="enabled">
<bool>true</bool>
</property>
<property name="orientation">
<enum>Qt::Vertical</enum>
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>173</height>
</size>
</property>
</spacer>
</widget>
</item>
<item>
<widget class="QPushButton" name="removeAttachmentButton">
@ -164,7 +155,7 @@
<enum>Qt::Vertical</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Minimum</enum>
<enum>QSizePolicy::Expanding</enum>
</property>
<property name="sizeHint" stdset="0">
<size>

View file

@ -1,90 +0,0 @@
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include "NewEntryAttachmentsDialog.h"
#include "core/EntryAttachments.h"
#include "ui_EntryAttachmentsDialog.h"
#include <QMessageBox>
#include <QPushButton>
NewEntryAttachmentsDialog::NewEntryAttachmentsDialog(QPointer<EntryAttachments> attachments, QWidget* parent)
: QDialog(parent)
, m_attachments(std::move(attachments))
, m_ui(new Ui::EntryAttachmentsDialog)
{
Q_ASSERT(m_attachments);
m_ui->setupUi(this);
setWindowTitle(tr("New entry attachment"));
m_ui->dialogButtons->setStandardButtons(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
connect(m_ui->dialogButtons, SIGNAL(accepted()), this, SLOT(saveAttachment()));
connect(m_ui->dialogButtons, SIGNAL(rejected()), this, SLOT(reject()));
connect(m_ui->titleEdit, SIGNAL(textChanged(const QString&)), this, SLOT(fileNameTextChanged(const QString&)));
fileNameTextChanged(m_ui->titleEdit->text());
}
NewEntryAttachmentsDialog::~NewEntryAttachmentsDialog() = default;
bool NewEntryAttachmentsDialog::validateFileName(const QString& fileName, QString& error) const
{
if (fileName.isEmpty()) {
error = tr("Attachment name cannot be empty");
return false;
}
if (m_attachments->hasKey(fileName)) {
error = tr("Attachment with the same name already exists");
return false;
}
return true;
}
void NewEntryAttachmentsDialog::saveAttachment()
{
auto fileName = m_ui->titleEdit->text();
auto text = m_ui->attachmentTextEdit->toPlainText().toUtf8();
QString error;
if (!validateFileName(fileName, error)) {
QMessageBox::warning(this, tr("Save attachment"), error);
return;
}
m_attachments->set(fileName, text);
accept();
}
void NewEntryAttachmentsDialog::fileNameTextChanged(const QString& fileName)
{
QString error;
bool valid = validateFileName(fileName, error);
m_ui->errorLabel->setText(error);
m_ui->errorLabel->setVisible(!valid);
auto okButton = m_ui->dialogButtons->button(QDialogButtonBox::Ok);
if (okButton) {
okButton->setDisabled(!valid);
}
}

View file

@ -16,117 +16,48 @@
*/
#include "PreviewEntryAttachmentsDialog.h"
#include "ui_EntryAttachmentsDialog.h"
#include "ui_PreviewEntryAttachmentsDialog.h"
#include <QDebug>
#include <QDialogButtonBox>
#include <QMimeDatabase>
#include <QPushButton>
#include <QTextCursor>
#include <QtDebug>
PreviewEntryAttachmentsDialog::PreviewEntryAttachmentsDialog(QWidget* parent)
: QDialog(parent)
, m_ui(new Ui::EntryAttachmentsDialog)
, m_ui(new Ui::PreviewEntryAttachmentsDialog)
{
m_ui->setupUi(this);
setWindowTitle(tr("Preview entry attachment"));
// Disable the help button in the title bar
setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint);
// Set to read-only
m_ui->titleEdit->setReadOnly(true);
m_ui->attachmentTextEdit->setReadOnly(true);
m_ui->errorLabel->setVisible(false);
// Initialize dialog buttons
m_ui->dialogButtons->setStandardButtons(QDialogButtonBox::Close | QDialogButtonBox::Open | QDialogButtonBox::Save);
auto closeButton = m_ui->dialogButtons->button(QDialogButtonBox::Close);
closeButton->setDefault(true);
connect(m_ui->dialogButtons, SIGNAL(rejected()), this, SLOT(reject()));
auto saveButton = m_ui->dialogButtons->button(QDialogButtonBox::Save);
saveButton->setText(tr("Save…"));
connect(m_ui->dialogButtons, &QDialogButtonBox::rejected, this, &PreviewEntryAttachmentsDialog::reject);
connect(m_ui->dialogButtons, &QDialogButtonBox::clicked, [this](QAbstractButton* button) {
auto pressedButton = m_ui->dialogButtons->standardButton(button);
const auto attachment = m_ui->attachmentWidget->getAttachment();
if (pressedButton == QDialogButtonBox::Open) {
emit openAttachment(m_name);
emit openAttachment(attachment.name);
} else if (pressedButton == QDialogButtonBox::Save) {
emit saveAttachment(m_name);
emit saveAttachment(attachment.name);
}
});
}
PreviewEntryAttachmentsDialog::~PreviewEntryAttachmentsDialog() = default;
void PreviewEntryAttachmentsDialog::setAttachment(const QString& name, const QByteArray& data)
void PreviewEntryAttachmentsDialog::setAttachment(attachments::Attachment attachment)
{
m_name = name;
m_ui->titleEdit->setText(m_name);
setWindowTitle(tr("Preview: %1").arg(attachment.name));
m_type = attachmentType(data);
m_data = data;
m_imageCache = QImage();
update();
}
void PreviewEntryAttachmentsDialog::update()
{
if (m_type == Tools::MimeType::Unknown) {
updateTextAttachment(tr("No preview available").toUtf8());
} else if (m_type == Tools::MimeType::Image) {
updateImageAttachment(m_data);
} else if (m_type == Tools::MimeType::PlainText) {
updateTextAttachment(m_data);
}
}
void PreviewEntryAttachmentsDialog::updateTextAttachment(const QByteArray& data)
{
m_ui->attachmentTextEdit->setPlainText(QString::fromUtf8(data));
}
void PreviewEntryAttachmentsDialog::updateImageAttachment(const QByteArray& data)
{
if (m_imageCache.isNull() && !m_imageCache.loadFromData(data)) {
updateTextAttachment(tr("Image format not supported").toUtf8());
return;
}
updateImageAttachment(m_imageCache);
}
void PreviewEntryAttachmentsDialog::updateImageAttachment(const QImage& image)
{
m_ui->attachmentTextEdit->clear();
auto cursor = m_ui->attachmentTextEdit->textCursor();
cursor.insertImage(image.scaled(calculateImageSize(), Qt::KeepAspectRatio, Qt::SmoothTransformation));
}
QSize PreviewEntryAttachmentsDialog::calculateImageSize()
{
// Scale the image to the contents rect minus another set of margins to avoid scrollbars
auto margins = m_ui->attachmentTextEdit->contentsMargins();
auto size = m_ui->attachmentTextEdit->contentsRect().size();
size.setWidth(size.width() - margins.left() - margins.right());
size.setHeight(size.height() - margins.top() - margins.bottom());
return size;
}
Tools::MimeType PreviewEntryAttachmentsDialog::attachmentType(const QByteArray& data) const
{
QMimeDatabase mimeDb{};
const auto mime = mimeDb.mimeTypeForData(data);
return Tools::toMimeType(mime.name());
}
void PreviewEntryAttachmentsDialog::resizeEvent(QResizeEvent* event)
{
QDialog::resizeEvent(event);
if (m_type == Tools::MimeType::Image) {
update();
}
m_ui->attachmentWidget->openAttachment(std::move(attachment), attachments::OpenMode::ReadOnly);
}

View file

@ -17,14 +17,16 @@
#pragma once
#include "core/Tools.h"
#include "attachments/AttachmentTypes.h"
#include <core/Tools.h>
#include <QDialog>
#include <QPointer>
namespace Ui
{
class EntryAttachmentsDialog;
class PreviewEntryAttachmentsDialog;
}
class PreviewEntryAttachmentsDialog : public QDialog
@ -35,29 +37,12 @@ public:
explicit PreviewEntryAttachmentsDialog(QWidget* parent = nullptr);
~PreviewEntryAttachmentsDialog() override;
void setAttachment(const QString& name, const QByteArray& data);
void setAttachment(attachments::Attachment attachment);
signals:
void openAttachment(const QString& name);
void saveAttachment(const QString& name);
protected:
void resizeEvent(QResizeEvent* event) override;
private:
Tools::MimeType attachmentType(const QByteArray& data) const;
void update();
void updateTextAttachment(const QByteArray& data);
void updateImageAttachment(const QByteArray& data);
void updateImageAttachment(const QImage& data);
QSize calculateImageSize();
QScopedPointer<Ui::EntryAttachmentsDialog> m_ui;
QString m_name;
QByteArray m_data;
QImage m_imageCache;
Tools::MimeType m_type{Tools::MimeType::Unknown};
QScopedPointer<Ui::PreviewEntryAttachmentsDialog> m_ui;
};

View file

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>PreviewEntryAttachmentsDialog</class>
<widget class="QDialog" name="PreviewEntryAttachmentsDialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>557</width>
<height>454</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout" stretch="1,0">
<property name="leftMargin">
<number>6</number>
</property>
<property name="topMargin">
<number>6</number>
</property>
<property name="rightMargin">
<number>6</number>
</property>
<property name="bottomMargin">
<number>6</number>
</property>
<item>
<widget class="AttachmentWidget" name="attachmentWidget" native="true">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
<item>
<widget class="QDialogButtonBox" name="dialogButtons">
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
</property>
</widget>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>AttachmentWidget</class>
<extends>QWidget</extends>
<header location="global">gui/entry/attachments/AttachmentWidget.h</header>
<container>1</container>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View file

@ -0,0 +1,37 @@
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <QByteArray>
#include <QString>
namespace attachments
{
struct Attachment
{
QString name;
QByteArray data;
};
enum class OpenMode
{
ReadOnly,
ReadWrite
};
} // namespace attachments

View file

@ -0,0 +1,89 @@
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include "AttachmentWidget.h"
#include "ImageAttachmentsWidget.h"
#include "TextAttachmentsWidget.h"
#include <core/Tools.h>
#include <QLabel>
#include <QVBoxLayout>
AttachmentWidget::AttachmentWidget(QWidget* parent)
: QWidget(parent)
{
setWindowTitle(tr("Attachment Viewer"));
auto verticalLayout = new QVBoxLayout(this);
verticalLayout->setSpacing(0);
verticalLayout->setObjectName(QString::fromUtf8("verticalLayout"));
verticalLayout->setContentsMargins(0, 0, 0, 0);
verticalLayout->setAlignment(Qt::AlignCenter);
}
AttachmentWidget::~AttachmentWidget() = default;
void AttachmentWidget::openAttachment(attachments::Attachment attachment, attachments::OpenMode mode)
{
m_attachment = std::move(attachment);
m_mode = mode;
updateUi();
}
void AttachmentWidget::updateUi()
{
auto type = Tools::getMimeType(m_attachment.data);
if (m_attachmentWidget) {
layout()->removeWidget(m_attachmentWidget);
m_attachmentWidget->deleteLater();
}
if (Tools::isTextMimeType(type)) {
auto widget = new TextAttachmentsWidget(this);
widget->openAttachment(m_attachment, m_mode);
m_attachmentWidget = widget;
} else if (type == Tools::MimeType::Image) {
auto widget = new ImageAttachmentsWidget(this);
widget->openAttachment(m_attachment, m_mode);
m_attachmentWidget = widget;
} else {
auto label = new QLabel(tr("Unknown attachment type"), this);
label->setAlignment(Qt::AlignCenter);
m_attachmentWidget = label;
}
Q_ASSERT(m_attachmentWidget);
m_attachmentWidget->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
layout()->addWidget(m_attachmentWidget);
}
attachments::Attachment AttachmentWidget::getAttachment() const
{
// Text attachments can be edited at this time so pass this call forward
if (auto textWidget = qobject_cast<TextAttachmentsWidget*>(m_attachmentWidget)) {
return textWidget->getAttachment();
}
return m_attachment;
}

View file

@ -0,0 +1,67 @@
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include "AttachmentTypes.h"
#include <core/Tools.h>
#include <QPointer>
#include <QScopedPointer>
#include <QWidget>
namespace Ui
{
class AttachmentWidget;
}
/**
* @brief The AttachmentWidget class provides a way to manage attachments in a GUI application.
*
*/
class AttachmentWidget : public QWidget
{
Q_OBJECT
public:
explicit AttachmentWidget(QWidget* parent = nullptr);
~AttachmentWidget() override;
/**
* @brief Opens an attachment in the specified mode.
*
* @param attachment - The attachment to be opened.
* @param mode - The mode in which to open the attachment (read-only or read-write).
*/
void openAttachment(attachments::Attachment attachment, attachments::OpenMode mode);
/**
* @brief Get the current attachment.
*
* @return Attachment - The current attachment.
*/
attachments::Attachment getAttachment() const;
private:
void updateUi();
QPointer<QWidget> m_attachmentWidget;
attachments::Attachment m_attachment;
attachments::OpenMode m_mode;
};

View file

@ -0,0 +1,98 @@
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include "ImageAttachmentsView.h"
#include <QDebug>
#include <QWheelEvent>
#include <limits>
ImageAttachmentsView::ImageAttachmentsView(QWidget* parent)
: QGraphicsView(parent)
{
}
void ImageAttachmentsView::wheelEvent(QWheelEvent* event)
{
if (event->modifiers() == Qt::ControlModifier) {
emit ctrlWheelEvent(event);
return;
}
QGraphicsView::wheelEvent(event);
}
void ImageAttachmentsView::resizeEvent(QResizeEvent* event)
{
QGraphicsView::resizeEvent(event);
if (m_autoFitInView) {
fitSceneInView();
}
}
void ImageAttachmentsView::showEvent(QShowEvent* event)
{
if (m_autoFitInView) {
fitSceneInView();
}
QGraphicsView::showEvent(event);
}
void ImageAttachmentsView::fitSceneInView()
{
if (auto scene = ImageAttachmentsView::scene()) {
ImageAttachmentsView::fitInView(scene->itemsBoundingRect(), Qt::KeepAspectRatio);
}
}
void ImageAttachmentsView::enableAutoFitInView()
{
m_autoFitInView = true;
fitSceneInView();
}
void ImageAttachmentsView::disableAutoFitInView()
{
m_autoFitInView = false;
}
bool ImageAttachmentsView::isAutoFitInViewActivated() const
{
return m_autoFitInView;
}
double ImageAttachmentsView::calculateFitInViewFactor() const
{
auto viewPort = viewport();
if (auto currentScene = scene(); currentScene && viewPort) {
const auto itemsRect = currentScene->itemsBoundingRect().size();
// If the image rect is empty
if (itemsRect.isEmpty()) {
return std::numeric_limits<double>::quiet_NaN();
}
const auto viewPortSize = viewPort->size();
// Calculate the zoom factor based on the current size and the image rect
return std::min(viewPortSize.width() / itemsRect.width(), viewPortSize.height() / itemsRect.height());
}
return std::numeric_limits<double>::quiet_NaN();
}

View file

@ -0,0 +1,46 @@
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <QGraphicsView>
class ImageAttachmentsView : public QGraphicsView
{
Q_OBJECT
public:
explicit ImageAttachmentsView(QWidget* parent = nullptr);
void enableAutoFitInView();
void disableAutoFitInView();
bool isAutoFitInViewActivated() const;
double calculateFitInViewFactor() const;
signals:
void ctrlWheelEvent(QWheelEvent* event);
protected:
void wheelEvent(QWheelEvent* event) override;
void showEvent(QShowEvent* event) override;
void resizeEvent(QResizeEvent* event) override;
private:
void fitSceneInView();
bool m_autoFitInView = false;
};

View file

@ -0,0 +1,258 @@
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include "ImageAttachmentsWidget.h"
#include "ui_ImageAttachmentsWidget.h"
#include <array>
#include <cmath>
#include <utility>
#include <QDebug>
#include <QEvent>
#include <QGraphicsScene>
#include <QLineEdit>
#include <QPixmap>
#include <QSizeF>
#include <QWheelEvent>
namespace
{
// Predefined zoom levels must be in ascending order
constexpr std::array ZoomList = {0.25, 0.5, 0.75, 1.0, 2.0};
constexpr double WheelZoomStep = 1.1;
const QString FitText = QObject::tr("Fit");
QString formatZoomText(double zoomFactor)
{
return QString("%1%").arg(QString::number(zoomFactor * 100, 'f', 0));
}
double parseZoomText(const QString& zoomText)
{
auto zoomTextTrimmed = zoomText.trimmed();
if (auto percentIndex = zoomTextTrimmed.indexOf('%'); percentIndex != -1) {
// Remove the '%' character and parse the number
zoomTextTrimmed = zoomTextTrimmed.left(percentIndex).trimmed();
}
bool ok;
double zoomFactor = zoomTextTrimmed.toDouble(&ok);
if (!ok) {
qWarning() << "Failed to parse zoom text:" << zoomText;
return std::numeric_limits<double>::quiet_NaN();
}
return zoomFactor / 100.0;
}
} // namespace
ImageAttachmentsWidget::ImageAttachmentsWidget(QWidget* parent)
: QWidget(parent)
, m_ui(new Ui::ImageAttachmentsWidget)
{
m_ui->setupUi(this);
m_scene = new QGraphicsScene(this);
m_ui->imagesView->setScene(m_scene);
m_ui->imagesView->setDragMode(QGraphicsView::ScrollHandDrag);
m_ui->imagesView->setHorizontalScrollBarPolicy(Qt::ScrollBarPolicy::ScrollBarAlwaysOff);
m_ui->imagesView->setVerticalScrollBarPolicy(Qt::ScrollBarPolicy::ScrollBarAlwaysOff);
connect(m_ui->imagesView, &ImageAttachmentsView::ctrlWheelEvent, this, &ImageAttachmentsWidget::onWheelZoomEvent);
static_assert(ZoomList.size() > 0, "ZoomList must not be empty");
static_assert(ZoomList.front() < ZoomList.back(), "ZoomList must be in ascending order");
m_zoomHelper = new ZoomHelper(1.0, WheelZoomStep, ZoomList.front(), ZoomList.back(), this);
connect(m_zoomHelper, &ZoomHelper::zoomChanged, this, &ImageAttachmentsWidget::onZoomFactorChanged);
initZoomComboBox();
}
ImageAttachmentsWidget::~ImageAttachmentsWidget() = default;
void ImageAttachmentsWidget::initZoomComboBox()
{
m_ui->zoomComboBox->clear();
auto textWidth = m_ui->zoomComboBox->fontMetrics().horizontalAdvance(FitText);
m_ui->zoomComboBox->addItem(FitText, 0.0);
for (const auto& zoom : ZoomList) {
auto zoomText = formatZoomText(zoom);
textWidth = std::max(textWidth, m_ui->zoomComboBox->fontMetrics().horizontalAdvance(zoomText));
m_ui->zoomComboBox->addItem(zoomText, zoom);
}
constexpr int minWidth = 50;
m_ui->zoomComboBox->setMinimumWidth(textWidth + minWidth);
connect(m_ui->zoomComboBox, &QComboBox::currentTextChanged, this, &ImageAttachmentsWidget::onZoomChanged);
connect(m_ui->zoomComboBox->lineEdit(), &QLineEdit::editingFinished, [this]() {
onZoomChanged(m_ui->zoomComboBox->lineEdit()->text());
});
// Fit by default
m_ui->zoomComboBox->setCurrentIndex(m_ui->zoomComboBox->findData(0.0));
onZoomChanged(m_ui->zoomComboBox->currentText());
}
void ImageAttachmentsWidget::onWheelZoomEvent(QWheelEvent* event)
{
m_ui->imagesView->disableAutoFitInView();
auto finInViewFactor = m_ui->imagesView->calculateFitInViewFactor();
// Limit the fit-in-view factor to a maximum of 100%
m_zoomHelper->setMinZoomOutFactor(std::isnan(finInViewFactor) ? 1.0 : std::min(finInViewFactor, 1.0));
event->angleDelta().y() > 0 ? m_zoomHelper->zoomIn() : m_zoomHelper->zoomOut();
}
void ImageAttachmentsWidget::onZoomFactorChanged(double zoomFactor)
{
if (m_ui->imagesView->isAutoFitInViewActivated()) {
return;
}
m_ui->imagesView->setTransform(QTransform::fromScale(zoomFactor, zoomFactor));
// Update the zoom combo box to reflect the current zoom factor
if (!m_ui->zoomComboBox->lineEdit()->hasFocus()) {
m_ui->zoomComboBox->setCurrentText(formatZoomText(zoomFactor));
}
}
void ImageAttachmentsWidget::onZoomChanged(const QString& zoomText)
{
auto zoomFactor = 1.0;
if (zoomText == FitText) {
m_ui->imagesView->enableAutoFitInView();
zoomFactor = std::min(m_ui->imagesView->calculateFitInViewFactor(), zoomFactor);
} else {
zoomFactor = parseZoomText(zoomText);
if (!std::isnan(zoomFactor)) {
m_ui->imagesView->disableAutoFitInView();
}
}
if (std::isnan(zoomFactor)) {
return;
}
m_zoomHelper->setZoomFactor(zoomFactor);
}
void ImageAttachmentsWidget::openAttachment(attachments::Attachment attachment, attachments::OpenMode mode)
{
m_attachment = std::move(attachment);
if (mode == attachments::OpenMode::ReadWrite) {
qWarning() << "Read-write mode is not supported for image attachments";
}
loadImage();
}
void ImageAttachmentsWidget::loadImage()
{
QPixmap pixmap{};
pixmap.loadFromData(m_attachment.data);
if (pixmap.isNull()) {
qWarning() << "Failed to load image from data";
return;
}
m_scene->clear();
m_scene->addPixmap(std::move(pixmap));
}
attachments::Attachment ImageAttachmentsWidget::getAttachment() const
{
return m_attachment;
}
// Zoom helper
ZoomHelper::ZoomHelper(double zoomFactor, double step, double min, double max, QObject* parent)
: QObject(parent)
, m_step(step)
, m_minZoomOut(min)
, m_maxZoomIn(max)
{
Q_ASSERT(!std::isnan(step) && step > 0);
Q_ASSERT(!std::isnan(zoomFactor));
Q_ASSERT(!std::isnan(min));
Q_ASSERT(!std::isnan(max));
Q_ASSERT(min < max);
setZoomFactor(zoomFactor);
}
void ZoomHelper::zoomIn()
{
const auto newZoomFactor = m_zoomFactor * m_step;
setZoomFactor(std::isgreater(newZoomFactor, m_maxZoomIn) ? m_zoomFactor : newZoomFactor);
}
void ZoomHelper::zoomOut()
{
const auto newZoomFactor = m_zoomFactor / m_step;
setZoomFactor(std::isless(newZoomFactor, m_minZoomOut) ? m_zoomFactor : newZoomFactor);
}
void ZoomHelper::setZoomFactor(double zoomFactor)
{
if (std::isnan(zoomFactor)) {
qWarning() << "Failed to set NaN zoom factor";
return;
}
auto oldValue = std::exchange(m_zoomFactor, zoomFactor);
if (std::isless(oldValue, m_zoomFactor) || std::isgreater(oldValue, m_zoomFactor)) {
Q_EMIT zoomChanged(m_zoomFactor);
}
}
double ZoomHelper::getZoomFactor() const
{
return m_zoomFactor;
}
void ZoomHelper::setMinZoomOutFactor(double zoomFactor)
{
if (std::isgreater(zoomFactor, m_maxZoomIn)) {
std::swap(m_maxZoomIn, zoomFactor);
}
m_minZoomOut = zoomFactor;
}
void ZoomHelper::setMaxZoomInFactor(double zoomFactor)
{
if (std::isless(zoomFactor, m_minZoomOut)) {
std::swap(m_minZoomOut, zoomFactor);
}
m_maxZoomIn = zoomFactor;
}

View file

@ -0,0 +1,85 @@
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include "AttachmentTypes.h"
#include <QPointer>
#include <QScopedPointer>
#include <QWidget>
namespace Ui
{
class ImageAttachmentsWidget;
}
class QGraphicsView;
class QGraphicsScene;
class ZoomHelper : public QObject
{
Q_OBJECT
public:
explicit ZoomHelper(double zoomFactor, double step, double min, double max, QObject* parent = nullptr);
void zoomIn();
void zoomOut();
void setZoomFactor(double zoomFactor);
double getZoomFactor() const;
void setMinZoomOutFactor(double zoomFactor);
void setMaxZoomInFactor(double zoomFactor);
signals:
void zoomChanged(double zoomFactor);
private:
double m_zoomFactor;
double m_step;
double m_minZoomOut;
double m_maxZoomIn;
};
class ImageAttachmentsWidget : public QWidget
{
Q_OBJECT
public:
explicit ImageAttachmentsWidget(QWidget* parent = nullptr);
~ImageAttachmentsWidget() override;
void openAttachment(attachments::Attachment attachment, attachments::OpenMode mode);
attachments::Attachment getAttachment() const;
private slots:
void onZoomChanged(const QString& zoomText);
void onWheelZoomEvent(QWheelEvent* event);
void onZoomFactorChanged(double zoomFactor);
private:
void loadImage();
void initZoomComboBox();
QScopedPointer<Ui::ImageAttachmentsWidget> m_ui;
attachments::Attachment m_attachment;
QPointer<QGraphicsScene> m_scene;
QPointer<ZoomHelper> m_zoomHelper;
};

View file

@ -0,0 +1,90 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>ImageAttachmentsWidget</class>
<widget class="QWidget" name="ImageAttachmentsWidget">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>400</width>
<height>300</height>
</rect>
</property>
<property name="windowTitle">
<string/>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<layout class="QHBoxLayout" name="horizontalLayout" stretch="0,0,0">
<item>
<widget class="QLabel" name="zoomLabel">
<property name="text">
<string>Zoom:</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="zoomComboBox">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="editable">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item>
<widget class="Line" name="line">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
</widget>
</item>
<item>
<widget class="ImageAttachmentsView" name="imagesView"/>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>ImageAttachmentsView</class>
<extends>QGraphicsView</extends>
<header location="global">gui/entry/attachments/ImageAttachmentsView.h</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View file

@ -0,0 +1,54 @@
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include "TextAttachmentsEditWidget.h"
#include "ui_TextAttachmentsEditWidget.h"
#include <QPushButton>
#include <QTextEdit>
#include <qwidget.h>
TextAttachmentsEditWidget::TextAttachmentsEditWidget(QWidget* parent)
: QWidget(parent)
, m_ui(new Ui::TextAttachmentsEditWidget())
{
m_ui->setupUi(this);
connect(m_ui->attachmentsTextEdit, &QTextEdit::textChanged, this, &TextAttachmentsEditWidget::textChanged);
connect(m_ui->previewPushButton, &QPushButton::clicked, this, &TextAttachmentsEditWidget::previewButtonClicked);
}
TextAttachmentsEditWidget::~TextAttachmentsEditWidget() = default;
void TextAttachmentsEditWidget::openAttachment(attachments::Attachment attachments, attachments::OpenMode mode)
{
m_attachment = std::move(attachments);
m_mode = mode;
updateUi();
}
attachments::Attachment TextAttachmentsEditWidget::getAttachment() const
{
return {m_attachment.name, m_ui->attachmentsTextEdit->toPlainText().toUtf8()};
}
void TextAttachmentsEditWidget::updateUi()
{
m_ui->attachmentsTextEdit->setPlainText(m_attachment.data);
m_ui->attachmentsTextEdit->setReadOnly(m_mode == attachments::OpenMode::ReadOnly);
}

View file

@ -0,0 +1,51 @@
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include "AttachmentTypes.h"
#include <QScopedPointer>
#include <QWidget>
namespace Ui
{
class TextAttachmentsEditWidget;
}
class TextAttachmentsEditWidget : public QWidget
{
Q_OBJECT
public:
explicit TextAttachmentsEditWidget(QWidget* parent = nullptr);
~TextAttachmentsEditWidget() override;
void openAttachment(attachments::Attachment attachment, attachments::OpenMode mode);
attachments::Attachment getAttachment() const;
signals:
void textChanged();
void previewButtonClicked(bool isChecked);
private:
void updateUi();
QScopedPointer<Ui::TextAttachmentsEditWidget> m_ui;
attachments::Attachment m_attachment;
attachments::OpenMode m_mode;
};

View file

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>TextAttachmentsEditWidget</class>
<widget class="QWidget" name="TextAttachmentsEditWidget">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>400</width>
<height>300</height>
</rect>
</property>
<property name="windowTitle">
<string/>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QPushButton" name="previewPushButton">
<property name="text">
<string>Preview</string>
</property>
<property name="checkable">
<bool>false</bool>
</property>
<property name="checked">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item>
<widget class="QTextEdit" name="attachmentsTextEdit"/>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View file

@ -0,0 +1,133 @@
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include "TextAttachmentsPreviewWidget.h"
#include "ui_TextAttachmentsPreviewWidget.h"
#include <core/Tools.h>
#include <QComboBox>
#include <QDebug>
#include <QMetaEnum>
#include <QSortFilterProxyModel>
#include <QStandardItemModel>
namespace
{
constexpr TextAttachmentsPreviewWidget::PreviewTextType ConvertToPreviewTextType(Tools::MimeType mimeType) noexcept
{
if (mimeType == Tools::MimeType::Html) {
return TextAttachmentsPreviewWidget::Html;
}
#if (QT_VERSION >= QT_VERSION_CHECK(5, 14, 0))
if (mimeType == Tools::MimeType::Markdown) {
return TextAttachmentsPreviewWidget::Markdown;
}
#endif
return TextAttachmentsPreviewWidget::PlainText;
}
} // namespace
TextAttachmentsPreviewWidget::TextAttachmentsPreviewWidget(QWidget* parent)
: QWidget(parent)
, m_ui(new Ui::TextAttachmentsPreviewWidget())
{
m_ui->setupUi(this);
initTypeCombobox();
}
TextAttachmentsPreviewWidget::~TextAttachmentsPreviewWidget() = default;
void TextAttachmentsPreviewWidget::openAttachment(attachments::Attachment attachments, attachments::OpenMode mode)
{
if (mode == attachments::OpenMode::ReadWrite) {
qWarning() << "Read-write mode is not supported for text preview attachments";
}
m_attachment = std::move(attachments);
updateUi();
}
attachments::Attachment TextAttachmentsPreviewWidget::getAttachment() const
{
return m_attachment;
}
void TextAttachmentsPreviewWidget::initTypeCombobox()
{
QStandardItemModel* model = new QStandardItemModel(this);
const auto metaEnum = QMetaEnum::fromType<TextAttachmentsPreviewWidget::PreviewTextType>();
for (int i = 0; i < metaEnum.keyCount(); ++i) {
QStandardItem* item = new QStandardItem(metaEnum.key(i));
item->setData(metaEnum.value(i), Qt::UserRole);
model->appendRow(item);
}
QSortFilterProxyModel* filterProxyMode = new QSortFilterProxyModel(this);
filterProxyMode->setSourceModel(model);
filterProxyMode->sort(0, Qt::SortOrder::DescendingOrder);
m_ui->typeComboBox->setModel(filterProxyMode);
connect(m_ui->typeComboBox,
QOverload<int>::of(&QComboBox::currentIndexChanged),
this,
&TextAttachmentsPreviewWidget::onTypeChanged);
m_ui->typeComboBox->setCurrentIndex(m_ui->typeComboBox->findData(PlainText));
onTypeChanged(m_ui->typeComboBox->currentIndex());
}
void TextAttachmentsPreviewWidget::updateUi()
{
if (!m_attachment.name.isEmpty()) {
const auto mimeType = Tools::getMimeType(QFileInfo(m_attachment.name));
auto index = m_ui->typeComboBox->findData(ConvertToPreviewTextType(mimeType));
m_ui->typeComboBox->setCurrentIndex(index);
}
onTypeChanged(m_ui->typeComboBox->currentIndex());
}
void TextAttachmentsPreviewWidget::onTypeChanged(int index)
{
if (index < 0) {
qWarning() << "TextAttachmentsPreviewWidget: Unknown text format";
}
const auto fileType = m_ui->typeComboBox->itemData(index).toInt();
if (fileType == TextAttachmentsPreviewWidget::PreviewTextType::PlainText) {
m_ui->previewTextBrowser->setPlainText(m_attachment.data);
}
if (fileType == TextAttachmentsPreviewWidget::PreviewTextType::Html) {
m_ui->previewTextBrowser->setHtml(m_attachment.data);
}
#if (QT_VERSION >= QT_VERSION_CHECK(5, 14, 0))
if (fileType == TextAttachmentsPreviewWidget::PreviewTextType::Markdown) {
m_ui->previewTextBrowser->setMarkdown(m_attachment.data);
}
#endif
}

View file

@ -0,0 +1,61 @@
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include "AttachmentTypes.h"
#include <QScopedPointer>
#include <QWidget>
namespace Ui
{
class TextAttachmentsPreviewWidget;
}
class TextAttachmentsPreviewWidget : public QWidget
{
Q_OBJECT
public:
explicit TextAttachmentsPreviewWidget(QWidget* parent = nullptr);
~TextAttachmentsPreviewWidget() override;
void openAttachment(attachments::Attachment attachment, attachments::OpenMode mode);
attachments::Attachment getAttachment() const;
enum PreviewTextType : int
{
Html,
PlainText,
#if (QT_VERSION >= QT_VERSION_CHECK(5, 14, 0))
Markdown
#endif
};
Q_ENUM(PreviewTextType)
private slots:
void onTypeChanged(int index);
private:
void initTypeCombobox();
void updateUi();
QScopedPointer<Ui::TextAttachmentsPreviewWidget> m_ui;
attachments::Attachment m_attachment;
};

View file

@ -0,0 +1,70 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>TextAttachmentsPreviewWidget</class>
<widget class="QWidget" name="TextAttachmentsPreviewWidget">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>400</width>
<height>300</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<property name="spacing">
<number>6</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QLabel" name="typeLabel">
<property name="text">
<string>Type:</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="typeComboBox">
<property name="editable">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item>
<widget class="QTextBrowser" name="previewTextBrowser"/>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View file

@ -0,0 +1,105 @@
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include "TextAttachmentsWidget.h"
#include "TextAttachmentsEditWidget.h"
#include "TextAttachmentsPreviewWidget.h"
#include "ui_TextAttachmentsWidget.h"
#include <QSplitter>
#include <QTextEdit>
#include <QTimer>
TextAttachmentsWidget::TextAttachmentsWidget(QWidget* parent)
: QWidget(parent)
, m_ui(new Ui::TextAttachmentsWidget())
, m_previewUpdateTimer(new QTimer(this))
, m_mode(attachments::OpenMode::ReadOnly)
{
m_ui->setupUi(this);
initWidget();
}
TextAttachmentsWidget::~TextAttachmentsWidget() = default;
void TextAttachmentsWidget::openAttachment(attachments::Attachment attachment, attachments::OpenMode mode)
{
m_attachment = std::move(attachment);
m_mode = mode;
updateWidget();
}
attachments::Attachment TextAttachmentsWidget::getAttachment() const
{
if (m_mode == attachments::OpenMode::ReadWrite) {
return m_editWidget->getAttachment();
}
return m_attachment;
}
void TextAttachmentsWidget::updateWidget()
{
if (m_mode == attachments::OpenMode::ReadOnly) {
m_splitter->setSizes({0, 1});
m_editWidget->hide();
} else {
m_splitter->setSizes({1, 0});
m_editWidget->show();
}
m_editWidget->openAttachment(m_attachment, m_mode);
m_previewWidget->openAttachment(m_attachment, attachments::OpenMode::ReadOnly);
}
void TextAttachmentsWidget::initWidget()
{
m_splitter = new QSplitter(this);
m_editWidget = new TextAttachmentsEditWidget(this);
m_previewWidget = new TextAttachmentsPreviewWidget(this);
m_previewUpdateTimer->setSingleShot(true);
m_previewUpdateTimer->setInterval(500);
// Only update the preview after a set timeout and if it is visible
connect(m_previewUpdateTimer, &QTimer::timeout, this, [this] {
if (m_previewWidget->width() > 0) {
m_attachment = m_editWidget->getAttachment();
m_previewWidget->openAttachment(m_attachment, attachments::OpenMode::ReadOnly);
}
});
connect(
m_editWidget, &TextAttachmentsEditWidget::textChanged, m_previewUpdateTimer, QOverload<>::of(&QTimer::start));
connect(m_editWidget, &TextAttachmentsEditWidget::previewButtonClicked, [this] {
const auto sizes = m_splitter->sizes();
const auto previewSize = sizes.value(1, 0) > 0 ? 0 : 1;
m_splitter->setSizes({1, previewSize});
});
m_splitter->addWidget(m_editWidget);
m_splitter->addWidget(m_previewWidget);
// Prevent collapsing of the edit widget
m_splitter->setCollapsible(0, false);
m_ui->verticalLayout->addWidget(m_splitter);
updateWidget();
}

View file

@ -0,0 +1,58 @@
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include "AttachmentTypes.h"
#include <QPointer>
#include <QScopedPointer>
#include <QWidget>
namespace Ui
{
class TextAttachmentsWidget;
}
class QSplitter;
class QTimer;
class TextAttachmentsPreviewWidget;
class TextAttachmentsEditWidget;
class TextAttachmentsWidget : public QWidget
{
Q_OBJECT
public:
explicit TextAttachmentsWidget(QWidget* parent = nullptr);
~TextAttachmentsWidget() override;
void openAttachment(attachments::Attachment attachment, attachments::OpenMode mode);
attachments::Attachment getAttachment() const;
private:
void updateWidget();
void initWidget();
QScopedPointer<Ui::TextAttachmentsWidget> m_ui;
QPointer<QSplitter> m_splitter;
QPointer<TextAttachmentsEditWidget> m_editWidget;
QPointer<TextAttachmentsPreviewWidget> m_previewWidget;
QPointer<QTimer> m_previewUpdateTimer;
attachments::Attachment m_attachment;
attachments::OpenMode m_mode;
};

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>TextAttachmentsWidget</class>
<widget class="QWidget" name="TextAttachmentsWidget">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>732</width>
<height>432</height>
</rect>
</property>
<property name="windowTitle">
<string/>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View file

@ -18,7 +18,9 @@
#include "TestTools.h"
#include "core/Clock.h"
#include "core/Tools.h"
#include <QFileInfo>
#include <QRegularExpression>
#include <QTest>
#include <QUuid>
@ -277,10 +279,8 @@ void TestTools::testMimeTypes()
{
const QStringList TextMimeTypes = {
"text/plain", // Plain text
"text/html", // HTML documents
"text/css", // CSS stylesheets
"text/javascript", // JavaScript files
"text/markdown", // Markdown documents
"text/xml", // XML documents
"text/rtf", // Rich Text Format
"text/vcard", // vCard files
@ -327,6 +327,9 @@ void TestTools::testMimeTypes()
"application/x-shellscript", // Shell scripts
};
QCOMPARE(Tools::toMimeType("text/html"), Tools::MimeType::Html);
QCOMPARE(Tools::toMimeType("text/markdown"), Tools::MimeType::Markdown);
for (const auto& mime : TextMimeTypes) {
QCOMPARE(Tools::toMimeType(mime), Tools::MimeType::PlainText);
}
@ -339,3 +342,89 @@ void TestTools::testMimeTypes()
QCOMPARE(Tools::toMimeType(mime), Tools::MimeType::Unknown);
}
}
void TestTools::testGetMimeType()
{
const QStringList Text = {"0x42", ""};
for (const auto& text : Text) {
QCOMPARE(Tools::getMimeType(text.toUtf8()), Tools::MimeType::PlainText);
}
const QByteArrayList ImageHeaders = {
// JPEG: starts with 0xFF 0xD8 0xFF (Start of Image marker)
QByteArray::fromHex("FFD8FF"),
// PNG: starts with 0x89 0x50 0x4E 0x47 0D 0A 1A 0A (PNG signature)
QByteArray::fromHex("89504E470D0A1A0A"),
// GIF87a: original GIF format (1987 standard)
QByteArray("GIF87a"),
// GIF89a: extended GIF format (1989, supports animation, transparency, etc.)
QByteArray("GIF89a"),
};
for (const auto& image : ImageHeaders) {
QCOMPARE(Tools::getMimeType(image), Tools::MimeType::Image);
}
const QByteArrayList UnknownHeaders = {
// MP3: typically starts with ID3 tag (ID3v2)
QByteArray("ID3"),
// MP4: usually starts with a 'ftyp' box (ISO base media file format)
// Common major brands: isom, mp42, avc1, etc.
QByteArray::fromHex("000000186674797069736F6D"), // size + 'ftyp' + 'isom'
// PDF: starts with "%PDF-" followed by version (e.g., %PDF-1.7)
QByteArray("%PDF-"),
};
for (const auto& unknown : UnknownHeaders) {
QCOMPARE(Tools::getMimeType(unknown), Tools::MimeType::Unknown);
}
}
void TestTools::testGetMimeTypeByFileInfo()
{
const QStringList Text = {"test.txt", "test.csv", "test.xml", "test.json"};
for (const auto& text : Text) {
QCOMPARE(Tools::getMimeType(QFileInfo(text)), Tools::MimeType::PlainText);
}
const QStringList Images = {"test.jpg", "test.png", "test.bmp", "test.svg"};
for (const auto& image : Images) {
QCOMPARE(Tools::getMimeType(QFileInfo(image)), Tools::MimeType::Image);
}
const QStringList Htmls = {"test.html", "test.htm"};
for (const auto& html : Htmls) {
QCOMPARE(Tools::getMimeType(QFileInfo(html)), Tools::MimeType::Html);
}
const QStringList Markdowns = {"test.md", "test.markdown"};
for (const auto& makdown : Markdowns) {
QCOMPARE(Tools::getMimeType(QFileInfo(makdown)), Tools::MimeType::Markdown);
}
const QStringList UnknownHeaders = {"test.doc", "test.pdf", "test.docx"};
for (const auto& unknown : UnknownHeaders) {
QCOMPARE(Tools::getMimeType(unknown), Tools::MimeType::Unknown);
}
}
void TestTools::testIsTextMimeType()
{
const auto Text = {Tools::MimeType::PlainText, Tools::MimeType::Html, Tools::MimeType::Markdown};
for (const auto& text : Text) {
QVERIFY(Tools::isTextMimeType(text));
}
const auto NoText = {Tools::MimeType::Image, Tools::MimeType::Unknown};
for (const auto& noText : NoText) {
QVERIFY(!Tools::isTextMimeType(noText));
}
}

View file

@ -18,7 +18,7 @@
#ifndef KEEPASSX_TESTTOOLS_H
#define KEEPASSX_TESTTOOLS_H
#include "core/Tools.h"
#include <QObject>
class TestTools : public QObject
{
@ -38,6 +38,9 @@ private slots:
void testConvertToRegex_data();
void testArrayContainsValues();
void testMimeTypes();
void testGetMimeType();
void testGetMimeTypeByFileInfo();
void testIsTextMimeType();
};
#endif // KEEPASSX_TESTTOOLS_H

View file

@ -18,6 +18,10 @@ include_directories(${CMAKE_CURRENT_SOURCE_DIR}/..)
add_unit_test(NAME testgui SOURCES TestGui.cpp ../util/TemporaryFile.cpp ../mock/MockRemoteProcess.cpp LIBS ${TEST_LIBRARIES})
add_unit_test(NAME testguipixmaps SOURCES TestGuiPixmaps.cpp LIBS ${TEST_LIBRARIES})
file(GLOB_RECURSE ATTACHMENTS_TEST_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/attachments/*.cpp)
add_unit_test(NAME testguiattachments SOURCES ${ATTACHMENTS_TEST_SOURCES} LIBS ${TEST_LIBRARIES})
include_directories(testguiattachments PRIVATE ${PROJECT_SOURCE_DIR}/src/gui/entry)
if(WITH_XC_BROWSER)
add_unit_test(NAME testguibrowser SOURCES TestGuiBrowser.cpp ../util/TemporaryFile.cpp LIBS ${TEST_LIBRARIES})
endif()

View file

@ -0,0 +1,94 @@
#include "TestAttachmentWidget.h"
#include <attachments/AttachmentWidget.h>
#include <attachments/AttachmentTypes.h>
#include <attachments/ImageAttachmentsWidget.h>
#include <attachments/TextAttachmentsWidget.h>
#include <QLabel>
#include <QTest>
#include <QVBoxLayout>
void TestAttachmentsWidget::initTestCase()
{
m_attachmentWidget.reset(new AttachmentWidget());
QVERIFY(m_attachmentWidget);
}
void TestAttachmentsWidget::testTextAttachment()
{
for (const auto& attachment : {attachments::Attachment{.name = "Test.txt", .data = "Test"},
attachments::Attachment{.name = "Test.html", .data = "<h1> test </h1>"},
attachments::Attachment{.name = "Test.md", .data = "**bold**"}}) {
for (auto mode : {attachments::OpenMode::ReadWrite, attachments::OpenMode::ReadOnly}) {
m_attachmentWidget->openAttachment(attachment, mode);
QCoreApplication::processEvents();
auto layout = m_attachmentWidget->findChild<QVBoxLayout*>("verticalLayout");
QVERIFY(layout);
QCOMPARE(layout->count(), 1);
auto item = layout->itemAt(0);
QVERIFY(item);
QVERIFY(qobject_cast<TextAttachmentsWidget*>(item->widget()));
auto actualAttachment = m_attachmentWidget->getAttachment();
QCOMPARE(actualAttachment.name, attachment.name);
QCOMPARE(actualAttachment.data, attachment.data);
}
}
}
void TestAttachmentsWidget::testImageAttachment()
{
const auto Attachment = attachments::Attachment{.name = "Test.jpg", .data = QByteArray::fromHex("FFD8FF")};
m_attachmentWidget->openAttachment(Attachment, attachments::OpenMode::ReadWrite);
QCoreApplication::processEvents();
auto layout = m_attachmentWidget->findChild<QVBoxLayout*>("verticalLayout");
QVERIFY(layout);
QCOMPARE(layout->count(), 1);
auto item = layout->itemAt(0);
QVERIFY(item);
QVERIFY(qobject_cast<ImageAttachmentsWidget*>(item->widget()));
auto actualAttachment = m_attachmentWidget->getAttachment();
QCOMPARE(actualAttachment.name, Attachment.name);
QCOMPARE(actualAttachment.data, Attachment.data);
}
void TestAttachmentsWidget::testUnknownAttachment()
{
const auto Attachment = attachments::Attachment{.name = "Test", .data = QByteArray{"ID3"}};
m_attachmentWidget->openAttachment(Attachment, attachments::OpenMode::ReadWrite);
QCoreApplication::processEvents();
auto layout = m_attachmentWidget->findChild<QVBoxLayout*>("verticalLayout");
QVERIFY(layout);
QCOMPARE(layout->count(), 1);
auto item = layout->itemAt(0);
QVERIFY(item);
auto label = qobject_cast<QLabel*>(item->widget());
QVERIFY(label);
QVERIFY(!label->text().isEmpty());
auto actualAttachment = m_attachmentWidget->getAttachment();
QCOMPARE(actualAttachment.name, Attachment.name);
QCOMPARE(actualAttachment.data, Attachment.data);
}

View file

@ -0,0 +1,38 @@
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <attachments/AttachmentWidget.h>
#include <QObject>
#include <QScopedPointer>
class TestAttachmentsWidget : public QObject
{
Q_OBJECT
private slots:
void initTestCase();
void testTextAttachment();
void testImageAttachment();
void testUnknownAttachment();
private:
QScopedPointer<AttachmentWidget> m_attachmentWidget;
};

View file

@ -0,0 +1,46 @@
#include <QtTest>
#include "TestAttachmentWidget.h"
#include "TestEditEntryAttachmentsDialog.h"
#include "TestImageAttachmentsView.h"
#include "TestImageAttachmentsWidget.h"
#include "TestPreviewEntryAttachmentsDialog.h"
#include "TestTextAttachmentsEditWidget.h"
#include "TestTextAttachmentsPreviewWidget.h"
#include "TestTextAttachmentsWidget.h"
#include <config-keepassx.h>
#include <gui/Application.h>
int main(int argc, char* argv[])
{
QApplication::setAttribute(Qt::AA_EnableHighDpiScaling);
QGuiApplication::setAttribute(Qt::AA_UseHighDpiPixmaps);
Application app(argc, argv);
app.setApplicationName("KeePassXC");
app.setApplicationVersion(KEEPASSXC_VERSION);
app.setQuitOnLastWindowClosed(false);
app.setAttribute(Qt::AA_Use96Dpi, true);
app.applyTheme();
TestPreviewEntryAttachmentsDialog previewDialogTest{};
TestEditEntryAttachmentsDialog editDialogTest{};
TestTextAttachmentsWidget textAttachmentsWidget{};
TestTextAttachmentsPreviewWidget textPreviewWidget{};
TestTextAttachmentsEditWidget textEditWidget{};
TestImageAttachmentsWidget imageWidget{};
TestImageAttachmentsView imageView{};
TestAttachmentsWidget attachmentWidget{};
int result = 0;
result |= QTest::qExec(&previewDialogTest, argc, argv);
result |= QTest::qExec(&editDialogTest, argc, argv);
result |= QTest::qExec(&textAttachmentsWidget, argc, argv);
result |= QTest::qExec(&textPreviewWidget, argc, argv);
result |= QTest::qExec(&textEditWidget, argc, argv);
result |= QTest::qExec(&imageWidget, argc, argv);
result |= QTest::qExec(&imageView, argc, argv);
result |= QTest::qExec(&attachmentWidget, argc, argv);
return result;
}

View file

@ -0,0 +1,95 @@
#include "TestEditEntryAttachmentsDialog.h"
#include <attachments/AttachmentWidget.h>
#include <PreviewEntryAttachmentsDialog.h>
#include <QAbstractButton>
#include <QDialogButtonBox>
#include <QSignalSpy>
#include <QSizePolicy>
#include <QTest>
#include <QTestMouseEvent>
#include <QVBoxLayout>
void TestEditEntryAttachmentsDialog::initTestCase()
{
m_editDialog.reset(new EditEntryAttachmentsDialog());
QVERIFY(m_editDialog);
}
void TestEditEntryAttachmentsDialog::testSetAttachment()
{
const attachments::Attachment Test{.name = "text.txt", .data = "Test"};
m_editDialog->setAttachment(Test);
QCoreApplication::processEvents();
QVERIFY2(m_editDialog->windowTitle().contains(Test.name), "Expected file name in the title");
auto layout = m_editDialog->findChild<QVBoxLayout*>("verticalLayout");
QVERIFY2(layout, "QVBoxLayout not found");
QCOMPARE(layout->count(), 2);
auto widget = qobject_cast<AttachmentWidget*>(layout->itemAt(0)->widget());
QVERIFY2(widget, "Expected AttachmentWidget");
auto sizePolicy = widget->sizePolicy();
QCOMPARE(sizePolicy.horizontalPolicy(), QSizePolicy::Expanding);
QCOMPARE(sizePolicy.verticalPolicy(), QSizePolicy::Expanding);
auto attachments = widget->getAttachment();
QCOMPARE(attachments.name, Test.name);
QCOMPARE(attachments.data, Test.data);
}
void TestEditEntryAttachmentsDialog::testSetAttachmentTwice()
{
const attachments::Attachment TestText{.name = "text.txt", .data = "Test"};
m_editDialog->setAttachment(TestText);
QCoreApplication::processEvents();
const attachments::Attachment TestImage{
.name = "test.jpg", .data = QByteArray::fromHex("FFD8FFE000104A46494600010101006000600000FFD9")};
m_editDialog->setAttachment(TestImage);
QCoreApplication::processEvents();
QVERIFY2(m_editDialog->windowTitle().contains(TestImage.name), "Expected file name in the title");
auto layout = m_editDialog->findChild<QVBoxLayout*>("verticalLayout");
QVERIFY2(layout, "QVBoxLayout not found");
QCOMPARE(layout->count(), 2);
auto widget = qobject_cast<AttachmentWidget*>(layout->itemAt(0)->widget());
QVERIFY2(widget, "Expected AttachmentWidget");
auto attachments = widget->getAttachment();
QCOMPARE(attachments.name, TestImage.name);
QCOMPARE(attachments.data, TestImage.data);
}
void TestEditEntryAttachmentsDialog::testBottonsBox()
{
const attachments::Attachment TestText{.name = "text.txt", .data = "Test"};
m_editDialog->setAttachment(TestText);
QCoreApplication::processEvents();
QSignalSpy acceptButton(m_editDialog.data(), &PreviewEntryAttachmentsDialog::accepted);
QSignalSpy closeButton(m_editDialog.data(), &PreviewEntryAttachmentsDialog::rejected);
auto buttonsBox = m_editDialog->findChild<QDialogButtonBox*>();
QVERIFY2(buttonsBox, "ButtonsBox not found");
for (auto button : buttonsBox->buttons()) {
QTest::mouseClick(button, Qt::LeftButton);
}
QCOMPARE(acceptButton.count(), 1);
QCOMPARE(closeButton.count(), 1);
}

View file

@ -0,0 +1,39 @@
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include "EditEntryAttachmentsDialog.h"
#include <QObject>
#include <QScopedPointer>
class TestEditEntryAttachmentsDialog : public QObject
{
Q_OBJECT
private slots:
void initTestCase();
void testSetAttachment();
void testSetAttachmentTwice();
void testBottonsBox();
private:
QScopedPointer<EditEntryAttachmentsDialog> m_editDialog{};
};

View file

@ -0,0 +1,76 @@
#include "TestImageAttachmentsView.h"
#include <attachments/ImageAttachmentsView.h>
#include <QBuffer>
#include <QPixmap>
#include <QSignalSpy>
#include <QTest>
#include <QWheelEvent>
void TestImageAttachmentsView::initTestCase()
{
m_view.reset(new ImageAttachmentsView());
// Generate the black rectange.
QImage image(1000, 1000, QImage::Format_RGB32);
image.fill(Qt::black);
auto scene = new QGraphicsScene();
scene->addPixmap(QPixmap::fromImage(image));
m_view->setScene(scene);
m_view->show();
QCoreApplication::processEvents();
}
void TestImageAttachmentsView::testEmitWheelEvent()
{
QSignalSpy ctrlWheelEvent{m_view.data(), &ImageAttachmentsView::ctrlWheelEvent};
QPoint center = m_view->rect().center();
m_view->setFocus();
QWheelEvent event(center, // local pos
m_view->mapToGlobal(center), // global pos
QPoint(0, 0),
QPoint(0, 120),
Qt::NoButton,
Qt::ControlModifier,
Qt::ScrollBegin,
false);
QCoreApplication::sendEvent(m_view->viewport(), &event);
QCOMPARE(ctrlWheelEvent.count(), 1);
}
void TestImageAttachmentsView::testEnableFit()
{
m_view->enableAutoFitInView();
QVERIFY(m_view->isAutoFitInViewActivated());
const auto oldTransform = m_view->transform();
m_view->resize(m_view->size() + QSize(100, 100));
QCoreApplication::processEvents();
QVERIFY(m_view->transform() != oldTransform);
}
void TestImageAttachmentsView::testDisableFit()
{
m_view->disableAutoFitInView();
QVERIFY(!m_view->isAutoFitInViewActivated());
const auto expectedTransform = m_view->transform();
m_view->resize(m_view->size() + QSize(100, 100));
QCoreApplication::processEvents();
QCOMPARE(m_view->transform(), expectedTransform);
}

View file

@ -0,0 +1,38 @@
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <attachments/ImageAttachmentsView.h>
#include <QObject>
#include <QScopedPointer>
class TestImageAttachmentsView : public QObject
{
Q_OBJECT
private slots:
void initTestCase();
void testEmitWheelEvent();
void testEnableFit();
void testDisableFit();
private:
QScopedPointer<ImageAttachmentsView> m_view{};
};

View file

@ -0,0 +1,244 @@
#include "TestImageAttachmentsWidget.h"
#include <attachments/ImageAttachmentsView.h>
#include <attachments/ImageAttachmentsWidget.h>
#include <QBuffer>
#include <QComboBox>
#include <QCoreApplication>
#include <QTest>
#include <QTransform>
void TestImageAttachmentsWidget::initTestCase()
{
m_widget.reset(new ImageAttachmentsWidget());
m_zoomCombobox = m_widget->findChild<QComboBox*>("zoomComboBox");
QVERIFY(m_zoomCombobox);
m_imageAttachmentsView = m_widget->findChild<ImageAttachmentsView*>("imagesView");
QVERIFY(m_imageAttachmentsView);
// Generate the black rectange.
QImage image(1000, 1000, QImage::Format_RGB32);
image.fill(Qt::black);
QByteArray imageBytes{};
QBuffer buffer(&imageBytes);
buffer.open(QIODevice::WriteOnly);
image.save(&buffer, "PNG");
m_widget->openAttachment({.name = "black.png", .data = std::move(imageBytes)}, attachments::OpenMode::ReadOnly);
m_widget->show();
QCoreApplication::processEvents();
}
void TestImageAttachmentsWidget::testFitInView()
{
QCOMPARE(m_zoomCombobox->currentText(), tr("Fit"));
QVERIFY(m_imageAttachmentsView->isAutoFitInViewActivated());
auto zoomFactor = m_imageAttachmentsView->transform();
m_widget->setMinimumSize(m_widget->size() + QSize{100, 100});
QCoreApplication::processEvents();
QVERIFY(zoomFactor != m_imageAttachmentsView->transform());
}
void TestImageAttachmentsWidget::testZoomCombobox()
{
for (const auto zoom : {0.25, 0.5, 1.0, 2.0}) {
auto index = m_zoomCombobox->findData(zoom);
QVERIFY(index != -1);
m_zoomCombobox->setCurrentIndex(index);
QCoreApplication::processEvents();
QCOMPARE(m_imageAttachmentsView->transform(), QTransform::fromScale(zoom, zoom));
}
}
void TestImageAttachmentsWidget::testEditZoomCombobox()
{
for (double i = 0.25; i < 5; i += 0.25) {
m_zoomCombobox->setCurrentText(QString::number(i * 100));
QCoreApplication::processEvents();
QCOMPARE(m_imageAttachmentsView->transform(), QTransform::fromScale(i, i));
}
}
void TestImageAttachmentsWidget::testEditWithPercentZoomCombobox()
{
// Example 100 %
for (double i = 0.25; i < 5; i += 0.25) {
m_zoomCombobox->setCurrentText(QString("%1 %").arg(i * 100));
QCoreApplication::processEvents();
QCOMPARE(m_imageAttachmentsView->transform(), QTransform::fromScale(i, i));
}
// Example 100%
for (double i = 0.25; i < 5; i += 0.25) {
m_zoomCombobox->setCurrentText(QString("%1%").arg(i * 100));
QCoreApplication::processEvents();
QCOMPARE(m_imageAttachmentsView->transform(), QTransform::fromScale(i, i));
}
}
void TestImageAttachmentsWidget::testInvalidValueZoomCombobox()
{
auto index = m_zoomCombobox->findData(1.0);
QVERIFY(index != -1);
m_zoomCombobox->setCurrentIndex(index);
QCoreApplication::processEvents();
const QTransform expectedTransform = m_imageAttachmentsView->transform();
for (const auto& invalidValue : {"Help", "3,4", "", ".", "% 100"}) {
m_zoomCombobox->setCurrentText(invalidValue);
QCoreApplication::processEvents();
QCOMPARE(m_imageAttachmentsView->transform(), expectedTransform);
}
}
void TestImageAttachmentsWidget::testZoomInByMouse()
{
QPoint center = m_imageAttachmentsView->rect().center();
// Set zoom: 100%
auto index = m_zoomCombobox->findData(1.0);
QVERIFY(index != -1);
m_zoomCombobox->setCurrentIndex(index);
m_imageAttachmentsView->setFocus();
QCoreApplication::processEvents();
const auto transform = m_imageAttachmentsView->transform();
QWheelEvent event(center, // local pos
m_imageAttachmentsView->mapToGlobal(center), // global pos
QPoint(0, 0),
QPoint(0, 120),
Qt::NoButton,
Qt::ControlModifier,
Qt::ScrollBegin,
false);
QCoreApplication::sendEvent(m_imageAttachmentsView->viewport(), &event);
QCoreApplication::processEvents();
QTransform t = m_imageAttachmentsView->transform();
QVERIFY(t.m11() > transform.m11());
QVERIFY(t.m22() > transform.m22());
}
void TestImageAttachmentsWidget::testZoomOutByMouse()
{
QPoint center = m_imageAttachmentsView->rect().center();
// Set zoom: 100%
auto index = m_zoomCombobox->findData(1.0);
QVERIFY(index != -1);
m_zoomCombobox->setCurrentIndex(index);
m_imageAttachmentsView->setFocus();
QCoreApplication::processEvents();
const auto transform = m_imageAttachmentsView->transform();
QWheelEvent event(center, // local pos
center, // global pos
QPoint(0, 0),
QPoint(0, -120),
Qt::NoButton,
Qt::ControlModifier,
Qt::ScrollBegin,
true);
QCoreApplication::sendEvent(m_imageAttachmentsView->viewport(), &event);
QCoreApplication::processEvents();
QTransform t = m_imageAttachmentsView->transform();
QVERIFY(t.m11() < transform.m11());
QVERIFY(t.m22() < transform.m22());
}
void TestImageAttachmentsWidget::testZoomLowerBound()
{
m_widget->setMinimumSize(100, 100);
QCoreApplication::processEvents();
auto minFactor = m_imageAttachmentsView->calculateFitInViewFactor();
// Set size less then minFactor
m_zoomCombobox->setCurrentText(QString::number((minFactor * 100.0) / 2));
QCoreApplication::processEvents();
const auto expectTransform = m_imageAttachmentsView->transform();
QPoint center = m_imageAttachmentsView->rect().center();
QWheelEvent event(center, // local pos
center, // global pos
QPoint(0, 0),
QPoint(0, -120),
Qt::NoButton,
Qt::ControlModifier,
Qt::ScrollBegin,
true);
QCoreApplication::sendEvent(m_imageAttachmentsView->viewport(), &event);
QCoreApplication::processEvents();
QCOMPARE(m_imageAttachmentsView->transform(), expectTransform);
}
void TestImageAttachmentsWidget::testZoomUpperBound()
{
m_widget->setMinimumSize(100, 100);
// Set size less then minFactor
m_zoomCombobox->setCurrentText(QString::number(500));
QCoreApplication::processEvents();
const auto expectTransform = m_imageAttachmentsView->transform();
QPoint center = m_imageAttachmentsView->rect().center();
QWheelEvent event(center, // local pos
center, // global pos
QPoint(0, 0),
QPoint(0, 120),
Qt::NoButton,
Qt::ControlModifier,
Qt::ScrollBegin,
true);
QCoreApplication::sendEvent(m_imageAttachmentsView->viewport(), &event);
QCoreApplication::processEvents();
QCOMPARE(m_imageAttachmentsView->transform(), expectTransform);
}

View file

@ -0,0 +1,49 @@
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <attachments/ImageAttachmentsWidget.h>
#include <QComboBox>
#include <QObject>
#include <QScopedPointer>
class ImageAttachmentsView;
class TestImageAttachmentsWidget : public QObject
{
Q_OBJECT
private slots:
void initTestCase();
void testFitInView();
void testZoomCombobox();
void testEditZoomCombobox();
void testEditWithPercentZoomCombobox();
void testInvalidValueZoomCombobox();
void testZoomInByMouse();
void testZoomOutByMouse();
void testZoomLowerBound();
void testZoomUpperBound();
private:
QScopedPointer<ImageAttachmentsWidget> m_widget{};
QPointer<QComboBox> m_zoomCombobox{};
QPointer<ImageAttachmentsView> m_imageAttachmentsView{};
};

View file

@ -0,0 +1,97 @@
#include "TestPreviewEntryAttachmentsDialog.h"
#include <attachments/AttachmentWidget.h>
#include <PreviewEntryAttachmentsDialog.h>
#include <QAbstractButton>
#include <QDialogButtonBox>
#include <QSignalSpy>
#include <QSizePolicy>
#include <QTest>
#include <QTestMouseEvent>
#include <QVBoxLayout>
void TestPreviewEntryAttachmentsDialog::initTestCase()
{
m_previewDialog.reset(new PreviewEntryAttachmentsDialog());
QVERIFY(m_previewDialog);
}
void TestPreviewEntryAttachmentsDialog::testSetAttachment()
{
const attachments::Attachment Test{.name = "text.txt", .data = "Test"};
m_previewDialog->setAttachment(Test);
QCoreApplication::processEvents();
QVERIFY2(m_previewDialog->windowTitle().contains(Test.name), "Expected file name in the title");
auto layout = m_previewDialog->findChild<QVBoxLayout*>("verticalLayout");
QVERIFY2(layout, "QVBoxLayout not found");
QCOMPARE(layout->count(), 2);
auto widget = qobject_cast<AttachmentWidget*>(layout->itemAt(0)->widget());
QVERIFY2(widget, "Expected AbstractAttachmentWidget");
auto sizePolicy = widget->sizePolicy();
QCOMPARE(sizePolicy.horizontalPolicy(), QSizePolicy::Expanding);
QCOMPARE(sizePolicy.verticalPolicy(), QSizePolicy::Expanding);
auto attachments = widget->getAttachment();
QCOMPARE(attachments.name, Test.name);
QCOMPARE(attachments.data, Test.data);
}
void TestPreviewEntryAttachmentsDialog::testSetAttachmentTwice()
{
const attachments::Attachment TestText{.name = "text.txt", .data = "Test"};
m_previewDialog->setAttachment(TestText);
QCoreApplication::processEvents();
const attachments::Attachment TestImage{
.name = "test.jpg", .data = QByteArray::fromHex("FFD8FFE000104A46494600010101006000600000FFD9")};
m_previewDialog->setAttachment(TestImage);
QCoreApplication::processEvents();
QVERIFY2(m_previewDialog->windowTitle().contains(TestImage.name), "Expected file name in the title");
auto layout = m_previewDialog->findChild<QVBoxLayout*>("verticalLayout");
QVERIFY2(layout, "QVBoxLayout not found");
QCOMPARE(layout->count(), 2);
auto widget = qobject_cast<AttachmentWidget*>(layout->itemAt(0)->widget());
QVERIFY2(widget, "Expected AbstractAttachmentWidget");
auto attachments = widget->getAttachment();
QCOMPARE(attachments.name, TestImage.name);
QCOMPARE(attachments.data, TestImage.data);
}
void TestPreviewEntryAttachmentsDialog::testBottonsBox()
{
const attachments::Attachment TestText{.name = "text.txt", .data = "Test"};
m_previewDialog->setAttachment(TestText);
QCoreApplication::processEvents();
QSignalSpy saveButton(m_previewDialog.data(), &PreviewEntryAttachmentsDialog::saveAttachment);
QSignalSpy openButton(m_previewDialog.data(), &PreviewEntryAttachmentsDialog::openAttachment);
QSignalSpy closeButton(m_previewDialog.data(), &PreviewEntryAttachmentsDialog::rejected);
auto buttonsBox = m_previewDialog->findChild<QDialogButtonBox*>("dialogButtons");
QVERIFY2(buttonsBox, "ButtonsBox not found");
for (auto button : buttonsBox->buttons()) {
QTest::mouseClick(button, Qt::LeftButton);
}
QCOMPARE(saveButton.count(), 1);
QCOMPARE(openButton.count(), 1);
QCOMPARE(closeButton.count(), 1);
}

View file

@ -0,0 +1,40 @@
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include "PreviewEntryAttachmentsDialog.h"
#include <QObject>
#include <qscopedpointer.h>
class TestPreviewEntryAttachmentsDialog : public QObject
{
Q_OBJECT
private slots:
void initTestCase();
void testSetAttachment();
void testSetAttachmentTwice();
void testBottonsBox();
private:
QScopedPointer<PreviewEntryAttachmentsDialog> m_previewDialog{};
};

View file

@ -0,0 +1,49 @@
#include "TestTextAttachmentsEditWidget.h"
#include <attachments/TextAttachmentsEditWidget.h>
#include <QComboBox>
#include <QMouseEvent>
#include <QPushButton>
#include <QSignalSpy>
#include <QTest>
#include <QTestMouseEvent>
#include <QTextEdit>
void TestTextAttachmentsEditWidget::initTestCase()
{
m_widget.reset(new TextAttachmentsEditWidget());
}
void TestTextAttachmentsEditWidget::testEmitTextChanged()
{
QSignalSpy textChangedSignal(m_widget.data(), &TextAttachmentsEditWidget::textChanged);
m_widget->openAttachment({.name = "test.txt", .data = {}}, attachments::OpenMode::ReadWrite);
QCoreApplication::processEvents();
auto textEdit = m_widget->findChild<QTextEdit*>("attachmentsTextEdit");
QVERIFY(textEdit);
const QByteArray NewText = "New test text";
textEdit->setText(NewText);
QVERIFY(textChangedSignal.count() > 0);
}
void TestTextAttachmentsEditWidget::testEmitPreviewButtonClicked()
{
QSignalSpy previwButtonClickedSignal(m_widget.data(), &TextAttachmentsEditWidget::previewButtonClicked);
m_widget->openAttachment({.name = "test.txt", .data = {}}, attachments::OpenMode::ReadWrite);
QCoreApplication::processEvents();
auto previewButton = m_widget->findChild<QPushButton*>("previewPushButton");
QVERIFY(previewButton);
QTest::mouseClick(previewButton, Qt::LeftButton);
QCOMPARE(previwButtonClickedSignal.count(), 1);
}

View file

@ -0,0 +1,37 @@
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <attachments/TextAttachmentsEditWidget.h>
#include <QObject>
#include <QScopedPointer>
class TestTextAttachmentsEditWidget : public QObject
{
Q_OBJECT
private slots:
void initTestCase();
void testEmitTextChanged();
void testEmitPreviewButtonClicked();
private:
QScopedPointer<TextAttachmentsEditWidget> m_widget{};
};

View file

@ -0,0 +1,40 @@
#include "TestTextAttachmentsPreviewWidget.h"
#include <attachments/TextAttachmentsPreviewWidget.h>
#include <QComboBox>
#include <QTest>
void TestTextAttachmentsPreviewWidget::initTestCase()
{
m_widget.reset(new TextAttachmentsPreviewWidget());
}
void TestTextAttachmentsPreviewWidget::testDetectMimeByFile()
{
const auto combobox = m_widget->findChild<QComboBox*>("typeComboBox");
QVERIFY(combobox);
const attachments::Attachment Text{.name = "test.txt", .data = {}};
m_widget->openAttachment(Text, attachments::OpenMode::ReadOnly);
QCoreApplication::processEvents();
QCOMPARE(combobox->currentData().toInt(), TextAttachmentsPreviewWidget::PlainText);
const attachments::Attachment Html{.name = "test.html", .data = {}};
m_widget->openAttachment(Html, attachments::OpenMode::ReadOnly);
QCoreApplication::processEvents();
QCOMPARE(combobox->currentData().toInt(), TextAttachmentsPreviewWidget::Html);
#if (QT_VERSION >= QT_VERSION_CHECK(5, 14, 0))
const attachments::Attachment Markdown{.name = "test.md", .data = {}};
m_widget->openAttachment(Markdown, attachments::OpenMode::ReadOnly);
QCoreApplication::processEvents();
QCOMPARE(combobox->currentData().toInt(), TextAttachmentsPreviewWidget::Markdown);
#endif
}

View file

@ -0,0 +1,36 @@
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <attachments/TextAttachmentsPreviewWidget.h>
#include <QObject>
#include <QScopedPointer>
class TestTextAttachmentsPreviewWidget : public QObject
{
Q_OBJECT
private slots:
void initTestCase();
void testDetectMimeByFile();
private:
QScopedPointer<TextAttachmentsPreviewWidget> m_widget{};
};

View file

@ -0,0 +1,224 @@
#include "TestTextAttachmentsWidget.h"
#include <attachments/TextAttachmentsEditWidget.h>
#include <attachments/TextAttachmentsPreviewWidget.h>
#include <attachments/TextAttachmentsWidget.h>
#include <QPushButton>
#include <QSignalSpy>
#include <QSplitter>
#include <QTest>
#include <QTestMouseEvent>
#include <QTextEdit>
#include <QTimer>
void TestTextAttachmentsWidget::initTestCase()
{
m_textWidget.reset(new TextAttachmentsWidget());
}
void TestTextAttachmentsWidget::testInitTextWidget()
{
auto splitter = m_textWidget->findChild<QSplitter*>();
QVERIFY2(splitter, "Splitter not found");
QCOMPARE(splitter->count(), 2);
QVERIFY2(qobject_cast<TextAttachmentsEditWidget*>(splitter->widget(0)), "EditTextWidget not found");
QVERIFY2(qobject_cast<TextAttachmentsPreviewWidget*>(splitter->widget(1)), "PreviewTextWidget not found");
}
void TestTextAttachmentsWidget::testTextReadWriteWidget()
{
const attachments::Attachment Test{.name = "text.txt", .data = "Test"};
m_textWidget->openAttachment(Test, attachments::OpenMode::ReadWrite);
m_textWidget->show();
QCoreApplication::processEvents();
auto splitter = m_textWidget->findChild<QSplitter*>();
QVERIFY2(splitter, "Splitter not found");
auto sizes = splitter->sizes();
QCOMPARE(sizes.size(), 2);
QVERIFY2(sizes[0] > 0, "EditTextWidget width must be greater than zero");
QCOMPARE(sizes[1], 0);
auto widget = qobject_cast<TextAttachmentsEditWidget*>(splitter->widget(0));
QVERIFY(widget);
auto attachments = widget->getAttachment();
QCOMPARE(attachments.name, Test.name);
QCOMPARE(attachments.data, Test.data);
auto previewWidget = qobject_cast<TextAttachmentsPreviewWidget*>(splitter->widget(1));
QVERIFY(previewWidget);
attachments = previewWidget->getAttachment();
QCOMPARE(attachments.name, Test.name);
QCOMPARE(attachments.data, Test.data);
}
void TestTextAttachmentsWidget::testTextReadWidget()
{
const attachments::Attachment Test{.name = "text.txt", .data = "Test"};
m_textWidget->openAttachment(Test, attachments::OpenMode::ReadOnly);
m_textWidget->show();
QCoreApplication::processEvents();
auto splitter = m_textWidget->findChild<QSplitter*>();
QVERIFY2(splitter, "Splitter not found");
auto sizes = splitter->sizes();
QCOMPARE(sizes.size(), 2);
QVERIFY2(sizes[1] > 0, "PreviewTextWidget width must be greater then zero");
QVERIFY(splitter->widget(0)->isHidden());
auto widget = qobject_cast<TextAttachmentsEditWidget*>(splitter->widget(0));
QVERIFY(widget);
auto attachments = widget->getAttachment();
QCOMPARE(attachments.name, Test.name);
QCOMPARE(attachments.data, Test.data);
auto previewWidget = qobject_cast<TextAttachmentsPreviewWidget*>(splitter->widget(1));
QVERIFY(previewWidget);
attachments = previewWidget->getAttachment();
QCOMPARE(attachments.name, Test.name);
QCOMPARE(attachments.data, Test.data);
}
void TestTextAttachmentsWidget::testTextChanged()
{
const attachments::Attachment Test{.name = "text.txt", .data = "Test"};
m_textWidget->openAttachment(Test, attachments::OpenMode::ReadWrite);
QCoreApplication::processEvents();
auto splitter = m_textWidget->findChild<QSplitter*>();
QVERIFY2(splitter, "Splitter not found");
QCOMPARE(splitter->sizes().size(), 2);
auto editWidget = qobject_cast<TextAttachmentsEditWidget*>(splitter->widget(0));
QVERIFY2(editWidget, "Edit widget not found");
auto textEdit = editWidget->findChild<QTextEdit*>();
QVERIFY(textEdit);
const QByteArray NewText = "New test text";
textEdit->setText(NewText);
QCoreApplication::processEvents();
auto attachments = m_textWidget->getAttachment();
QCOMPARE(attachments.data, NewText);
}
void TestTextAttachmentsWidget::testTextChangedInReadOnlyMode()
{
const attachments::Attachment Test{.name = "text.txt", .data = "Test"};
m_textWidget->openAttachment(Test, attachments::OpenMode::ReadOnly);
QCoreApplication::processEvents();
auto splitter = m_textWidget->findChild<QSplitter*>();
QVERIFY2(splitter, "Splitter not found");
QCOMPARE(splitter->sizes().size(), 2);
auto editWidget = qobject_cast<TextAttachmentsEditWidget*>(splitter->widget(0));
QVERIFY2(editWidget, "Edit widget not found");
auto textEdit = editWidget->findChild<QTextEdit*>();
QVERIFY(textEdit);
const QByteArray NewText = "New test text";
textEdit->setText(NewText);
QCoreApplication::processEvents();
auto attachments = m_textWidget->getAttachment();
QCOMPARE(attachments.data, Test.data);
}
void TestTextAttachmentsWidget::testPreviewTextChanged()
{
const attachments::Attachment Test{.name = "text.txt", .data = "Test"};
auto previewTimer = m_textWidget->findChild<QTimer*>();
QVERIFY2(previewTimer, "PreviewTimer not found!");
QSignalSpy timeout(previewTimer, &QTimer::timeout);
m_textWidget->openAttachment(Test, attachments::OpenMode::ReadWrite);
// Waiting for the first timeout
while (timeout.count() < 1) {
QCoreApplication::processEvents(QEventLoop::AllEvents, 50);
}
auto splitter = m_textWidget->findChild<QSplitter*>();
QVERIFY2(splitter, "Splitter not found");
QCOMPARE(splitter->sizes().size(), 2);
splitter->setSizes({1, 1});
QCoreApplication::processEvents();
auto editWidget = qobject_cast<TextAttachmentsEditWidget*>(splitter->widget(0));
QVERIFY2(editWidget, "Edit widget not found");
auto textEdit = editWidget->findChild<QTextEdit*>();
QVERIFY(textEdit);
const QByteArray NewText = "New test text";
textEdit->setText(NewText);
// Waiting for the second timeout
while (timeout.count() < 2) {
QCoreApplication::processEvents(QEventLoop::AllEvents, 50);
}
auto previewWidget = qobject_cast<TextAttachmentsPreviewWidget*>(splitter->widget(1));
auto attachments = previewWidget->getAttachment();
QCOMPARE(attachments.data, NewText);
}
void TestTextAttachmentsWidget::testOpenPreviewButton()
{
const attachments::Attachment Test{.name = "text.txt", .data = "Test"};
m_textWidget->openAttachment(Test, attachments::OpenMode::ReadWrite);
m_textWidget->show();
QCoreApplication::processEvents();
auto splitter = m_textWidget->findChild<QSplitter*>();
QVERIFY2(splitter, "Splitter not found");
QCOMPARE(splitter->sizes().size(), 2);
auto editWidget = qobject_cast<TextAttachmentsEditWidget*>(splitter->widget(0));
QVERIFY2(editWidget, "Edit widget not found");
QVERIFY(editWidget->isVisible());
auto previewButton = editWidget->findChild<QPushButton*>("previewPushButton");
auto sizes = splitter->sizes();
QVERIFY(sizes[0] > 0);
QCOMPARE(sizes[1], 0);
QTest::mouseClick(previewButton, Qt::LeftButton);
sizes = splitter->sizes();
QCOMPARE(sizes.size(), 2);
QVERIFY(sizes[0] > 0);
QVERIFY(sizes[1] > 0);
QTest::mouseClick(previewButton, Qt::LeftButton);
sizes = splitter->sizes();
QCOMPARE(sizes.size(), 2);
QVERIFY(sizes[0] > 0);
QCOMPARE(sizes[1], 0);
}

View file

@ -0,0 +1,42 @@
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <attachments/TextAttachmentsWidget.h>
#include <QObject>
#include <QScopedPointer>
class TestTextAttachmentsWidget : public QObject
{
Q_OBJECT
private slots:
void initTestCase();
void testInitTextWidget();
void testTextReadWriteWidget();
void testTextReadWidget();
void testOpenPreviewButton();
void testPreviewTextChanged();
void testTextChanged();
void testTextChangedInReadOnlyMode();
private:
QScopedPointer<TextAttachmentsWidget> m_textWidget;
};