diff --git a/share/translations/keepassxc_en.ts b/share/translations/keepassxc_en.ts
index b93ebac90..82d986c40 100644
--- a/share/translations/keepassxc_en.ts
+++ b/share/translations/keepassxc_en.ts
@@ -663,6 +663,17 @@
+
+ AttachmentWidget
+
+ Attachment Viewer
+
+
+
+ Unknown attachment type
+
+
+
AutoType
@@ -2661,10 +2672,6 @@ This is definitely a bug, please report it to the developers.
No Results
-
- Save
-
-
Enter a unique name or overwrite an existing search from the list:
@@ -2833,6 +2840,17 @@ Disable safe saves and try again?
Failed to save backup database: %1
+
+ Save
+
+
+
+
+ EditEntryAttachmentsDialog
+
+ Edit: %1
+
+
EditEntryWidget
@@ -3904,21 +3922,6 @@ This may cause the affected plugins to malfunction.
-
- EntryAttachmentsDialog
-
- Form
-
-
-
- File name
-
-
-
- File contents...
-
-
-
EntryAttachmentsModel
@@ -3944,10 +3947,6 @@ This may cause the affected plugins to malfunction.
Add new attachment
-
- Add
-
-
Remove selected attachment
@@ -3968,10 +3967,6 @@ This may cause the affected plugins to malfunction.
Save selected attachment to disk
-
- Save
-
-
Select files
@@ -4065,16 +4060,28 @@ Error: %1
Would you like to overwrite the existing attachment?
-
- New
-
-
Preview
- Failed to preview an attachment: Attachment not found
+ Edit
+
+
+
+ New Text Document
+
+
+
+ Add file…
+
+
+
+ Load from Disk…
+
+
+
+ Save…
@@ -4640,6 +4647,13 @@ You can enable the DuckDuckGo website icon service in the security section of th
+
+ ImageAttachmentsWidget
+
+ Zoom:
+
+
+
ImportWizard
@@ -6466,25 +6480,6 @@ Expect some bugs and minor issues, this version is meant for testing purposes.
-
- NewEntryAttachmentsDialog
-
- Attachment name cannot be empty
-
-
-
- Attachment with the same name already exists
-
-
-
- Save attachment
-
-
-
- New entry attachment
-
-
-
NixUtils
@@ -7253,15 +7248,15 @@ Do you want to overwrite it?
PreviewEntryAttachmentsDialog
- Preview entry attachment
+ Form
- No preview available
+ Preview: %1
- Image format not supported
+ Save…
@@ -9248,6 +9243,10 @@ This option is deprecated, use --set-key-file instead.
Tags
+
+ Fit
+
+
QtIOCompressor
@@ -10182,6 +10181,24 @@ This option is deprecated, use --set-key-file instead.
+
+ TextAttachmentsEditWidget
+
+ Preview
+
+
+
+
+ TextAttachmentsPreviewWidget
+
+ Form
+
+
+
+ Type:
+
+
+
TotpDialog
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 66d074c9d..9ddc46cf9 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -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
diff --git a/src/core/Tools.cpp b/src/core/Tools.cpp
index 39c7ab8eb..814233941 100644
--- a/src/core/Tools.cpp
+++ b/src/core/Tools.cpp
@@ -35,6 +35,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -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
diff --git a/src/core/Tools.h b/src/core/Tools.h
index 7265f68bb..ee5ef612e 100644
--- a/src/core/Tools.h
+++ b/src/core/Tools.h
@@ -22,6 +22,7 @@
#include "core/Global.h"
#include
+#include
#include
#include
#include
@@ -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
diff --git a/src/gui/entry/EditEntryAttachmentsDialog.cpp b/src/gui/entry/EditEntryAttachmentsDialog.cpp
new file mode 100644
index 000000000..047f0a6b4
--- /dev/null
+++ b/src/gui/entry/EditEntryAttachmentsDialog.cpp
@@ -0,0 +1,52 @@
+/*
+ * Copyright (C) 2025 KeePassXC Team
+ *
+ * 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 .
+ */
+
+#include "EditEntryAttachmentsDialog.h"
+#include "ui_EditEntryAttachmentsDialog.h"
+
+#include
+
+#include
+#include
+#include
+#include
+
+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();
+}
diff --git a/src/gui/entry/NewEntryAttachmentsDialog.h b/src/gui/entry/EditEntryAttachmentsDialog.h
similarity index 62%
rename from src/gui/entry/NewEntryAttachmentsDialog.h
rename to src/gui/entry/EditEntryAttachmentsDialog.h
index 651100f65..38c77179a 100644
--- a/src/gui/entry/NewEntryAttachmentsDialog.h
+++ b/src/gui/entry/EditEntryAttachmentsDialog.h
@@ -17,32 +17,29 @@
#pragma once
+#include "attachments/AttachmentTypes.h"
+
#include
#include
namespace Ui
{
- class EntryAttachmentsDialog;
+ class EditEntryAttachmentsDialog;
}
-class QByteArray;
class EntryAttachments;
-class NewEntryAttachmentsDialog : public QDialog
+class EditEntryAttachmentsDialog : public QDialog
{
Q_OBJECT
public:
- explicit NewEntryAttachmentsDialog(QPointer 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 m_attachments;
- QScopedPointer m_ui;
+ QScopedPointer m_ui;
};
diff --git a/src/gui/entry/EditEntryAttachmentsDialog.ui b/src/gui/entry/EditEntryAttachmentsDialog.ui
new file mode 100644
index 000000000..b958f966d
--- /dev/null
+++ b/src/gui/entry/EditEntryAttachmentsDialog.ui
@@ -0,0 +1,46 @@
+
+
+ EditEntryAttachmentsDialog
+
+
+
+ 0
+ 0
+ 447
+ 424
+
+
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ -
+
+
+ QDialogButtonBox::Cancel|QDialogButtonBox::Ok
+
+
+
+
+
+
+
+ AttachmentWidget
+ QWidget
+ gui/entry/attachments/AttachmentWidget.h
+ 1
+
+
+
+
+
diff --git a/src/gui/entry/EntryAttachmentsDialog.ui b/src/gui/entry/EntryAttachmentsDialog.ui
deleted file mode 100644
index 2b13ea0be..000000000
--- a/src/gui/entry/EntryAttachmentsDialog.ui
+++ /dev/null
@@ -1,55 +0,0 @@
-
-
- EntryAttachmentsDialog
-
-
-
- 0
- 0
- 402
- 300
-
-
-
- Form
-
-
- -
-
-
- File name
-
-
-
- -
-
-
- true
-
-
- color: #FF9696
-
-
-
-
-
-
- -
-
-
- File contents...
-
-
-
- -
-
-
- QDialogButtonBox::Cancel|QDialogButtonBox::Ok
-
-
-
-
-
-
-
-
diff --git a/src/gui/entry/EntryAttachmentsModel.cpp b/src/gui/entry/EntryAttachmentsModel.cpp
index d66e912ab..96a55ed72 100644
--- a/src/gui/entry/EntryAttachmentsModel.cpp
+++ b/src/gui/entry/EntryAttachmentsModel.cpp
@@ -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);
diff --git a/src/gui/entry/EntryAttachmentsModel.h b/src/gui/entry/EntryAttachmentsModel.h
index d155f25ca..ea541ebf7 100644
--- a/src/gui/entry/EntryAttachmentsModel.h
+++ b/src/gui/entry/EntryAttachmentsModel.h
@@ -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);
diff --git a/src/gui/entry/EntryAttachmentsWidget.cpp b/src/gui/entry/EntryAttachmentsWidget.cpp
index 744a65931..a3b7fc763 100644
--- a/src/gui/entry/EntryAttachmentsWidget.cpp
+++ b/src/gui/entry/EntryAttachmentsWidget.cpp
@@ -17,23 +17,41 @@
#include "EntryAttachmentsWidget.h"
+#include "EditEntryAttachmentsDialog.h"
#include "EntryAttachmentsModel.h"
-#include "NewEntryAttachmentsDialog.h"
#include "PreviewEntryAttachmentsDialog.h"
#include "ui_EntryAttachmentsWidget.h"
#include
#include
+#include
+#include
#include
#include
#include
-#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(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)
diff --git a/src/gui/entry/EntryAttachmentsWidget.h b/src/gui/entry/EntryAttachmentsWidget.h
index 8c15fd68a..8d82d02d8 100644
--- a/src/gui/entry/EntryAttachmentsWidget.h
+++ b/src/gui/entry/EntryAttachmentsWidget.h
@@ -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);
diff --git a/src/gui/entry/EntryAttachmentsWidget.ui b/src/gui/entry/EntryAttachmentsWidget.ui
index 5b6de67aa..7fd960972 100644
--- a/src/gui/entry/EntryAttachmentsWidget.ui
+++ b/src/gui/entry/EntryAttachmentsWidget.ui
@@ -6,8 +6,8 @@
0
0
- 337
- 258
+ 332
+ 312
@@ -47,7 +47,7 @@
-
-
+
0
@@ -60,16 +60,6 @@
0
-
-
-
-
- false
-
-
- New
-
-
-
-
@@ -79,22 +69,26 @@
Add new attachment
- Add
+ Add file…
-
-
+
+
+ false
+
+
+ Edit
+
+
+
+ -
+
- Qt::Vertical
+ Qt::Horizontal
-
-
- 20
- 40
-
-
-
+
-
@@ -128,22 +122,19 @@
Save selected attachment to disk
- Save
+ Save…
-
-
+
+
+ true
+
- Qt::Vertical
+ Qt::Horizontal
-
-
- 20
- 173
-
-
-
+
-
@@ -164,7 +155,7 @@
Qt::Vertical
- QSizePolicy::Minimum
+ QSizePolicy::Expanding
diff --git a/src/gui/entry/NewEntryAttachmentsDialog.cpp b/src/gui/entry/NewEntryAttachmentsDialog.cpp
deleted file mode 100644
index 7adbff332..000000000
--- a/src/gui/entry/NewEntryAttachmentsDialog.cpp
+++ /dev/null
@@ -1,90 +0,0 @@
-/*
- * Copyright (C) 2025 KeePassXC Team
- *
- * 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 .
- */
-
-#include "NewEntryAttachmentsDialog.h"
-#include "core/EntryAttachments.h"
-#include "ui_EntryAttachmentsDialog.h"
-
-#include
-#include
-
-NewEntryAttachmentsDialog::NewEntryAttachmentsDialog(QPointer 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);
- }
-}
diff --git a/src/gui/entry/PreviewEntryAttachmentsDialog.cpp b/src/gui/entry/PreviewEntryAttachmentsDialog.cpp
index 69de02606..62be2bd34 100644
--- a/src/gui/entry/PreviewEntryAttachmentsDialog.cpp
+++ b/src/gui/entry/PreviewEntryAttachmentsDialog.cpp
@@ -16,117 +16,48 @@
*/
#include "PreviewEntryAttachmentsDialog.h"
-#include "ui_EntryAttachmentsDialog.h"
+#include "ui_PreviewEntryAttachmentsDialog.h"
+#include
#include
#include
#include
-#include
-#include
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);
}
diff --git a/src/gui/entry/PreviewEntryAttachmentsDialog.h b/src/gui/entry/PreviewEntryAttachmentsDialog.h
index 43bf41abd..cee23ea13 100644
--- a/src/gui/entry/PreviewEntryAttachmentsDialog.h
+++ b/src/gui/entry/PreviewEntryAttachmentsDialog.h
@@ -17,14 +17,16 @@
#pragma once
-#include "core/Tools.h"
+#include "attachments/AttachmentTypes.h"
+
+#include
#include
#include
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 m_ui;
-
- QString m_name;
- QByteArray m_data;
- QImage m_imageCache;
- Tools::MimeType m_type{Tools::MimeType::Unknown};
+ QScopedPointer m_ui;
};
diff --git a/src/gui/entry/PreviewEntryAttachmentsDialog.ui b/src/gui/entry/PreviewEntryAttachmentsDialog.ui
new file mode 100644
index 000000000..d7f8fd31c
--- /dev/null
+++ b/src/gui/entry/PreviewEntryAttachmentsDialog.ui
@@ -0,0 +1,58 @@
+
+
+ PreviewEntryAttachmentsDialog
+
+
+
+ 0
+ 0
+ 557
+ 454
+
+
+
+ Form
+
+
+
+ 6
+
+
+ 6
+
+
+ 6
+
+
+ 6
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+
+ -
+
+
+ QDialogButtonBox::Cancel|QDialogButtonBox::Ok
+
+
+
+
+
+
+
+ AttachmentWidget
+ QWidget
+ gui/entry/attachments/AttachmentWidget.h
+ 1
+
+
+
+
+
diff --git a/src/gui/entry/attachments/AttachmentTypes.h b/src/gui/entry/attachments/AttachmentTypes.h
new file mode 100644
index 000000000..9d63bc23e
--- /dev/null
+++ b/src/gui/entry/attachments/AttachmentTypes.h
@@ -0,0 +1,37 @@
+/*
+ * Copyright (C) 2025 KeePassXC Team
+ *
+ * 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 .
+ */
+
+#pragma once
+
+#include
+#include
+
+namespace attachments
+{
+ struct Attachment
+ {
+ QString name;
+ QByteArray data;
+ };
+
+ enum class OpenMode
+ {
+ ReadOnly,
+ ReadWrite
+ };
+
+} // namespace attachments
diff --git a/src/gui/entry/attachments/AttachmentWidget.cpp b/src/gui/entry/attachments/AttachmentWidget.cpp
new file mode 100644
index 000000000..f68df2d00
--- /dev/null
+++ b/src/gui/entry/attachments/AttachmentWidget.cpp
@@ -0,0 +1,89 @@
+/*
+ * Copyright (C) 2025 KeePassXC Team
+ *
+ * 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 .
+ */
+
+#include "AttachmentWidget.h"
+
+#include "ImageAttachmentsWidget.h"
+#include "TextAttachmentsWidget.h"
+
+#include
+
+#include
+#include
+
+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(m_attachmentWidget)) {
+ return textWidget->getAttachment();
+ }
+
+ return m_attachment;
+}
diff --git a/src/gui/entry/attachments/AttachmentWidget.h b/src/gui/entry/attachments/AttachmentWidget.h
new file mode 100644
index 000000000..73dd5b2b5
--- /dev/null
+++ b/src/gui/entry/attachments/AttachmentWidget.h
@@ -0,0 +1,67 @@
+/*
+ * Copyright (C) 2025 KeePassXC Team
+ *
+ * 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 .
+ */
+
+#pragma once
+
+#include "AttachmentTypes.h"
+
+#include
+
+#include
+#include
+#include
+
+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 m_attachmentWidget;
+
+ attachments::Attachment m_attachment;
+ attachments::OpenMode m_mode;
+};
diff --git a/src/gui/entry/attachments/ImageAttachmentsView.cpp b/src/gui/entry/attachments/ImageAttachmentsView.cpp
new file mode 100644
index 000000000..fea7a2d5f
--- /dev/null
+++ b/src/gui/entry/attachments/ImageAttachmentsView.cpp
@@ -0,0 +1,98 @@
+/*
+ * Copyright (C) 2025 KeePassXC Team
+ *
+ * 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 .
+ */
+
+#include "ImageAttachmentsView.h"
+
+#include
+#include
+
+#include
+
+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::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::quiet_NaN();
+}
diff --git a/src/gui/entry/attachments/ImageAttachmentsView.h b/src/gui/entry/attachments/ImageAttachmentsView.h
new file mode 100644
index 000000000..3fa4f76b6
--- /dev/null
+++ b/src/gui/entry/attachments/ImageAttachmentsView.h
@@ -0,0 +1,46 @@
+/*
+ * Copyright (C) 2025 KeePassXC Team
+ *
+ * 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 .
+ */
+
+#pragma once
+
+#include
+
+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;
+};
diff --git a/src/gui/entry/attachments/ImageAttachmentsWidget.cpp b/src/gui/entry/attachments/ImageAttachmentsWidget.cpp
new file mode 100644
index 000000000..3d547433c
--- /dev/null
+++ b/src/gui/entry/attachments/ImageAttachmentsWidget.cpp
@@ -0,0 +1,258 @@
+/*
+ * Copyright (C) 2025 KeePassXC Team
+ *
+ * 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 .
+ */
+
+#include "ImageAttachmentsWidget.h"
+
+#include "ui_ImageAttachmentsWidget.h"
+
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+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::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;
+}
diff --git a/src/gui/entry/attachments/ImageAttachmentsWidget.h b/src/gui/entry/attachments/ImageAttachmentsWidget.h
new file mode 100644
index 000000000..6c5ab95a3
--- /dev/null
+++ b/src/gui/entry/attachments/ImageAttachmentsWidget.h
@@ -0,0 +1,85 @@
+/*
+ * Copyright (C) 2025 KeePassXC Team
+ *
+ * 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 .
+ */
+
+#pragma once
+
+#include "AttachmentTypes.h"
+
+#include
+#include
+#include
+
+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 m_ui;
+ attachments::Attachment m_attachment;
+
+ QPointer m_scene;
+ QPointer m_zoomHelper;
+};
diff --git a/src/gui/entry/attachments/ImageAttachmentsWidget.ui b/src/gui/entry/attachments/ImageAttachmentsWidget.ui
new file mode 100644
index 000000000..d95f6ea9a
--- /dev/null
+++ b/src/gui/entry/attachments/ImageAttachmentsWidget.ui
@@ -0,0 +1,90 @@
+
+
+ ImageAttachmentsWidget
+
+
+
+ 0
+ 0
+ 400
+ 300
+
+
+
+
+
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+ -
+
+
-
+
+
+ Zoom:
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ true
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ -
+
+
+
+
+
+
+ ImageAttachmentsView
+ QGraphicsView
+ gui/entry/attachments/ImageAttachmentsView.h
+
+
+
+
+
diff --git a/src/gui/entry/attachments/TextAttachmentsEditWidget.cpp b/src/gui/entry/attachments/TextAttachmentsEditWidget.cpp
new file mode 100644
index 000000000..1c0bcd223
--- /dev/null
+++ b/src/gui/entry/attachments/TextAttachmentsEditWidget.cpp
@@ -0,0 +1,54 @@
+/*
+ * Copyright (C) 2025 KeePassXC Team
+ *
+ * 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 .
+ */
+
+#include "TextAttachmentsEditWidget.h"
+#include "ui_TextAttachmentsEditWidget.h"
+
+#include
+#include
+#include
+
+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);
+}
diff --git a/src/gui/entry/attachments/TextAttachmentsEditWidget.h b/src/gui/entry/attachments/TextAttachmentsEditWidget.h
new file mode 100644
index 000000000..0879d9251
--- /dev/null
+++ b/src/gui/entry/attachments/TextAttachmentsEditWidget.h
@@ -0,0 +1,51 @@
+/*
+ * Copyright (C) 2025 KeePassXC Team
+ *
+ * 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 .
+ */
+
+#pragma once
+
+#include "AttachmentTypes.h"
+
+#include
+#include
+
+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 m_ui;
+
+ attachments::Attachment m_attachment;
+ attachments::OpenMode m_mode;
+};
diff --git a/src/gui/entry/attachments/TextAttachmentsEditWidget.ui b/src/gui/entry/attachments/TextAttachmentsEditWidget.ui
new file mode 100644
index 000000000..e73894d15
--- /dev/null
+++ b/src/gui/entry/attachments/TextAttachmentsEditWidget.ui
@@ -0,0 +1,66 @@
+
+
+ TextAttachmentsEditWidget
+
+
+
+ 0
+ 0
+ 400
+ 300
+
+
+
+
+
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+ -
+
+
-
+
+
+ Preview
+
+
+ false
+
+
+ false
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
diff --git a/src/gui/entry/attachments/TextAttachmentsPreviewWidget.cpp b/src/gui/entry/attachments/TextAttachmentsPreviewWidget.cpp
new file mode 100644
index 000000000..4775f289e
--- /dev/null
+++ b/src/gui/entry/attachments/TextAttachmentsPreviewWidget.cpp
@@ -0,0 +1,133 @@
+/*
+ * Copyright (C) 2025 KeePassXC Team
+ *
+ * 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 .
+ */
+
+#include "TextAttachmentsPreviewWidget.h"
+#include "ui_TextAttachmentsPreviewWidget.h"
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+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();
+ 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::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
+}
diff --git a/src/gui/entry/attachments/TextAttachmentsPreviewWidget.h b/src/gui/entry/attachments/TextAttachmentsPreviewWidget.h
new file mode 100644
index 000000000..c7d20eb9d
--- /dev/null
+++ b/src/gui/entry/attachments/TextAttachmentsPreviewWidget.h
@@ -0,0 +1,61 @@
+/*
+ * Copyright (C) 2025 KeePassXC Team
+ *
+ * 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 .
+ */
+
+#pragma once
+
+#include "AttachmentTypes.h"
+
+#include
+#include
+
+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 m_ui;
+
+ attachments::Attachment m_attachment;
+};
diff --git a/src/gui/entry/attachments/TextAttachmentsPreviewWidget.ui b/src/gui/entry/attachments/TextAttachmentsPreviewWidget.ui
new file mode 100644
index 000000000..2ee97aa22
--- /dev/null
+++ b/src/gui/entry/attachments/TextAttachmentsPreviewWidget.ui
@@ -0,0 +1,70 @@
+
+
+ TextAttachmentsPreviewWidget
+
+
+
+ 0
+ 0
+ 400
+ 300
+
+
+
+ Form
+
+
+
+ 6
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+ -
+
+
-
+
+
+ Type:
+
+
+
+ -
+
+
+ false
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
diff --git a/src/gui/entry/attachments/TextAttachmentsWidget.cpp b/src/gui/entry/attachments/TextAttachmentsWidget.cpp
new file mode 100644
index 000000000..d0935c6af
--- /dev/null
+++ b/src/gui/entry/attachments/TextAttachmentsWidget.cpp
@@ -0,0 +1,105 @@
+/*
+ * Copyright (C) 2025 KeePassXC Team
+ *
+ * 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 .
+ */
+
+#include "TextAttachmentsWidget.h"
+#include "TextAttachmentsEditWidget.h"
+#include "TextAttachmentsPreviewWidget.h"
+
+#include "ui_TextAttachmentsWidget.h"
+
+#include
+#include
+#include
+
+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();
+}
diff --git a/src/gui/entry/attachments/TextAttachmentsWidget.h b/src/gui/entry/attachments/TextAttachmentsWidget.h
new file mode 100644
index 000000000..f4709823f
--- /dev/null
+++ b/src/gui/entry/attachments/TextAttachmentsWidget.h
@@ -0,0 +1,58 @@
+/*
+ * Copyright (C) 2025 KeePassXC Team
+ *
+ * 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 .
+ */
+
+#pragma once
+
+#include "AttachmentTypes.h"
+
+#include
+#include
+#include
+
+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 m_ui;
+ QPointer m_splitter;
+ QPointer m_editWidget;
+ QPointer m_previewWidget;
+ QPointer m_previewUpdateTimer;
+
+ attachments::Attachment m_attachment;
+ attachments::OpenMode m_mode;
+};
diff --git a/src/gui/entry/attachments/TextAttachmentsWidget.ui b/src/gui/entry/attachments/TextAttachmentsWidget.ui
new file mode 100644
index 000000000..9c18a7c60
--- /dev/null
+++ b/src/gui/entry/attachments/TextAttachmentsWidget.ui
@@ -0,0 +1,36 @@
+
+
+ TextAttachmentsWidget
+
+
+
+ 0
+ 0
+ 732
+ 432
+
+
+
+
+
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
+
+
+
+
diff --git a/tests/TestTools.cpp b/tests/TestTools.cpp
index 27a468929..a373e1523 100644
--- a/tests/TestTools.cpp
+++ b/tests/TestTools.cpp
@@ -18,7 +18,9 @@
#include "TestTools.h"
#include "core/Clock.h"
+#include "core/Tools.h"
+#include
#include
#include
#include
@@ -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));
+ }
+}
diff --git a/tests/TestTools.h b/tests/TestTools.h
index 5f4b6b6e0..728849bd3 100644
--- a/tests/TestTools.h
+++ b/tests/TestTools.h
@@ -18,7 +18,7 @@
#ifndef KEEPASSX_TESTTOOLS_H
#define KEEPASSX_TESTTOOLS_H
-#include "core/Tools.h"
+#include
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
diff --git a/tests/gui/CMakeLists.txt b/tests/gui/CMakeLists.txt
index 2734cf582..30f255778 100644
--- a/tests/gui/CMakeLists.txt
+++ b/tests/gui/CMakeLists.txt
@@ -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()
diff --git a/tests/gui/attachments/TestAttachmentWidget.cpp b/tests/gui/attachments/TestAttachmentWidget.cpp
new file mode 100644
index 000000000..a0d17fcac
--- /dev/null
+++ b/tests/gui/attachments/TestAttachmentWidget.cpp
@@ -0,0 +1,94 @@
+#include "TestAttachmentWidget.h"
+
+#include
+
+#include
+#include
+#include
+
+#include
+#include
+#include
+
+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 = " test
"},
+ 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("verticalLayout");
+ QVERIFY(layout);
+
+ QCOMPARE(layout->count(), 1);
+
+ auto item = layout->itemAt(0);
+ QVERIFY(item);
+
+ QVERIFY(qobject_cast(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("verticalLayout");
+ QVERIFY(layout);
+
+ QCOMPARE(layout->count(), 1);
+
+ auto item = layout->itemAt(0);
+ QVERIFY(item);
+
+ QVERIFY(qobject_cast(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("verticalLayout");
+ QVERIFY(layout);
+
+ QCOMPARE(layout->count(), 1);
+
+ auto item = layout->itemAt(0);
+ QVERIFY(item);
+
+ auto label = qobject_cast(item->widget());
+ QVERIFY(label);
+
+ QVERIFY(!label->text().isEmpty());
+
+ auto actualAttachment = m_attachmentWidget->getAttachment();
+ QCOMPARE(actualAttachment.name, Attachment.name);
+ QCOMPARE(actualAttachment.data, Attachment.data);
+}
diff --git a/tests/gui/attachments/TestAttachmentWidget.h b/tests/gui/attachments/TestAttachmentWidget.h
new file mode 100644
index 000000000..4367234cd
--- /dev/null
+++ b/tests/gui/attachments/TestAttachmentWidget.h
@@ -0,0 +1,38 @@
+/*
+ * Copyright (C) 2025 KeePassXC Team
+ *
+ * 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 .
+ */
+
+#pragma once
+
+#include
+
+#include
+#include
+
+class TestAttachmentsWidget : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void initTestCase();
+
+ void testTextAttachment();
+ void testImageAttachment();
+ void testUnknownAttachment();
+
+private:
+ QScopedPointer m_attachmentWidget;
+};
diff --git a/tests/gui/attachments/TestAttachmentsGui.cpp b/tests/gui/attachments/TestAttachmentsGui.cpp
new file mode 100644
index 000000000..c29626df5
--- /dev/null
+++ b/tests/gui/attachments/TestAttachmentsGui.cpp
@@ -0,0 +1,46 @@
+#include
+
+#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
+#include
+
+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;
+}
\ No newline at end of file
diff --git a/tests/gui/attachments/TestEditEntryAttachmentsDialog.cpp b/tests/gui/attachments/TestEditEntryAttachmentsDialog.cpp
new file mode 100644
index 000000000..0353b6a6f
--- /dev/null
+++ b/tests/gui/attachments/TestEditEntryAttachmentsDialog.cpp
@@ -0,0 +1,95 @@
+#include "TestEditEntryAttachmentsDialog.h"
+
+#include
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+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("verticalLayout");
+ QVERIFY2(layout, "QVBoxLayout not found");
+ QCOMPARE(layout->count(), 2);
+
+ auto widget = qobject_cast(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("verticalLayout");
+ QVERIFY2(layout, "QVBoxLayout not found");
+ QCOMPARE(layout->count(), 2);
+
+ auto widget = qobject_cast(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();
+ QVERIFY2(buttonsBox, "ButtonsBox not found");
+
+ for (auto button : buttonsBox->buttons()) {
+ QTest::mouseClick(button, Qt::LeftButton);
+ }
+
+ QCOMPARE(acceptButton.count(), 1);
+ QCOMPARE(closeButton.count(), 1);
+}
diff --git a/tests/gui/attachments/TestEditEntryAttachmentsDialog.h b/tests/gui/attachments/TestEditEntryAttachmentsDialog.h
new file mode 100644
index 000000000..76f3ac2ff
--- /dev/null
+++ b/tests/gui/attachments/TestEditEntryAttachmentsDialog.h
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2025 KeePassXC Team
+ *
+ * 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 .
+ */
+
+#pragma once
+
+#include "EditEntryAttachmentsDialog.h"
+
+#include
+#include
+
+class TestEditEntryAttachmentsDialog : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void initTestCase();
+
+ void testSetAttachment();
+ void testSetAttachmentTwice();
+
+ void testBottonsBox();
+
+private:
+ QScopedPointer m_editDialog{};
+};
diff --git a/tests/gui/attachments/TestImageAttachmentsView.cpp b/tests/gui/attachments/TestImageAttachmentsView.cpp
new file mode 100644
index 000000000..0d3ffed00
--- /dev/null
+++ b/tests/gui/attachments/TestImageAttachmentsView.cpp
@@ -0,0 +1,76 @@
+#include "TestImageAttachmentsView.h"
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+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);
+}
diff --git a/tests/gui/attachments/TestImageAttachmentsView.h b/tests/gui/attachments/TestImageAttachmentsView.h
new file mode 100644
index 000000000..06f8c58d3
--- /dev/null
+++ b/tests/gui/attachments/TestImageAttachmentsView.h
@@ -0,0 +1,38 @@
+/*
+ * Copyright (C) 2025 KeePassXC Team
+ *
+ * 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 .
+ */
+
+#pragma once
+
+#include
+
+#include
+#include
+
+class TestImageAttachmentsView : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void initTestCase();
+
+ void testEmitWheelEvent();
+ void testEnableFit();
+ void testDisableFit();
+
+private:
+ QScopedPointer m_view{};
+};
diff --git a/tests/gui/attachments/TestImageAttachmentsWidget.cpp b/tests/gui/attachments/TestImageAttachmentsWidget.cpp
new file mode 100644
index 000000000..fc2d14e2d
--- /dev/null
+++ b/tests/gui/attachments/TestImageAttachmentsWidget.cpp
@@ -0,0 +1,244 @@
+#include "TestImageAttachmentsWidget.h"
+
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+void TestImageAttachmentsWidget::initTestCase()
+{
+ m_widget.reset(new ImageAttachmentsWidget());
+ m_zoomCombobox = m_widget->findChild("zoomComboBox");
+ QVERIFY(m_zoomCombobox);
+
+ m_imageAttachmentsView = m_widget->findChild("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);
+}
diff --git a/tests/gui/attachments/TestImageAttachmentsWidget.h b/tests/gui/attachments/TestImageAttachmentsWidget.h
new file mode 100644
index 000000000..6f27be2f6
--- /dev/null
+++ b/tests/gui/attachments/TestImageAttachmentsWidget.h
@@ -0,0 +1,49 @@
+/*
+ * Copyright (C) 2025 KeePassXC Team
+ *
+ * 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 .
+ */
+
+#pragma once
+
+#include
+
+#include
+#include
+#include
+
+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 m_widget{};
+ QPointer m_zoomCombobox{};
+ QPointer m_imageAttachmentsView{};
+};
diff --git a/tests/gui/attachments/TestPreviewEntryAttachmentsDialog.cpp b/tests/gui/attachments/TestPreviewEntryAttachmentsDialog.cpp
new file mode 100644
index 000000000..ca0d6ce20
--- /dev/null
+++ b/tests/gui/attachments/TestPreviewEntryAttachmentsDialog.cpp
@@ -0,0 +1,97 @@
+#include "TestPreviewEntryAttachmentsDialog.h"
+
+#include
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+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("verticalLayout");
+ QVERIFY2(layout, "QVBoxLayout not found");
+ QCOMPARE(layout->count(), 2);
+
+ auto widget = qobject_cast(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("verticalLayout");
+ QVERIFY2(layout, "QVBoxLayout not found");
+ QCOMPARE(layout->count(), 2);
+
+ auto widget = qobject_cast(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("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);
+}
diff --git a/tests/gui/attachments/TestPreviewEntryAttachmentsDialog.h b/tests/gui/attachments/TestPreviewEntryAttachmentsDialog.h
new file mode 100644
index 000000000..7b5899b9e
--- /dev/null
+++ b/tests/gui/attachments/TestPreviewEntryAttachmentsDialog.h
@@ -0,0 +1,40 @@
+/*
+ * Copyright (C) 2025 KeePassXC Team
+ *
+ * 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 .
+ */
+
+#pragma once
+
+#include "PreviewEntryAttachmentsDialog.h"
+
+#include
+
+#include
+
+class TestPreviewEntryAttachmentsDialog : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void initTestCase();
+
+ void testSetAttachment();
+ void testSetAttachmentTwice();
+
+ void testBottonsBox();
+
+private:
+ QScopedPointer m_previewDialog{};
+};
diff --git a/tests/gui/attachments/TestTextAttachmentsEditWidget.cpp b/tests/gui/attachments/TestTextAttachmentsEditWidget.cpp
new file mode 100644
index 000000000..963533bd7
--- /dev/null
+++ b/tests/gui/attachments/TestTextAttachmentsEditWidget.cpp
@@ -0,0 +1,49 @@
+#include "TestTextAttachmentsEditWidget.h"
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+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("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("previewPushButton");
+ QVERIFY(previewButton);
+
+ QTest::mouseClick(previewButton, Qt::LeftButton);
+
+ QCOMPARE(previwButtonClickedSignal.count(), 1);
+}
diff --git a/tests/gui/attachments/TestTextAttachmentsEditWidget.h b/tests/gui/attachments/TestTextAttachmentsEditWidget.h
new file mode 100644
index 000000000..3d89f2a2e
--- /dev/null
+++ b/tests/gui/attachments/TestTextAttachmentsEditWidget.h
@@ -0,0 +1,37 @@
+/*
+ * Copyright (C) 2025 KeePassXC Team
+ *
+ * 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 .
+ */
+
+#pragma once
+
+#include
+
+#include
+#include
+
+class TestTextAttachmentsEditWidget : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void initTestCase();
+
+ void testEmitTextChanged();
+ void testEmitPreviewButtonClicked();
+
+private:
+ QScopedPointer m_widget{};
+};
diff --git a/tests/gui/attachments/TestTextAttachmentsPreviewWidget.cpp b/tests/gui/attachments/TestTextAttachmentsPreviewWidget.cpp
new file mode 100644
index 000000000..0ddea53e3
--- /dev/null
+++ b/tests/gui/attachments/TestTextAttachmentsPreviewWidget.cpp
@@ -0,0 +1,40 @@
+#include "TestTextAttachmentsPreviewWidget.h"
+
+#include
+
+#include
+#include
+
+void TestTextAttachmentsPreviewWidget::initTestCase()
+{
+ m_widget.reset(new TextAttachmentsPreviewWidget());
+}
+
+void TestTextAttachmentsPreviewWidget::testDetectMimeByFile()
+{
+ const auto combobox = m_widget->findChild("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
+}
diff --git a/tests/gui/attachments/TestTextAttachmentsPreviewWidget.h b/tests/gui/attachments/TestTextAttachmentsPreviewWidget.h
new file mode 100644
index 000000000..8e34a8d9b
--- /dev/null
+++ b/tests/gui/attachments/TestTextAttachmentsPreviewWidget.h
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2025 KeePassXC Team
+ *
+ * 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 .
+ */
+
+#pragma once
+
+#include
+
+#include
+#include
+
+class TestTextAttachmentsPreviewWidget : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void initTestCase();
+
+ void testDetectMimeByFile();
+
+private:
+ QScopedPointer m_widget{};
+};
diff --git a/tests/gui/attachments/TestTextAttachmentsWidget.cpp b/tests/gui/attachments/TestTextAttachmentsWidget.cpp
new file mode 100644
index 000000000..bba6f9840
--- /dev/null
+++ b/tests/gui/attachments/TestTextAttachmentsWidget.cpp
@@ -0,0 +1,224 @@
+#include "TestTextAttachmentsWidget.h"
+
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+void TestTextAttachmentsWidget::initTestCase()
+{
+ m_textWidget.reset(new TextAttachmentsWidget());
+}
+
+void TestTextAttachmentsWidget::testInitTextWidget()
+{
+ auto splitter = m_textWidget->findChild();
+ QVERIFY2(splitter, "Splitter not found");
+
+ QCOMPARE(splitter->count(), 2);
+ QVERIFY2(qobject_cast(splitter->widget(0)), "EditTextWidget not found");
+ QVERIFY2(qobject_cast