1 | /* |
2 | SPDX-FileCopyrightText: 2022 Eric Armbruster <eric1@armbruster-online.de> |
3 | SPDX-FileCopyrightText: 2022 Waqar Ahmed <waqar.17a@gmail.com> |
4 | |
5 | SPDX-License-Identifier: LGPL-2.0-or-later |
6 | */ |
7 | |
8 | #include "clipboardhistorydialog.h" |
9 | #include "kateconfig.h" |
10 | #include "katedocument.h" |
11 | #include "kateview.h" |
12 | |
13 | #include <QBoxLayout> |
14 | #include <QCoreApplication> |
15 | #include <QFont> |
16 | #include <QGraphicsOpacityEffect> |
17 | #include <QItemSelectionModel> |
18 | #include <QKeyEvent> |
19 | #include <QMimeDatabase> |
20 | #include <QSortFilterProxyModel> |
21 | #include <QStyledItemDelegate> |
22 | #include <QVBoxLayout> |
23 | |
24 | #include <KLocalizedString> |
25 | #include <KSyntaxHighlighting/Definition> |
26 | #include <KSyntaxHighlighting/Repository> |
27 | #include <KTextEditor/Editor> |
28 | |
29 | class ClipboardHistoryModel : public QAbstractTableModel |
30 | { |
31 | public: |
32 | enum Role { |
33 | HighlightingRole = Qt::UserRole + 1, |
34 | OriginalSorting |
35 | }; |
36 | |
37 | explicit ClipboardHistoryModel(QObject *parent) |
38 | : QAbstractTableModel(parent) |
39 | { |
40 | } |
41 | |
42 | int rowCount(const QModelIndex &parent) const override |
43 | { |
44 | if (parent.isValid()) { |
45 | return 0; |
46 | } |
47 | return m_modelEntries.size(); |
48 | } |
49 | |
50 | int columnCount(const QModelIndex &parent) const override |
51 | { |
52 | Q_UNUSED(parent); |
53 | return 1; |
54 | } |
55 | |
56 | QVariant data(const QModelIndex &idx, int role) const override |
57 | { |
58 | if (!idx.isValid()) { |
59 | return {}; |
60 | } |
61 | |
62 | const ClipboardEntry &clipboardEntry = m_modelEntries.at(i: idx.row()); |
63 | if (role == Qt::DisplayRole) { |
64 | return clipboardEntry.text; |
65 | } else if (role == Role::HighlightingRole) { |
66 | return clipboardEntry.fileName; |
67 | } else if (role == Qt::DecorationRole) { |
68 | return clipboardEntry.icon; |
69 | } else if (role == Role::OriginalSorting) { |
70 | return clipboardEntry.dateSort; |
71 | } |
72 | |
73 | return {}; |
74 | } |
75 | |
76 | void refresh(const QList<KTextEditor::EditorPrivate::ClipboardEntry> &clipboardEntry) |
77 | { |
78 | QList<ClipboardEntry> temp; |
79 | |
80 | for (int i = 0; i < clipboardEntry.size(); ++i) { |
81 | const auto entry = clipboardEntry.at(i); |
82 | |
83 | auto icon = QIcon::fromTheme(name: QMimeDatabase().mimeTypeForFile(fileName: entry.fileName).iconName()); |
84 | if (icon.isNull()) { |
85 | icon = QIcon::fromTheme(QStringLiteral("text-plain" )); |
86 | } |
87 | |
88 | temp.append(t: {.text = entry.text, .fileName = entry.fileName, .icon = icon, .dateSort = i}); |
89 | } |
90 | |
91 | beginResetModel(); |
92 | m_modelEntries = std::move(temp); |
93 | endResetModel(); |
94 | } |
95 | |
96 | void clear() |
97 | { |
98 | beginResetModel(); |
99 | QList<ClipboardEntry>().swap(other&: m_modelEntries); |
100 | endResetModel(); |
101 | } |
102 | |
103 | private: |
104 | struct ClipboardEntry { |
105 | QString text; |
106 | QString fileName; |
107 | QIcon icon; |
108 | int dateSort; |
109 | }; |
110 | |
111 | QList<ClipboardEntry> m_modelEntries; |
112 | }; |
113 | |
114 | class ClipboardHistoryFilterModel : public QSortFilterProxyModel |
115 | { |
116 | public: |
117 | explicit ClipboardHistoryFilterModel(QObject *parent = nullptr) |
118 | : QSortFilterProxyModel(parent) |
119 | { |
120 | } |
121 | |
122 | protected: |
123 | bool lessThan(const QModelIndex &sourceLeft, const QModelIndex &sourceRight) const override |
124 | { |
125 | const int l = sourceLeft.data(arole: ClipboardHistoryModel::OriginalSorting).toInt(); |
126 | const int r = sourceRight.data(arole: ClipboardHistoryModel::OriginalSorting).toInt(); |
127 | return l > r; |
128 | } |
129 | }; |
130 | |
131 | class SingleLineDelegate : public QStyledItemDelegate |
132 | { |
133 | public: |
134 | explicit SingleLineDelegate(const QFont &font) |
135 | : QStyledItemDelegate(nullptr) |
136 | , m_font(font) |
137 | , m_newLineRegExp(QStringLiteral("\\n|\\r|\u2028" ), QRegularExpression::UseUnicodePropertiesOption) |
138 | { |
139 | } |
140 | |
141 | void initStyleOption(QStyleOptionViewItem *option, const QModelIndex &index) const override |
142 | { |
143 | QStyledItemDelegate::initStyleOption(option, index); |
144 | option->font = m_font; |
145 | } |
146 | |
147 | QString displayText(const QVariant &value, const QLocale &locale) const override |
148 | { |
149 | QString baseText = QStyledItemDelegate::displayText(value, locale).trimmed(); |
150 | auto endOfLine = baseText.indexOf(re: m_newLineRegExp, from: 0); |
151 | if (endOfLine != -1) { |
152 | baseText.truncate(pos: endOfLine); |
153 | } |
154 | |
155 | return baseText; |
156 | } |
157 | |
158 | private: |
159 | QFont m_font; |
160 | QRegularExpression m_newLineRegExp; |
161 | }; |
162 | |
163 | ClipboardHistoryDialog::ClipboardHistoryDialog(QWidget *mainWindow, KTextEditor::ViewPrivate *viewPrivate) |
164 | : QMenu(mainWindow) |
165 | , m_mainWindow(mainWindow) |
166 | , m_viewPrivate(viewPrivate) |
167 | , m_model(new ClipboardHistoryModel(this)) |
168 | , m_proxyModel(new ClipboardHistoryFilterModel(this)) |
169 | , m_selectedDoc(new KTextEditor::DocumentPrivate) |
170 | { |
171 | // -------------------------------------------------- |
172 | // start of copy from Kate quickdialog.cpp (slight changes) |
173 | // -------------------------------------------------- |
174 | |
175 | QVBoxLayout *layout = new QVBoxLayout(); |
176 | layout->setSpacing(0); |
177 | layout->setContentsMargins(left: 4, top: 4, right: 4, bottom: 4); |
178 | setLayout(layout); |
179 | |
180 | setFocusProxy(&m_lineEdit); |
181 | |
182 | layout->addWidget(&m_lineEdit); |
183 | |
184 | layout->addWidget(&m_treeView, stretch: 2); |
185 | m_treeView.setTextElideMode(Qt::ElideLeft); |
186 | m_treeView.setUniformRowHeights(true); |
187 | |
188 | connect(sender: &m_lineEdit, signal: &QLineEdit::returnPressed, context: this, slot: &ClipboardHistoryDialog::slotReturnPressed); |
189 | // user can add this as necessary |
190 | // connect(m_lineEdit, &QLineEdit::textChanged, delegate, &StyleDelegate::setFilterString); |
191 | connect(sender: &m_lineEdit, signal: &QLineEdit::textChanged, context: this, slot: [this]() { |
192 | m_treeView.viewport()->update(); |
193 | }); |
194 | connect(sender: &m_treeView, signal: &QTreeView::doubleClicked, context: this, slot: &ClipboardHistoryDialog::slotReturnPressed); |
195 | m_treeView.setSortingEnabled(true); |
196 | |
197 | m_treeView.setHeaderHidden(true); |
198 | m_treeView.setRootIsDecorated(false); |
199 | m_treeView.setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); |
200 | m_treeView.setSelectionMode(QTreeView::SingleSelection); |
201 | |
202 | updateViewGeometry(); |
203 | setFocus(); |
204 | |
205 | // -------------------------------------------------- |
206 | // end of copy from Kate quickdialog.cpp |
207 | // -------------------------------------------------- |
208 | |
209 | m_proxyModel->setSourceModel(m_model); |
210 | m_proxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive); |
211 | |
212 | const QFont font = viewPrivate->rendererConfig()->baseFont(); |
213 | |
214 | m_treeView.setModel(m_proxyModel); |
215 | m_treeView.setItemDelegate(new SingleLineDelegate(font)); |
216 | m_treeView.setTextElideMode(Qt::ElideRight); |
217 | |
218 | m_selectedDoc->setParent(this); |
219 | m_selectedView = new KTextEditor::ViewPrivate(m_selectedDoc, this); |
220 | m_selectedView->setStatusBarEnabled(false); |
221 | m_selectedView->setLineNumbersOn(false); |
222 | m_selectedView->setFoldingMarkersOn(false); |
223 | m_selectedView->setIconBorder(false); |
224 | m_selectedView->setScrollBarMarks(false); |
225 | m_selectedView->setScrollBarMiniMap(false); |
226 | |
227 | layout->addWidget(m_selectedView, stretch: 3); |
228 | |
229 | m_lineEdit.setFont(font); |
230 | |
231 | connect(sender: m_treeView.selectionModel(), signal: &QItemSelectionModel::currentRowChanged, context: this, slot: [this](const QModelIndex ¤t, const QModelIndex &previous) { |
232 | Q_UNUSED(previous); |
233 | showSelectedText(idx: current); |
234 | }); |
235 | |
236 | connect(sender: &m_lineEdit, signal: &QLineEdit::textChanged, context: this, slot: [this](const QString &s) { |
237 | m_proxyModel->setFilterFixedString(s); |
238 | |
239 | const auto bestMatch = m_proxyModel->index(row: 0, column: 0); |
240 | m_treeView.setCurrentIndex(bestMatch); |
241 | showSelectedText(idx: bestMatch); |
242 | }); |
243 | |
244 | m_treeView.installEventFilter(filterObj: this); |
245 | m_lineEdit.installEventFilter(filterObj: this); |
246 | m_selectedView->installEventFilter(filterObj: this); |
247 | } |
248 | |
249 | void ClipboardHistoryDialog::showSelectedText(const QModelIndex &idx) |
250 | { |
251 | QString text = m_proxyModel->data(index: idx, role: Qt::DisplayRole).toString(); |
252 | if (m_selectedDoc->text().isEmpty() || text != m_selectedDoc->text()) { |
253 | QString fileName = m_proxyModel->data(index: idx, role: ClipboardHistoryModel::Role::HighlightingRole).toString(); |
254 | m_selectedDoc->setReadWrite(true); |
255 | m_selectedDoc->setText(text); |
256 | m_selectedDoc->setReadWrite(false); |
257 | const auto mode = KTextEditor::Editor::instance()->repository().definitionForFileName(fileName).name(); |
258 | m_selectedDoc->setHighlightingMode(mode); |
259 | } |
260 | } |
261 | |
262 | void ClipboardHistoryDialog::resetValues() |
263 | { |
264 | m_lineEdit.setPlaceholderText(i18n("Select text to paste." )); |
265 | } |
266 | |
267 | void ClipboardHistoryDialog::openDialog(const QList<KTextEditor::EditorPrivate::ClipboardEntry> &clipboardHistory) |
268 | { |
269 | m_model->refresh(clipboardEntry: clipboardHistory); |
270 | resetValues(); |
271 | |
272 | if (m_model->rowCount(parent: m_model->index(row: -1, column: -1)) == 0) { |
273 | showEmptyPlaceholder(); |
274 | } else { |
275 | const auto first = m_proxyModel->index(row: 0, column: 0); |
276 | m_treeView.setCurrentIndex(first); |
277 | showSelectedText(idx: first); |
278 | } |
279 | |
280 | exec(); |
281 | } |
282 | |
283 | void ClipboardHistoryDialog::showEmptyPlaceholder() |
284 | { |
285 | QVBoxLayout *noRecentsLayout = new QVBoxLayout(&m_treeView); |
286 | m_treeView.setLayout(noRecentsLayout); |
287 | m_noEntries = new QLabel(&m_treeView); |
288 | QFont placeholderLabelFont; |
289 | // To match the size of a level 2 Heading/KTitleWidget |
290 | placeholderLabelFont.setPointSize(qRound(d: placeholderLabelFont.pointSize() * 1.3)); |
291 | noRecentsLayout->addWidget(m_noEntries); |
292 | m_noEntries->setFont(placeholderLabelFont); |
293 | m_noEntries->setTextInteractionFlags(Qt::NoTextInteraction); |
294 | m_noEntries->setWordWrap(true); |
295 | m_noEntries->setAlignment(Qt::AlignCenter); |
296 | m_noEntries->setText(i18n("No entries in clipboard history" )); |
297 | // Match opacity of QML placeholder label component |
298 | auto *effect = new QGraphicsOpacityEffect(m_noEntries); |
299 | effect->setOpacity(0.5); |
300 | m_noEntries->setGraphicsEffect(effect); |
301 | } |
302 | |
303 | // -------------------------------------------------- |
304 | // start of copy from Kate quickdialog.cpp |
305 | // -------------------------------------------------- |
306 | |
307 | void ClipboardHistoryDialog::slotReturnPressed() |
308 | { |
309 | const QString text = m_proxyModel->data(index: m_treeView.currentIndex(), role: Qt::DisplayRole).toString(); |
310 | m_viewPrivate->paste(textToPaste: &text); |
311 | |
312 | clearLineEdit(); |
313 | hide(); |
314 | } |
315 | |
316 | bool ClipboardHistoryDialog::eventFilter(QObject *obj, QEvent *event) |
317 | { |
318 | // catch key presses + shortcut overrides to allow to have ESC as application wide shortcut, too, see bug 409856 |
319 | if (event->type() == QEvent::KeyPress || event->type() == QEvent::ShortcutOverride) { |
320 | QKeyEvent *keyEvent = static_cast<QKeyEvent *>(event); |
321 | if (obj == &m_lineEdit) { |
322 | const bool forward2list = (keyEvent->key() == Qt::Key_Up) || (keyEvent->key() == Qt::Key_Down) || (keyEvent->key() == Qt::Key_PageUp) |
323 | || (keyEvent->key() == Qt::Key_PageDown); |
324 | if (forward2list) { |
325 | QCoreApplication::sendEvent(receiver: &m_treeView, event); |
326 | return true; |
327 | } |
328 | |
329 | if (keyEvent->key() == Qt::Key_Escape) { |
330 | clearLineEdit(); |
331 | keyEvent->accept(); |
332 | hide(); |
333 | return true; |
334 | } |
335 | } else { |
336 | const bool forward2input = (keyEvent->key() != Qt::Key_Up) && (keyEvent->key() != Qt::Key_Down) && (keyEvent->key() != Qt::Key_PageUp) |
337 | && (keyEvent->key() != Qt::Key_PageDown) && (keyEvent->key() != Qt::Key_Tab) && (keyEvent->key() != Qt::Key_Backtab); |
338 | if (forward2input) { |
339 | QCoreApplication::sendEvent(receiver: &m_lineEdit, event); |
340 | return true; |
341 | } |
342 | } |
343 | } |
344 | |
345 | // hide on focus out, if neither input field nor list have focus! |
346 | else if (event->type() == QEvent::FocusOut && !(m_lineEdit.hasFocus() || m_treeView.hasFocus() || m_selectedView->hasFocus())) { |
347 | clearLineEdit(); |
348 | hide(); |
349 | return true; |
350 | } |
351 | |
352 | return QWidget::eventFilter(watched: obj, event); |
353 | } |
354 | |
355 | void ClipboardHistoryDialog::updateViewGeometry() |
356 | { |
357 | if (!m_mainWindow) |
358 | return; |
359 | |
360 | const QSize centralSize = m_mainWindow->size(); |
361 | |
362 | // width: 2.4 of editor, height: 1/2 of editor |
363 | const QSize viewMaxSize(centralSize.width() / 2.4, centralSize.height() / 2); |
364 | |
365 | // Position should be central over window |
366 | const int xPos = std::max(a: 0, b: (centralSize.width() - viewMaxSize.width()) / 2); |
367 | const int yPos = std::max(a: 0, b: (centralSize.height() - viewMaxSize.height()) * 1 / 4); |
368 | const QPoint p(xPos, yPos); |
369 | move(p + m_mainWindow->pos()); |
370 | |
371 | this->setFixedSize(viewMaxSize); |
372 | } |
373 | |
374 | void ClipboardHistoryDialog::clearLineEdit() |
375 | { |
376 | const QSignalBlocker block(m_lineEdit); |
377 | m_lineEdit.clear(); |
378 | } |
379 | |
380 | // -------------------------------------------------- |
381 | // end of copy from Kate quickdialog.cpp |
382 | // -------------------------------------------------- |
383 | |