Browse Source

modified: Change to Qt6 Model/View architecture

Satoshi Yoneda 3 weeks ago
parent
commit
78a4597c96
6 changed files with 513 additions and 216 deletions
  1. 9 14
      include/MainWindow.hpp
  2. 25 0
      include/ResultItemDelegate.hpp
  3. 47 0
      include/ResultListModel.hpp
  4. 61 202
      src/MainWindow.cpp
  5. 193 0
      src/ResultItemDelegate.cpp
  6. 178 0
      src/ResultListModel.cpp

+ 9 - 14
include/MainWindow.hpp

@@ -1,19 +1,19 @@
 #pragma once
 #include "DatabaseManager.hpp"
-#include "SimilaritySearch.hpp"
+#include "ResultListModel.hpp"
+#include "ResultItemDelegate.hpp"
 #include <unordered_set>
 #include <QCheckBox>
 #include <QCloseEvent>
 #include <QEvent>
 #include <QFutureWatcher>
-#include <QGridLayout>
 #include <QLabel>
+#include <QListView>
 #include <QListWidget>
 #include <QMainWindow>
 #include <QObject>
 #include <QProgressBar>
 #include <QPushButton>
-#include <QScrollArea>
 #include <QSlider>
 #include <QTimer>
 #include <memory>
@@ -40,6 +40,8 @@ private slots:
   void onSearchFinished();            // 類似画像の検索完了時
   void onClearResults();              // 結果表示のクリア
   void removeGroupFromView(int groupId); // 指定したグループをリストから除外
+  void onContextMenuRequested(const std::string& path, int groupId, const QPoint& globalPos);
+  void onFileDoubleClicked(const std::string& path);
 
 private:
   // 内部処理・初期化メソッド
@@ -66,9 +68,7 @@ private:
   QPushButton *m_startScanBtn;
   QPushButton *m_deselectBtn;  // チェック状態を全クリアするボタン
   QProgressBar *m_progressBar; // スキャン・検索時のプログレスバー
-  QScrollArea *m_scrollArea;   // 検索結果表示用スクロールエリア
-  QWidget *m_resultWidget;
-  QGridLayout *m_resultLayout; // 検索結果をグリッドで配置するレイアウト
+  QListView *m_resultView;     // 検索結果表示用リストビュー
   QPushButton *m_deleteBtn;
   QPushButton *m_clearBtn;
 
@@ -79,6 +79,9 @@ private:
   int m_currentThreshold = 5;
   bool m_strictMode = false;
 
+  ResultListModel *m_model;
+  ResultItemDelegate *m_delegate;
+
   // 非同期(バックグラウンド)処理用オブジェクト
   QFutureWatcher<void> *m_scanWatcher; // 画像スキャンの進捗監視
   QFutureWatcher<std::vector<DuplicateGroup>>
@@ -91,14 +94,6 @@ private:
       m_lastScannedImages;  // 各ディレクトリから抽出した画像キャッシュ
   QStringList m_loadedDirs; // 起動時にロードされたディレクトリ群
 
-  // 結果画面に追加された画像とチェックボックスを管理する構造体
-  struct ResultItem {
-    QCheckBox *checkbox; // 削除対象としてマークするチェックボックス
-    std::string path;    // チェックボックスに紐づく画像パス
-    int groupId;         // 属する重複グループのID(全削除警告用)
-  };
-  std::vector<ResultItem>
-      m_resultItems; // 現在表示されている結果アイテムのリスト
   std::vector<DuplicateGroup> m_currentGroups; // 現在表示中のグループ一覧
   std::unordered_set<std::string> m_ignoredPaths; // セッション中に除外した画像のパス
 };

+ 25 - 0
include/ResultItemDelegate.hpp

@@ -0,0 +1,25 @@
+#pragma once
+#include <QStyledItemDelegate>
+
+class ResultItemDelegate : public QStyledItemDelegate {
+  Q_OBJECT
+
+public:
+  explicit ResultItemDelegate(QObject *parent = nullptr);
+
+  void paint(QPainter *painter, const QStyleOptionViewItem &option,
+             const QModelIndex &index) const override;
+  QSize sizeHint(const QStyleOptionViewItem &option,
+                 const QModelIndex &index) const override;
+  bool editorEvent(QEvent *event, QAbstractItemModel *model,
+                   const QStyleOptionViewItem &option,
+                   const QModelIndex &index) override;
+  bool helpEvent(QHelpEvent *event, QAbstractItemView *view,
+                 const QStyleOptionViewItem &option,
+                 const QModelIndex &index) override;
+
+signals:
+  void contextMenuRequested(const std::string &path, int groupId,
+                            const QPoint &globalPos);
+  void fileDoubleClicked(const std::string &path);
+};

+ 47 - 0
include/ResultListModel.hpp

@@ -0,0 +1,47 @@
+#pragma once
+#include <QAbstractListModel>
+#include <QPixmap>
+#include <QString>
+#include <vector>
+#include <unordered_set>
+#include "SimilaritySearch.hpp"
+
+struct ResultListItem {
+    enum Type { Header, ImageRow };
+    Type type;
+    int groupId;
+    QString headerText;
+    std::vector<ImageData> images; // Up to 4 images
+};
+
+class ResultListModel : public QAbstractListModel {
+    Q_OBJECT
+
+public:
+    explicit ResultListModel(QObject *parent = nullptr);
+    ~ResultListModel() override;
+
+    int rowCount(const QModelIndex &parent = QModelIndex()) const override;
+    QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
+
+    // Custom APIs
+    void setGroups(const std::vector<DuplicateGroup>& groups, bool preserveState = false);
+    const ResultListItem& getItem(int row) const;
+    
+    bool isChecked(const std::string& path) const;
+    void setChecked(const std::string& path, bool state);
+    void clearAllChecks();
+    void clear();
+
+    const std::unordered_map<std::string, bool>& getCheckStates() const;
+    QPixmap getThumbnail(const std::string& path) const;
+
+private:
+    void requestThumbnail(const std::string& path) const;
+    void emitRowDataChangedForPath(const std::string& path);
+
+    std::vector<ResultListItem> m_items;
+    std::unordered_map<std::string, bool> m_checkStates;
+    mutable std::unordered_set<std::string> m_loadingPaths;
+    mutable std::unordered_map<std::string, QPixmap> m_thumbnails;
+};

+ 61 - 202
src/MainWindow.cpp

@@ -132,13 +132,17 @@ void MainWindow::setupUi() {
   }
   splitLayout->addWidget(m_dirList);
 
-  // Results Scroll Area
-  m_scrollArea = new QScrollArea();
-  m_scrollArea->setWidgetResizable(true);
-  m_resultWidget = new QWidget();
-  m_resultLayout = new QGridLayout(m_resultWidget);
-  m_scrollArea->setWidget(m_resultWidget);
-  splitLayout->addWidget(m_scrollArea);
+  // Results List View
+  m_resultView = new QListView();
+  m_model = new ResultListModel(this);
+  m_delegate = new ResultItemDelegate(this);
+  
+  m_resultView->setModel(m_model);
+  m_resultView->setItemDelegate(m_delegate);
+  m_resultView->setSelectionMode(QAbstractItemView::NoSelection);
+  m_resultView->setSpacing(5);
+  
+  splitLayout->addWidget(m_resultView);
 
   mainLayout->addLayout(splitLayout);
 
@@ -159,10 +163,7 @@ void MainWindow::setupUi() {
   connect(m_clearBtn, &QPushButton::clicked, this, &MainWindow::onClearResults);
 
   connect(m_deselectBtn, &QPushButton::clicked, this, [this]() {
-    for (auto &item : m_resultItems) {
-      if (item.checkbox)
-        item.checkbox->setChecked(false);
-    }
+    m_model->clearAllChecks();
   });
 
   connect(m_deleteBtn, &QPushButton::clicked, this,
@@ -173,6 +174,11 @@ void MainWindow::setupUi() {
           &MainWindow::onStrictChanged);
   connect(m_scanWatcher, &QFutureWatcher<void>::finished, this,
           &MainWindow::onScanFinished);
+
+  connect(m_delegate, &ResultItemDelegate::contextMenuRequested, this,
+          &MainWindow::onContextMenuRequested);
+  connect(m_delegate, &ResultItemDelegate::fileDoubleClicked, this,
+          &MainWindow::onFileDoubleClicked);
 }
 
 void MainWindow::loadSettings() {
@@ -394,192 +400,39 @@ void MainWindow::removeGroupFromView(int groupId) {
 }
 
 void MainWindow::onClearResults() {
-  // UIをクリア
-  QLayoutItem *child;
-  while ((child = m_resultLayout->takeAt(0)) != nullptr) {
-    if (child->widget())
-      delete child->widget();
-    delete child;
-  }
-  for (int i = 0; i < m_resultLayout->rowCount(); ++i) {
-    m_resultLayout->setRowStretch(i, 0);
-  }
-  m_resultItems.clear();
+  m_model->clear();
 }
 
 bool MainWindow::eventFilter(QObject *obj, QEvent *event) {
-  if (event->type() == QEvent::MouseButtonDblClick) {
-    QString path = obj->property("filePath").toString();
-    if (!path.isEmpty()) {
-      QDesktopServices::openUrl(QUrl::fromLocalFile(path));
-      return true;
-    }
-  } else if (event->type() == QEvent::ContextMenu) {
-    QString path = obj->property("filePath").toString();
-    if (!path.isEmpty()) {
-      QContextMenuEvent *ce = static_cast<QContextMenuEvent *>(event);
-      QMenu menu;
-      QAction *copyAction = menu.addAction("Copy Full Path(&C)");
-      menu.addSeparator();
-      QAction *removeAction = menu.addAction("Remove from List(&R)");
-
-      QAction *selectedAction = menu.exec(ce->globalPos());
-      if (selectedAction == copyAction) {
-        QApplication::clipboard()->setText(path);
-      } else if (selectedAction == removeAction) {
-        int groupId = obj->property("groupId").toInt();
-        removeGroupFromView(groupId);
-      }
-      return true;
-    }
-  }
   return QMainWindow::eventFilter(obj, event);
 }
 
-// 同一・類似と判定された画像のグループを受け取り、UI上のグリッドレイアウトへ動的にサムネイルと削除候補のチェックボックスを描画する
-void MainWindow::updateResultGrid(const std::vector<DuplicateGroup> &groups,
-                                  bool preserveState) {
-  // 状態の保存
-  std::unordered_map<std::string, bool> previousState;
-  if (preserveState) {
-    for (const auto &item : m_resultItems) {
-      if (item.checkbox) {
-        previousState[item.path] = item.checkbox->isChecked();
-      }
-    }
-  }
-
-  // UIをクリア
-  QLayoutItem *child;
-  while ((child = m_resultLayout->takeAt(0)) != nullptr) {
-    if (child->widget())
-      delete child->widget();
-    delete child;
+void MainWindow::onFileDoubleClicked(const std::string& path) {
+  if (!path.empty()) {
+    QDesktopServices::openUrl(QUrl::fromLocalFile(QString::fromStdString(path)));
   }
-  for (int i = 0; i < m_resultLayout->rowCount(); ++i) {
-    m_resultLayout->setRowStretch(i, 0);
-  }
-  m_resultItems.clear();
-
-  int row = 0;
-  int groupId = 0;
-  for (const auto &group : groups) {
-    // グループ内で残す1枚(ファイルサイズが最大のもの。同サイズなら最初の1枚)を特定する
-    const ImageData *bestImage = &group.images[0];
-    for (size_t i = 1; i < group.images.size(); ++i) {
-      if (group.images[i].file_size > bestImage->file_size) {
-        bestImage = &group.images[i];
-      }
-    }
-
-    // グループヘッダー
-    QLabel *groupLabel = new QLabel(
-        QString("Duplicate Group - %1 images").arg(group.images.size()));
-    groupLabel->setStyleSheet("font-weight: bold; background-color: #f0f0f0; "
-                              "padding: 5px; border-radius: 4px;");
-    m_resultLayout->addWidget(groupLabel, row++, 0, 1, 4);
-
-    int col = 0;
-    for (const auto &imgData : group.images) {
-      QWidget *imgWidget = new QWidget();
-      imgWidget->setObjectName("imgCard");
-      QVBoxLayout *vBox = new QVBoxLayout(imgWidget);
-
-      QLabel *thumb = new QLabel();
-      thumb->setProperty("filePath", QString::fromStdString(imgData.path));
-      thumb->setProperty("groupId", groupId);
-      thumb->installEventFilter(this);
-      //      thumb->setToolTip(
-      //          "Double click to open\nRight click to copy path or remove from
-      //          list");
-      QFileInfo fileInfo(QString::fromStdString(imgData.path));
-      thumb->setToolTip(fileInfo.baseName());
-
-      thumb->setText("Loading...");
-      thumb->setAlignment(Qt::AlignCenter);
-      vBox->addWidget(thumb);
-
-      QPointer<QLabel> safeThumb(thumb);
-      std::string pathCopy = imgData.path;
-
-      QtConcurrent::run([pathCopy]() -> QImage {
-        QImageReader reader(QString::fromStdString(pathCopy));
-        reader.setAutoTransform(true);
-        reader.setAllocationLimit(512); // デフォルト128MB制限を512MBに引き上げ
-
-        QSize imgSize = reader.size();
-        if (imgSize.isValid()) {
-          // サムネイル表示に必要なサイズ(高階調な表示のためここでは高めでもよいが、150x150枠)に
-          // デコード段階で縮小指定する。JPEG等では飛躍的に高速化・省メモリ化される。
-          imgSize.scale(300, 300, Qt::KeepAspectRatio);
-          reader.setScaledSize(imgSize);
-        }
+}
 
-        QImage img = reader.read();
-        if (!img.isNull()) {
-          return img;
-        } else {
-          // Qtの標準ImageReaderで読み込めない形式(WebPプラギン未導入時など)のフォールバック
-          cv::Mat cvImg = ImageHasher::loadImage(pathCopy, 300);
-          if (!cvImg.empty()) {
-            cv::Mat rgb;
-            cv::cvtColor(cvImg, rgb, cv::COLOR_BGR2RGB);
-            QImage qimg(rgb.data, rgb.cols, rgb.rows, rgb.step,
-                        QImage::Format_RGB888);
-            return qimg.copy(); // OpenCVのMatデータ揮発を防ぐためcopy()
-          }
-        }
-        return QImage();
-      }).then(this, [safeThumb](QImage img) {
-        if (!safeThumb)
-          return; // 既にUIがクリアされている場合は何もしない
-
-        if (!img.isNull()) {
-          QPixmap pix = QPixmap::fromImage(img);
-          safeThumb->setPixmap(pix.scaled(150, 150, Qt::KeepAspectRatio,
-                                          Qt::SmoothTransformation));
-        } else {
-          safeThumb->setText("Error Loading");
-        }
-      });
-
-      // 自動チェック: 残す1枚(bestImage)以外を削除候補としてチェックする
-      QCheckBox *cb = new QCheckBox("Delete candidate");
-      if (preserveState &&
-          previousState.find(imgData.path) != previousState.end()) {
-        cb->setChecked(previousState[imgData.path]);
-      } else {
-        if (&imgData != bestImage) {
-          cb->setChecked(true);
-        }
-      }
-      vBox->addWidget(cb);
-
-      QLabel *infoLabel =
-          new QLabel(QString("%1 KB\n%2")
-                         .arg(imgData.file_size / 1024)
-                         .arg(QString::fromStdString(imgData.path)));
-      infoLabel->setWordWrap(true);
-      infoLabel->setMaximumWidth(150);
-      infoLabel->setStyleSheet("font-size: 10px; color: #666;");
-      vBox->addWidget(infoLabel);
-
-      m_resultLayout->addWidget(imgWidget, row, col);
-      m_resultItems.push_back({cb, imgData.path, groupId});
-
-      col++;
-      if (col >= 4) {
-        col = 0;
-        row++;
-      }
+void MainWindow::onContextMenuRequested(const std::string& path, int groupId, const QPoint& globalPos) {
+  if (!path.empty()) {
+    QMenu menu;
+    QAction *copyAction = menu.addAction("Copy Full Path(&C)");
+    menu.addSeparator();
+    QAction *removeAction = menu.addAction("Remove from List(&R)");
+
+    QAction *selectedAction = menu.exec(globalPos);
+    if (selectedAction == copyAction) {
+      QApplication::clipboard()->setText(QString::fromStdString(path));
+    } else if (selectedAction == removeAction) {
+      removeGroupFromView(groupId);
     }
-    row++;
-    groupId++;
   }
+}
 
-  // 項目数が少ない時に各行が縦に間延びする(ヘッダーが極端に太くなる)のを防ぐため、
-  // 最後の空行に対して余った縦スペースをすべて吸収させる
-  m_resultLayout->setRowStretch(row, 1);
+// 同一・類似と判定された画像のグループを受け取り、UI上のグリッドレイアウトへ動的にサムネイルと削除候補のチェックボックスを描画する
+void MainWindow::updateResultGrid(const std::vector<DuplicateGroup> &groups,
+                                  bool preserveState) {
+  m_model->setGroups(groups, preserveState);
 }
 
 void MainWindow::onDeleteSelected() {
@@ -587,12 +440,19 @@ void MainWindow::onDeleteSelected() {
   std::map<int, int> groupTotalCount;
   std::map<int, int> groupCheckedCount;
 
-  for (const auto &item : m_resultItems) {
-    groupTotalCount[item.groupId]++;
-    if (item.checkbox->isChecked()) {
-      groupCheckedCount[item.groupId]++;
-      count++;
-    }
+  const auto& checkStates = m_model->getCheckStates();
+
+  int groupId = 0;
+  for (const auto& group : m_currentGroups) {
+      for (const auto& img : group.images) {
+          groupTotalCount[groupId]++;
+          auto it = checkStates.find(img.path);
+          if (it != checkStates.end() && it->second) {
+              groupCheckedCount[groupId]++;
+              count++;
+          }
+      }
+      groupId++;
   }
 
   if (count == 0)
@@ -600,7 +460,7 @@ void MainWindow::onDeleteSelected() {
 
   bool allCheckedInSomeGroup = false;
   for (auto const &[gId, total] : groupTotalCount) {
-    if (groupCheckedCount[gId] == total) {
+    if (groupCheckedCount[gId] == total && total > 0) {
       allCheckedInSomeGroup = true;
       break;
     }
@@ -625,16 +485,15 @@ void MainWindow::onDeleteSelected() {
 
   if (res == QMessageBox::Yes) {
     std::vector<QString> failures;
-    for (const auto &item : m_resultItems) {
-      if (item.checkbox->isChecked()) {
-        QString qPath = QString::fromStdString(item.path);
-        // QFile::moveToTrash は Qt 5.15+ で利用可能
-        if (QFile::moveToTrash(qPath)) {
-          m_dbManager->removeImage(item.path);
-        } else {
-          failures.push_back(qPath);
+    for (const auto& pair : checkStates) {
+        if (pair.second) { // is checked
+            QString qPath = QString::fromStdString(pair.first);
+            if (QFile::moveToTrash(qPath)) {
+                m_dbManager->removeImage(pair.first);
+            } else {
+                failures.push_back(qPath);
+            }
         }
-      }
     }
 
     if (!failures.empty()) {

+ 193 - 0
src/ResultItemDelegate.cpp

@@ -0,0 +1,193 @@
+#include "ResultItemDelegate.hpp"
+#include "ResultListModel.hpp"
+#include <QApplication>
+#include <QMouseEvent>
+#include <QPainter>
+#include <QToolTip>
+#include <QfileInfo>
+#include <cstddef>
+
+ResultItemDelegate::ResultItemDelegate(QObject *parent)
+    : QStyledItemDelegate(parent) {}
+
+void ResultItemDelegate::paint(QPainter *painter,
+                               const QStyleOptionViewItem &option,
+                               const QModelIndex &index) const {
+  const auto *model = qobject_cast<const ResultListModel *>(index.model());
+  if (!model)
+    return;
+
+  painter->save();
+
+  const auto &item = model->getItem(index.row());
+  if (item.type == ResultListItem::Header) {
+    QRect r = option.rect;
+    r.adjust(0, 5, 0, -5);
+    painter->setRenderHint(QPainter::Antialiasing);
+    painter->setBrush(QColor("#f0f0f0"));
+    painter->setPen(Qt::NoPen);
+    painter->drawRoundedRect(r, 4, 4);
+
+    QFont font = painter->font();
+    font.setBold(true);
+    painter->setFont(font);
+    painter->setPen(Qt::black);
+    painter->drawText(r.adjusted(5, 0, -5, 0), Qt::AlignVCenter | Qt::AlignLeft,
+                      item.headerText);
+  } else {
+    int cardWidth = option.rect.width() / 4;
+    for (int i = 0; i < static_cast<int>(item.images.size()); ++i) {
+      const auto &imgData = item.images[i];
+      QRect cardRect(option.rect.x() + i * cardWidth, option.rect.y(),
+                     cardWidth, option.rect.height());
+
+      // Thumbnail
+      QRect thumbRect(cardRect.x() + (cardWidth - 150) / 2, cardRect.y() + 5,
+                      150, 150);
+      QPixmap pix = model->getThumbnail(imgData.path);
+      if (pix.isNull()) {
+        painter->setPen(Qt::black);
+        painter->drawText(thumbRect, Qt::AlignCenter, "Loading...");
+      } else {
+        // center pixmap
+        QPoint topLeft(thumbRect.center().x() - pix.width() / 2,
+                       thumbRect.center().y() - pix.height() / 2);
+        painter->drawPixmap(topLeft, pix);
+      }
+
+      // Checkbox
+      QRect cbRect(cardRect.x() + 5, thumbRect.bottom() + 5, cardWidth - 10,
+                   20);
+      QStyleOptionButton cbOpt;
+      cbOpt.rect = cbRect;
+      cbOpt.text = "Delete candidate";
+      cbOpt.state = QStyle::State_Enabled;
+      if (model->isChecked(imgData.path)) {
+        cbOpt.state |= QStyle::State_On;
+      } else {
+        cbOpt.state |= QStyle::State_Off;
+      }
+
+      QApplication::style()->drawControl(QStyle::CE_CheckBox, &cbOpt, painter);
+
+      // Info text
+      QRect textRect(cardRect.x() + 5, cbRect.bottom() + 2, cardWidth - 10,
+                     cardRect.bottom() - cbRect.bottom() - 2);
+      QString info = QString("%1 KB\n%2")
+                         .arg(imgData.file_size / 1024)
+                         .arg(QString::fromStdString(imgData.path));
+
+      QFont font = painter->font();
+      font.setPixelSize(10);
+      painter->setFont(font);
+      painter->setPen(QColor("#666666"));
+      painter->drawText(textRect,
+                        Qt::AlignTop | Qt::AlignLeft | Qt::TextWordWrap, info);
+    }
+  }
+
+  painter->restore();
+}
+
+QSize ResultItemDelegate::sizeHint(const QStyleOptionViewItem &option,
+                                   const QModelIndex &index) const {
+  const auto *model = qobject_cast<const ResultListModel *>(index.model());
+  if (!model)
+    return QSize(0, 0);
+  const auto &item = model->getItem(index.row());
+  if (item.type == ResultListItem::Header) {
+    return QSize(option.rect.width(), 40);
+  } else {
+    return QSize(option.rect.width(), 220);
+  }
+}
+
+bool ResultItemDelegate::editorEvent(QEvent *event, QAbstractItemModel *model,
+                                     const QStyleOptionViewItem &option,
+                                     const QModelIndex &index) {
+  if (!event)
+    return false;
+
+  auto *listModel = qobject_cast<ResultListModel *>(model);
+  if (!listModel)
+    return false;
+
+  const auto &item = listModel->getItem(index.row());
+  if (item.type != ResultListItem::ImageRow)
+    return false;
+
+  if (event->type() == QEvent::MouseButtonRelease) {
+    QMouseEvent *me = static_cast<QMouseEvent *>(event);
+    int cardWidth = option.rect.width() / 4;
+    for (int i = 0; i < static_cast<int>(item.images.size()); ++i) {
+      const auto &imgData = item.images[i];
+      QRect cardRect(option.rect.x() + i * cardWidth, option.rect.y(),
+                     cardWidth, option.rect.height());
+
+      if (cardRect.contains(me->pos())) {
+        if (me->button() == Qt::RightButton) {
+          emit contextMenuRequested(imgData.path, item.groupId,
+                                    me->globalPosition().toPoint());
+          return true;
+        } else if (me->button() == Qt::LeftButton) {
+          QRect thumbRect(cardRect.x() + (cardWidth - 150) / 2,
+                          cardRect.y() + 5, 150, 150);
+          QRect cbRect(cardRect.x() + 5, thumbRect.bottom() + 5, cardWidth - 10,
+                       20);
+          if (cbRect.contains(me->pos())) {
+            bool currentState = listModel->isChecked(imgData.path);
+            listModel->setChecked(imgData.path, !currentState);
+            return true;
+          }
+        }
+      }
+    }
+  } else if (event->type() == QEvent::MouseButtonDblClick) {
+    QMouseEvent *me = static_cast<QMouseEvent *>(event);
+    int cardWidth = option.rect.width() / 4;
+    for (int i = 0; i < static_cast<int>(item.images.size()); ++i) {
+      const auto &imgData = item.images[i];
+      QRect cardRect(option.rect.x() + i * cardWidth, option.rect.y(),
+                     cardWidth, option.rect.height());
+      if (cardRect.contains(me->pos())) {
+        emit fileDoubleClicked(imgData.path);
+        return true;
+      }
+    }
+  }
+
+  return QStyledItemDelegate::editorEvent(event, model, option, index);
+}
+
+bool ResultItemDelegate::helpEvent(QHelpEvent *event, QAbstractItemView *view,
+                                   const QStyleOptionViewItem &option,
+                                   const QModelIndex &index) {
+  if (!event || !view || !index.isValid())
+    return false;
+
+  if (event->type() == QEvent::ToolTip) {
+    const auto *listModel =
+        qobject_cast<const ResultListModel *>(index.model());
+    if (!listModel)
+      return false;
+
+    const auto &item = listModel->getItem(index.row());
+    if (item.type != ResultListItem::ImageRow)
+      return false;
+
+    int cardWidth = option.rect.width() / 4;
+    for (int i = 0; i < static_cast<int>(item.images.size()); ++i) {
+      const auto &imgData = item.images[i];
+      QRect cardRect(option.rect.x() + i * cardWidth, option.rect.y(),
+                     cardWidth, option.rect.height());
+      if (cardRect.contains(event->pos())) {
+        QFileInfo fileInfo(QString::fromStdString(imgData.path));
+        QString toolTip = fileInfo.fileName();
+        QToolTip::showText(event->globalPos(), toolTip, nullptr, cardRect);
+        return true;
+      }
+    }
+  }
+
+  return QStyledItemDelegate::helpEvent(event, view, option, index);
+}

+ 178 - 0
src/ResultListModel.cpp

@@ -0,0 +1,178 @@
+#include "ResultListModel.hpp"
+#include "ImageHasher.hpp"
+#include <QImageReader>
+#include <QtConcurrent>
+#include <opencv2/opencv.hpp>
+#include <opencv2/imgproc.hpp>
+
+ResultListModel::ResultListModel(QObject *parent) : QAbstractListModel(parent) {
+}
+
+ResultListModel::~ResultListModel() {
+}
+
+int ResultListModel::rowCount(const QModelIndex &parent) const {
+    if (parent.isValid()) return 0;
+    return m_items.size();
+}
+
+QVariant ResultListModel::data(const QModelIndex &index, int role) const {
+    // Custom roles not strictly used since Delegate casts and reads data manually.
+    if (!index.isValid() || index.row() >= m_items.size()) return QVariant();
+    return QVariant();
+}
+
+const ResultListItem& ResultListModel::getItem(int row) const {
+    return m_items[row];
+}
+
+void ResultListModel::setGroups(const std::vector<DuplicateGroup>& groups, bool preserveState) {
+    beginResetModel();
+    
+    std::unordered_map<std::string, bool> oldState;
+    if (preserveState) {
+        oldState = m_checkStates;
+    }
+    m_checkStates.clear();
+    m_items.clear();
+    
+    int groupId = 0;
+    for (const auto& group : groups) {
+        if (group.images.empty()) continue;
+
+        const ImageData* bestImage = &group.images[0];
+        for (size_t i = 1; i < group.images.size(); ++i) {
+            if (group.images[i].file_size > bestImage->file_size) {
+                bestImage = &group.images[i];
+            }
+        }
+
+        for (const auto& img : group.images) {
+            if (preserveState && oldState.find(img.path) != oldState.end()) {
+                m_checkStates[img.path] = oldState[img.path];
+            } else {
+                m_checkStates[img.path] = (&img != bestImage);
+            }
+        }
+
+        ResultListItem header;
+        header.type = ResultListItem::Header;
+        header.groupId = groupId;
+        header.headerText = QString("Duplicate Group - %1 images").arg(group.images.size());
+        m_items.push_back(header);
+        
+        ResultListItem rowItem;
+        rowItem.type = ResultListItem::ImageRow;
+        rowItem.groupId = groupId;
+        for (const auto& img : group.images) {
+            rowItem.images.push_back(img);
+            if (rowItem.images.size() == 4) {
+                m_items.push_back(rowItem);
+                rowItem.images.clear();
+            }
+        }
+        if (!rowItem.images.empty()) {
+            m_items.push_back(rowItem);
+        }
+        groupId++;
+    }
+    endResetModel();
+}
+
+bool ResultListModel::isChecked(const std::string& path) const {
+    auto it = m_checkStates.find(path);
+    if (it != m_checkStates.end()) return it->second;
+    return false;
+}
+
+void ResultListModel::setChecked(const std::string& path, bool state) {
+    if (m_checkStates[path] != state) {
+        m_checkStates[path] = state;
+        emitRowDataChangedForPath(path);
+    }
+}
+
+void ResultListModel::clearAllChecks() {
+    for (auto& pair : m_checkStates) {
+        pair.second = false;
+    }
+    if (!m_items.empty()) {
+        emit dataChanged(index(0, 0), index(m_items.size()-1, 0));
+    }
+}
+
+void ResultListModel::clear() {
+    beginResetModel();
+    m_items.clear();
+    m_checkStates.clear();
+    m_thumbnails.clear();
+    m_loadingPaths.clear();
+    endResetModel();
+}
+
+const std::unordered_map<std::string, bool>& ResultListModel::getCheckStates() const {
+    return m_checkStates;
+}
+
+QPixmap ResultListModel::getThumbnail(const std::string& path) const {
+    auto it = m_thumbnails.find(path);
+    if (it != m_thumbnails.end()) return it->second;
+    
+    requestThumbnail(path);
+    return QPixmap();
+}
+
+void ResultListModel::requestThumbnail(const std::string& path) const {
+    if (m_loadingPaths.contains(path)) return;
+    
+    m_loadingPaths.insert(path);
+    QString qpath = QString::fromStdString(path);
+    
+    QtConcurrent::run([qpath, path]() -> QImage {
+        QImageReader reader(qpath);
+        reader.setAutoTransform(true);
+        reader.setAllocationLimit(512);
+
+        QSize imgSize = reader.size();
+        if (imgSize.isValid()) {
+            imgSize.scale(300, 300, Qt::KeepAspectRatio);
+            reader.setScaledSize(imgSize);
+        }
+
+        QImage img = reader.read();
+        if (!img.isNull()) {
+            return img;
+        } else {
+            cv::Mat cvImg = ImageHasher::loadImage(path, 300);
+            if (!cvImg.empty()) {
+                cv::Mat rgb;
+                cv::cvtColor(cvImg, rgb, cv::COLOR_BGR2RGB);
+                QImage qimg(rgb.data, rgb.cols, rgb.rows, rgb.step, QImage::Format_RGB888);
+                return qimg.copy();
+            }
+        }
+        return QImage();
+    }).then(const_cast<ResultListModel*>(this), [this, path](QImage img) {
+        m_loadingPaths.erase(path);
+        if (!img.isNull()) {
+            m_thumbnails[path] = QPixmap::fromImage(img).scaled(150, 150, Qt::KeepAspectRatio, Qt::SmoothTransformation);
+        } else {
+            // エラー表示用ダミーピックスマップをセットしても良いが、ここでは空のまま
+            m_thumbnails[path] = QPixmap(); 
+        }
+        const_cast<ResultListModel*>(this)->emitRowDataChangedForPath(path);
+    });
+}
+
+void ResultListModel::emitRowDataChangedForPath(const std::string& path) {
+    for (int i=0; i < static_cast<int>(m_items.size()); ++i) {
+        if (m_items[i].type == ResultListItem::ImageRow) {
+            for (const auto& img : m_items[i].images) {
+                if (img.path == path) {
+                    emit dataChanged(index(i, 0), index(i, 0));
+                    return; // found it, rows don't repeat paths.
+                }
+            }
+        }
+    }
+}