1/*
2 SPDX-FileCopyrightText: 2019-2020 Nibaldo González S. <nibgonz@gmail.com>
3
4 SPDX-License-Identifier: LGPL-2.0-or-later
5*/
6#include "katemodemenulist.h"
7
8#include "kateconfig.h"
9#include "katedocument.h"
10#include "kateglobal.h"
11#include "katemodemanager.h"
12#include "katepartdebug.h"
13
14#include <QAbstractItemView>
15#include <QApplication>
16#include <QFrame>
17#include <QHBoxLayout>
18#include <QVBoxLayout>
19#include <QWidgetAction>
20
21#include <KLocalizedString>
22
23namespace
24{
25/**
26 * Detect words delimiters:
27 * ! " # $ % & ' ( ) * + , - . / : ;
28 * < = > ? [ \ ] ^ ` { | } ~ « »
29 */
30static bool isDelimiter(const ushort c)
31{
32 return (c <= 126 && c >= 33 && (c >= 123 || c <= 47 || (c <= 96 && c >= 58 && c != 95 && (c >= 91 || c <= 63)))) || c == 171 || c == 187;
33}
34
35/**
36 * Overlay scroll bar on the list according to the operating system
37 * and/or the desktop environment. In some desktop themes the scroll bar
38 * isn't transparent, so it's better not to overlap it on the list.
39 * NOTE: Currently, in the Breeze theme, the scroll bar does not overlap
40 * the content. See: https://phabricator.kde.org/T9126
41 */
42inline static bool overlapScrollBar()
43{
44 return false;
45}
46}
47
48void KateModeMenuList::init()
49{
50 connect(sender: this, signal: &QMenu::aboutToShow, context: this, slot: &KateModeMenuList::onAboutToShowMenu);
51}
52
53void KateModeMenuList::onAboutToShowMenu()
54{
55 if (m_initialized) {
56 return;
57 }
58 /*
59 * Fix font size & font style: display the font correctly when changing it from the
60 * KDE Plasma preferences. For example, the font type "Menu" is displayed, but "font()"
61 * and "fontMetrics()" return the font type "General". Therefore, this overwrites the
62 * "General" font. This makes it possible to correctly apply word wrapping on items,
63 * when changing the font or its size.
64 */
65 QFont font = this->font();
66 font.setFamily(font.family());
67 font.setStyle(font.style());
68 font.setStyleName(font.styleName());
69 font.setBold(font.bold());
70 font.setItalic(font.italic());
71 font.setUnderline(font.underline());
72 font.setStrikeOut(font.strikeOut());
73 font.setPointSize(font.pointSize());
74 setFont(font);
75
76 /*
77 * Calculate the size of the list and the checkbox icon (in pixels) according
78 * to the font size. From font 12pt to 26pt increase the list size.
79 */
80 int menuWidth = 266;
81 int menuHeight = 428;
82 const int fontSize = font.pointSize();
83 if (fontSize >= 12) {
84 const int increaseSize = (fontSize - 11) * 10;
85 if (increaseSize >= 150) { // Font size: 26pt
86 menuWidth += 150;
87 menuHeight += 150;
88 } else {
89 menuWidth += increaseSize;
90 menuHeight += increaseSize;
91 }
92
93 if (fontSize >= 22) {
94 m_iconSize = 32;
95 } else if (fontSize >= 18) {
96 m_iconSize = 24;
97 } else if (fontSize >= 14) {
98 m_iconSize = 22;
99 } else if (fontSize >= 12) {
100 m_iconSize = 18;
101 }
102 }
103
104 // Create list and search bar
105 m_list = KateModeMenuListData::Factory::createListView(parentMenu: this);
106 m_searchBar = KateModeMenuListData::Factory::createSearchLine(parentMenu: this);
107
108 // Empty icon for items.
109 QPixmap emptyIconPixmap(m_iconSize, m_iconSize);
110 emptyIconPixmap.fill(fillColor: Qt::transparent);
111 m_emptyIcon = QIcon(emptyIconPixmap);
112
113 /*
114 * Load list widget, scroll bar and items.
115 */
116 if (overlapScrollBar()) {
117 // The vertical scroll bar will be added in another layout
118 m_scroll = new QScrollBar(Qt::Vertical, this);
119 m_list->setVerticalScrollBar(m_scroll);
120 m_list->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
121 m_list->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
122 } else {
123 m_list->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
124 m_list->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
125 }
126 m_list->setIconSize(QSize(m_iconSize, m_iconSize));
127 m_list->setResizeMode(QListView::Adjust);
128 // Size of the list widget and search bar.
129 setSizeList(height: menuHeight, width: menuWidth);
130
131 // Data model (items).
132 // couple model to view to let it be deleted with the view
133 m_model = new QStandardItemModel(0, 0, m_list);
134 loadHighlightingModel();
135
136 /*
137 * Search bar widget.
138 */
139 m_searchBar->setPlaceholderText(i18nc("Placeholder in search bar", "Search..."));
140 m_searchBar->setToolTip(i18nc("ToolTip of the search bar of modes of syntax highlighting",
141 "Search for syntax highlighting modes by language name or file extension (for example, C++ or .cpp)"));
142 m_searchBar->setMaxLength(200);
143
144 m_list->setFocusProxy(m_searchBar);
145
146 /*
147 * Set layouts and widgets.
148 * container (QWidget)
149 * └── layoutContainer (QVBoxLayout)
150 * ├── m_layoutList (QGridLayout)
151 * │ ├── m_list (ListView)
152 * │ ├── layoutScrollBar (QHBoxLayout) --> m_scroll (QScrollBar)
153 * │ └── m_emptyListMsg (QLabel)
154 * └── layoutSearchBar (QHBoxLayout) --> m_searchBar (SearchLine)
155 */
156 QWidget *container = new QWidget(this);
157 QVBoxLayout *layoutContainer = new QVBoxLayout(container);
158 m_layoutList = new QGridLayout();
159 QHBoxLayout *layoutSearchBar = new QHBoxLayout();
160
161 m_layoutList->addWidget(m_list, row: 0, column: 0, Qt::AlignLeft);
162
163 // Add scroll bar and set margin.
164 // Overlap scroll bar above the list widget.
165 if (overlapScrollBar()) {
166 QHBoxLayout *layoutScrollBar = new QHBoxLayout();
167 layoutScrollBar->addWidget(m_scroll);
168 layoutScrollBar->setContentsMargins(left: 1, top: 2, right: 2, bottom: 2); // ScrollBar Margin = 2, Also see: KateModeMenuListData::ListView::getContentWidth()
169 m_layoutList->addLayout(layoutScrollBar, row: 0, column: 0, Qt::AlignRight);
170 }
171
172 layoutSearchBar->addWidget(m_searchBar);
173 layoutContainer->addLayout(layout: m_layoutList);
174 layoutContainer->addLayout(layout: layoutSearchBar);
175
176 QWidgetAction *widAct = new QWidgetAction(this);
177 widAct->setDefaultWidget(container);
178 addAction(action: widAct);
179
180 /*
181 * Detect selected item with one click.
182 * This also applies to double-clicks.
183 */
184 connect(sender: m_list, signal: &KateModeMenuListData::ListView::clicked, context: this, slot: &KateModeMenuList::selectHighlighting);
185
186 m_initialized = true;
187}
188
189void KateModeMenuList::reloadItems()
190{
191 // We aren't initialized, nothing to reload
192 if (!m_initialized) {
193 return;
194 }
195
196 const QString searchText = m_searchBar->text().trimmed();
197 m_searchBar->m_bestResults.clear();
198 if (!isHidden()) {
199 hide();
200 }
201 /*
202 * Clear model.
203 * NOTE: This deletes the item objects and widgets indexed to items.
204 * That is, the QLabel & QFrame objects of the section titles are also deleted.
205 * See: QAbstractItemView::setIndexWidget(), QObject::deleteLater()
206 */
207 m_model->clear();
208 m_list->selectionModel()->clear();
209 m_selectedItem = nullptr;
210
211 loadHighlightingModel();
212
213 // Restore search text, if there is.
214 m_searchBar->m_bSearchStateAutoScroll = false;
215 if (!searchText.isEmpty()) {
216 selectHighlightingFromExternal();
217 m_searchBar->updateSearch(s: searchText);
218 m_searchBar->setText(searchText);
219 }
220}
221
222void KateModeMenuList::loadHighlightingModel()
223{
224 m_list->setModel(m_model);
225
226 QString *prevHlSection = nullptr;
227 /*
228 * The width of the text container in the item, in pixels. This is used to make
229 * a custom word wrap and prevent the item's text from passing under the scroll bar.
230 * NOTE: 8 = Icon margin
231 */
232 const int maxWidthText = m_list->getContentWidth(overlayScrollbarMargin: 1, classicScrollbarMargin: 8) - m_iconSize - 8;
233
234 // Transparent color used as background in the sections.
235 QPixmap transparentPixmap = QPixmap(m_iconSize / 2, m_iconSize / 2);
236 transparentPixmap.fill(fillColor: Qt::transparent);
237 QBrush transparentBrush(transparentPixmap);
238
239 /*
240 * The first item on the list is the "Best Search Matches" section,
241 * which will remain hidden and will only be shown when necessary.
242 */
243 createSectionList(sectionName: QString(), background: transparentBrush, bSeparator: false);
244 m_defaultHeightItemSection = m_list->visualRect(index: m_model->index(row: 0, column: 0)).height();
245 m_list->setRowHidden(row: 0, hide: true);
246
247 /*
248 * Get list of modes from KateModeManager::list().
249 * We assume that the modes are arranged according to sections, alphabetically;
250 * and the attribute "translatedSection" isn't empty if "section" has a value.
251 */
252 for (auto *hl : KTextEditor::EditorPrivate::self()->modeManager()->list()) {
253 if (hl->name.isEmpty()) {
254 continue;
255 }
256
257 // Detects a new section.
258 if (!hl->translatedSection.isEmpty() && (prevHlSection == nullptr || hl->translatedSection != *prevHlSection)) {
259 createSectionList(sectionName: hl->sectionTranslated(), background: transparentBrush);
260 }
261 prevHlSection = hl->translatedSection.isNull() ? nullptr : &hl->translatedSection;
262
263 // Create item in the list with the language name.
264 KateModeMenuListData::ListItem *item = KateModeMenuListData::Factory::createListItem();
265 /*
266 * NOTE:
267 * - (If the scroll bar is not overlapped) In QListView::setWordWrap(),
268 * when the scroll bar is hidden, the word wrap changes, but the size
269 * of the items is keeped, causing display problems in some items.
270 * KateModeMenuList::setWordWrap() applies a fixed word wrap.
271 * - Search names generated in: KateModeMenuListData::SearchLine::updateSearch()
272 */
273 item->setText(setWordWrap(text: hl->nameTranslated(), maxWidth: maxWidthText, fontMetrics: m_list->fontMetrics()));
274 item->setMode(hl);
275
276 item->setIcon(m_emptyIcon);
277 item->setEditable(false);
278 // Add item
279 m_model->appendRow(aitem: item);
280 }
281}
282
283KateModeMenuListData::ListItem *KateModeMenuList::createSectionList(const QString &sectionName, const QBrush &background, bool bSeparator, int modelPosition)
284{
285 /*
286 * Add a separator to the list.
287 */
288 if (bSeparator) {
289 KateModeMenuListData::ListItem *separator = KateModeMenuListData::Factory::createListItem();
290 separator->setFlags(Qt::NoItemFlags);
291 separator->setEnabled(false);
292 separator->setEditable(false);
293 separator->setSelectable(false);
294
295 separator->setSizeHint(QSize(separator->sizeHint().width() - 2, 4));
296 separator->setBackground(background);
297
298 QFrame *line = new QFrame(m_list);
299 line->setFrameStyle(QFrame::HLine);
300
301 if (modelPosition < 0) {
302 m_model->appendRow(aitem: separator);
303 } else {
304 m_model->insertRow(arow: modelPosition, aitem: separator);
305 }
306 m_list->setIndexWidget(index: m_model->index(row: separator->row(), column: 0), widget: line);
307 m_list->selectionModel()->select(index: separator->index(), command: QItemSelectionModel::Deselect);
308 }
309
310 /*
311 * Add the section name to the list.
312 */
313 KateModeMenuListData::ListItem *section = KateModeMenuListData::Factory::createListItem();
314 section->setFlags(Qt::NoItemFlags);
315 section->setEnabled(false);
316 section->setEditable(false);
317 section->setSelectable(false);
318
319 QLabel *label = new QLabel(sectionName, m_list);
320 if (m_list->layoutDirection() == Qt::RightToLeft) {
321 label->setAlignment(Qt::AlignRight);
322 }
323 label->setTextFormat(Qt::PlainText);
324 label->setIndent(6);
325
326 /*
327 * NOTE: Names of sections in bold. The font color
328 * should change according to Kate's color theme.
329 */
330 QFont font = label->font();
331 font.setWeight(QFont::Bold);
332 label->setFont(font);
333
334 section->setBackground(background);
335
336 if (modelPosition < 0) {
337 m_model->appendRow(aitem: section);
338 } else {
339 m_model->insertRow(arow: modelPosition + 1, aitem: section);
340 }
341 m_list->setIndexWidget(index: m_model->index(row: section->row(), column: 0), widget: label);
342 m_list->selectionModel()->select(index: section->index(), command: QItemSelectionModel::Deselect);
343
344 // Apply word wrap in sections, for long labels.
345 const int containerTextWidth = m_list->getContentWidth(overlayScrollbarMargin: 2, classicScrollbarMargin: 4);
346 int heightSectionMargin = m_list->visualRect(index: m_model->index(row: section->row(), column: 0)).height() - label->sizeHint().height();
347
348 if (label->sizeHint().width() > containerTextWidth) {
349 label->setText(setWordWrap(text: label->text(), maxWidth: containerTextWidth - label->indent(), fontMetrics: label->fontMetrics()));
350 if (heightSectionMargin < 2) {
351 heightSectionMargin = 2;
352 }
353 section->setSizeHint(QSize(section->sizeHint().width(), label->sizeHint().height() + heightSectionMargin));
354 } else if (heightSectionMargin < 2) {
355 section->setSizeHint(QSize(section->sizeHint().width(), label->sizeHint().height() + 2));
356 }
357
358 return section;
359}
360
361void KateModeMenuList::setButton(QPushButton *button, AlignmentHButton positionX, AlignmentVButton positionY, AutoUpdateTextButton autoUpdateTextButton)
362{
363 if (positionX == AlignHInverse) {
364 if (layoutDirection() == Qt::RightToLeft) {
365 m_positionX = KateModeMenuList::AlignLeft;
366 } else {
367 m_positionX = KateModeMenuList::AlignRight;
368 }
369 } else if (positionX == AlignLeft && layoutDirection() != Qt::RightToLeft) {
370 m_positionX = KateModeMenuList::AlignHDefault;
371 } else {
372 m_positionX = positionX;
373 }
374
375 m_positionY = positionY;
376 m_pushButton = button;
377 m_autoUpdateTextButton = autoUpdateTextButton;
378}
379
380void KateModeMenuList::setSizeList(const int height, const int width)
381{
382 m_list->setSizeList(height, width);
383 m_searchBar->setWidth(width);
384}
385
386void KateModeMenuList::autoScroll()
387{
388 if (m_selectedItem && m_autoScroll == ScrollToSelectedItem) {
389 m_list->setCurrentItem(m_selectedItem->row());
390 m_list->scrollToItem(rowItem: m_selectedItem->row(), hint: QAbstractItemView::PositionAtCenter);
391 } else {
392 m_list->scrollToFirstItem();
393 }
394}
395
396void KateModeMenuList::showEvent(QShowEvent *event)
397{
398 Q_UNUSED(event);
399 /*
400 * TODO: Put the menu on the bottom-edge of the window if the status bar is hidden,
401 * to show the menu with keyboard shortcuts. To do this, it's preferable to add a new
402 * function/slot to display the menu, correcting the position. If the trigger button
403 * isn't set or is destroyed, there may be problems detecting Right-to-left layouts.
404 */
405
406 // Set the menu position
407 if (m_pushButton && m_pushButton->isVisible()) {
408 /*
409 * Get vertical position.
410 * NOTE: In KDE Plasma with Wayland, the reference point of the position
411 * is the main window, not the desktop. Therefore, if the window is vertically
412 * smaller than the menu, it will be positioned on the upper edge of the window.
413 */
414 int newMenu_y; // New vertical menu position
415 if (m_positionY == AlignTop) {
416 newMenu_y = m_pushButton->mapToGlobal(QPoint(0, 0)).y() - geometry().height();
417 if (newMenu_y < 0) {
418 newMenu_y = 0;
419 }
420 } else {
421 newMenu_y = pos().y();
422 }
423
424 // Set horizontal position.
425 if (m_positionX == AlignRight) {
426 // New horizontal menu position
427 int newMenu_x = pos().x() - geometry().width() + m_pushButton->geometry().width();
428 // Get position of the right edge of the toggle button
429 const int buttonPositionRight = m_pushButton->mapToGlobal(QPoint(0, 0)).x() + m_pushButton->geometry().width();
430 if (newMenu_x < 0) {
431 newMenu_x = 0;
432 } else if (newMenu_x + geometry().width() < buttonPositionRight) {
433 newMenu_x = buttonPositionRight - geometry().width();
434 }
435 move(ax: newMenu_x, ay: newMenu_y);
436 } else if (m_positionX == AlignLeft) {
437 move(ax: m_pushButton->mapToGlobal(QPoint(0, 0)).x(), ay: newMenu_y);
438 } else if (m_positionY == AlignTop) {
439 // Set vertical position, use the default horizontal position
440 move(ax: pos().x(), ay: newMenu_y);
441 }
442 }
443
444 // Select text from the search bar
445 if (!m_searchBar->text().isEmpty()) {
446 if (m_searchBar->text().trimmed().isEmpty()) {
447 m_searchBar->clear();
448 } else {
449 m_searchBar->selectAll();
450 }
451 }
452
453 // Set focus on the list. The list widget uses focus proxy to the search bar.
454 m_list->setFocus(Qt::ActiveWindowFocusReason);
455
456 KTextEditor::DocumentPrivate *doc = m_doc;
457 if (!doc) {
458 return;
459 }
460
461 // First show or if an external changed the current syntax highlighting.
462 if (!m_selectedItem || (m_selectedItem->hasMode() && m_selectedItem->getMode()->name != doc->fileType())) {
463 if (!selectHighlightingFromExternal(nameMode: doc->fileType())) {
464 // Strange case: if the current syntax highlighting does not exist in the list.
465 if (m_selectedItem) {
466 m_selectedItem->setIcon(m_emptyIcon);
467 }
468 if ((m_selectedItem || !m_list->currentItem()) && m_searchBar->text().isEmpty()) {
469 m_list->scrollToFirstItem();
470 }
471 m_selectedItem = nullptr;
472 }
473 }
474}
475
476void KateModeMenuList::updateSelectedItem(KateModeMenuListData::ListItem *item)
477{
478 // Change the previously selected item to empty icon
479 if (m_selectedItem) {
480 m_selectedItem->setIcon(m_emptyIcon);
481 }
482
483 // Update the selected item
484 item->setIcon(m_checkIcon);
485 m_selectedItem = item;
486 m_list->setCurrentItem(item->row());
487
488 // Change text of the trigger button
489 if (bool(m_autoUpdateTextButton) && m_pushButton && item->hasMode()) {
490 m_pushButton->setText(item->getMode()->nameTranslated());
491 }
492}
493
494void KateModeMenuList::selectHighlightingSetVisibility(QStandardItem *pItem, const bool bHideMenu)
495{
496 if (!pItem || !pItem->isSelectable() || !pItem->isEnabled()) {
497 return;
498 }
499
500 KateModeMenuListData::ListItem *item = static_cast<KateModeMenuListData::ListItem *>(pItem);
501
502 if (!item->text().isEmpty()) {
503 updateSelectedItem(item);
504 }
505 if (bHideMenu) {
506 hide();
507 }
508
509 // Apply syntax highlighting
510 KTextEditor::DocumentPrivate *doc = m_doc;
511 if (doc && item->hasMode()) {
512 doc->updateFileType(newType: item->getMode()->name, user: true);
513 }
514}
515
516void KateModeMenuList::selectHighlighting(const QModelIndex &index)
517{
518 selectHighlightingSetVisibility(pItem: m_model->item(row: index.row(), column: 0), bHideMenu: true);
519}
520
521bool KateModeMenuList::selectHighlightingFromExternal(const QString &nameMode)
522{
523 for (int i = 0; i < m_model->rowCount(); ++i) {
524 KateModeMenuListData::ListItem *item = static_cast<KateModeMenuListData::ListItem *>(m_model->item(row: i, column: 0));
525
526 if (!item->hasMode() || m_model->item(row: i, column: 0)->text().isEmpty()) {
527 continue;
528 }
529 if (item->getMode()->name == nameMode || (nameMode.isEmpty() && item->getMode()->name == QLatin1String("Normal"))) {
530 updateSelectedItem(item);
531
532 // Clear search
533 if (!m_searchBar->text().isEmpty()) {
534 // Prevent the empty list message from being seen over the items for a short time
535 if (m_emptyListMsg) {
536 m_emptyListMsg->hide();
537 }
538
539 // NOTE: This calls updateSearch(), it's scrolled to the selected item or the first item.
540 m_searchBar->clear();
541 } else if (m_autoScroll == ScrollToSelectedItem) {
542 m_list->scrollToItem(rowItem: i);
543 } else {
544 // autoScroll()
545 m_list->scrollToFirstItem();
546 }
547 return true;
548 }
549 }
550 return false;
551}
552
553bool KateModeMenuList::selectHighlightingFromExternal()
554{
555 KTextEditor::DocumentPrivate *doc = m_doc;
556 if (doc) {
557 return selectHighlightingFromExternal(nameMode: doc->fileType());
558 }
559 return false;
560}
561
562void KateModeMenuList::loadEmptyMsg()
563{
564 m_emptyListMsg = new QLabel(i18nc("A search yielded no results", "No items matching your search"), this);
565 m_emptyListMsg->setMargin(15);
566 m_emptyListMsg->setWordWrap(true);
567
568 const int fontSize = font().pointSize() > 10 ? font().pointSize() + 4 : 14;
569
570 QColor color = m_emptyListMsg->palette().color(cr: QPalette::Text);
571 m_emptyListMsg->setStyleSheet(QLatin1String("font-size: ") + QString::number(fontSize) + QLatin1String("pt; color: rgba(") + QString::number(color.red())
572 + QLatin1Char(',') + QString::number(color.green()) + QLatin1Char(',') + QString::number(color.blue())
573 + QLatin1String(", 0.3);"));
574
575 m_emptyListMsg->setAlignment(Qt::AlignCenter);
576 m_layoutList->addWidget(m_emptyListMsg, row: 0, column: 0, Qt::AlignCenter);
577}
578
579QString KateModeMenuList::setWordWrap(const QString &text, const int maxWidth, const QFontMetrics &fontMetrics) const
580{
581 // Get the length of the text, in pixels, and compare it with the container
582 if (fontMetrics.horizontalAdvance(text) <= maxWidth) {
583 return text;
584 }
585
586 // Add line breaks in the text to fit in the container
587 QStringList words = text.split(sep: QLatin1Char(' '));
588 if (words.count() < 1) {
589 return text;
590 }
591 QString newText = QString();
592 QString tmpLineText = QString();
593
594 for (int i = 0; i < words.count() - 1; ++i) {
595 // Elide mode in long words
596 if (fontMetrics.horizontalAdvance(words[i]) > maxWidth) {
597 if (!tmpLineText.isEmpty()) {
598 newText += tmpLineText + QLatin1Char('\n');
599 tmpLineText.clear();
600 }
601 newText +=
602 fontMetrics.elidedText(text: words[i], mode: m_list->layoutDirection() == Qt::RightToLeft ? Qt::ElideLeft : Qt::ElideRight, width: maxWidth) + QLatin1Char('\n');
603 continue;
604 } else {
605 tmpLineText += words[i];
606 }
607
608 // This prevents the last line of text from having only one word with 1 or 2 chars
609 if (i == words.count() - 3 && words[i + 2].length() <= 2
610 && fontMetrics.horizontalAdvance(tmpLineText + QLatin1Char(' ') + words[i + 1] + QLatin1Char(' ') + words[i + 2]) > maxWidth) {
611 newText += tmpLineText + QLatin1Char('\n');
612 tmpLineText.clear();
613 }
614 // Add line break if the maxWidth is exceeded with the next word
615 else if (fontMetrics.horizontalAdvance(tmpLineText + QLatin1Char(' ') + words[i + 1]) > maxWidth) {
616 newText += tmpLineText + QLatin1Char('\n');
617 tmpLineText.clear();
618 } else {
619 tmpLineText.append(c: QLatin1Char(' '));
620 }
621 }
622
623 // Add line breaks in delimiters, if the last word is greater than the container
624 bool bElidedLastWord = false;
625 if (fontMetrics.horizontalAdvance(words[words.count() - 1]) > maxWidth) {
626 bElidedLastWord = true;
627 const int lastw = words.count() - 1;
628 for (int c = words[lastw].length() - 1; c >= 0; --c) {
629 if (isDelimiter(c: words[lastw][c].unicode()) && fontMetrics.horizontalAdvance(words[lastw].mid(position: 0, n: c + 1)) <= maxWidth) {
630 bElidedLastWord = false;
631 if (fontMetrics.horizontalAdvance(words[lastw].mid(position: c + 1)) > maxWidth) {
632 words[lastw] = words[lastw].mid(position: 0, n: c + 1) + QLatin1Char('\n')
633 + fontMetrics.elidedText(text: words[lastw].mid(position: c + 1),
634 mode: m_list->layoutDirection() == Qt::RightToLeft ? Qt::ElideLeft : Qt::ElideRight,
635 width: maxWidth);
636 } else {
637 words[lastw].insert(i: c + 1, c: QLatin1Char('\n'));
638 }
639 break;
640 }
641 }
642 }
643
644 if (!tmpLineText.isEmpty()) {
645 newText += tmpLineText;
646 }
647 if (bElidedLastWord) {
648 newText += fontMetrics.elidedText(text: words[words.count() - 1], mode: m_list->layoutDirection() == Qt::RightToLeft ? Qt::ElideLeft : Qt::ElideRight, width: maxWidth);
649 } else {
650 newText += words[words.count() - 1];
651 }
652 return newText;
653}
654
655void KateModeMenuListData::SearchLine::setWidth(const int width)
656{
657 setMinimumWidth(width);
658 setMaximumWidth(width);
659}
660
661void KateModeMenuListData::ListView::setSizeList(const int height, const int width)
662{
663 setMinimumWidth(width);
664 setMaximumWidth(width);
665 setMinimumHeight(height);
666 setMaximumHeight(height);
667}
668
669int KateModeMenuListData::ListView::getWidth() const
670{
671 // Equivalent to: sizeHint().width()
672 // But "sizeHint().width()" returns an incorrect value when the menu is large.
673 return size().width() - 4;
674}
675
676int KateModeMenuListData::ListView::getContentWidth(const int overlayScrollbarMargin, const int classicScrollbarMargin) const
677{
678 if (overlapScrollBar()) {
679 return getWidth() - m_parentMenu->m_scroll->sizeHint().width() - 2 - overlayScrollbarMargin; // ScrollBar Margin = 2
680 }
681 return getWidth() - verticalScrollBar()->sizeHint().width() - classicScrollbarMargin;
682}
683
684int KateModeMenuListData::ListView::getContentWidth() const
685{
686 return getContentWidth(overlayScrollbarMargin: 0, classicScrollbarMargin: 0);
687}
688
689bool KateModeMenuListData::ListItem::generateSearchName(const QString &itemName)
690{
691 QString searchName = QString(itemName);
692 bool bNewName = false;
693
694 // Replace word delimiters with spaces
695 for (int i = searchName.length() - 1; i >= 0; --i) {
696 if (isDelimiter(c: searchName[i].unicode())) {
697 searchName.replace(i, len: 1, after: QLatin1Char(' '));
698 if (!bNewName) {
699 bNewName = true;
700 }
701 }
702 // Avoid duplicate delimiters/spaces
703 if (bNewName && i < searchName.length() - 1 && searchName[i].isSpace() && searchName[i + 1].isSpace()) {
704 searchName.remove(i: i + 1, len: 1);
705 }
706 }
707
708 if (bNewName) {
709 if (searchName[searchName.length() - 1].isSpace()) {
710 searchName.remove(i: searchName.length() - 1, len: 1);
711 }
712 if (searchName[0].isSpace()) {
713 searchName.remove(i: 0, len: 1);
714 }
715 m_searchName = searchName;
716 return true;
717 } else {
718 m_searchName = itemName;
719 }
720 return false;
721}
722
723bool KateModeMenuListData::ListItem::matchExtension(const QString &text) const
724{
725 if (!hasMode() || m_type->wildcards.count() == 0) {
726 return false;
727 }
728
729 /*
730 * Only file extensions and full names are matched. Files like "Kconfig*"
731 * aren't considered. It's also assumed that "text" doesn't contain '*'.
732 */
733 for (const auto &ext : m_type->wildcards) {
734 // File extension
735 if (ext.startsWith(s: QLatin1String("*."))) {
736 if (text.length() == ext.length() - 2 && text.compare(s: QStringView(ext).mid(pos: 2), cs: Qt::CaseInsensitive) == 0) {
737 return true;
738 }
739 } else if (text.length() != ext.length() || ext.endsWith(c: QLatin1Char('*'))) {
740 continue;
741 // Full name
742 } else if (text.compare(s: ext, cs: Qt::CaseInsensitive) == 0) {
743 return true;
744 }
745 }
746 return false;
747}
748
749void KateModeMenuListData::ListView::keyPressEvent(QKeyEvent *event)
750{
751 // Ctrl/Alt/Shift/Meta + Return/Enter selects an item, but without hiding the menu
752 if ((event->key() == Qt::Key_Enter || event->key() == Qt::Key_Return)
753 && (event->modifiers().testFlag(flag: Qt::ControlModifier) || event->modifiers().testFlag(flag: Qt::AltModifier) || event->modifiers().testFlag(flag: Qt::ShiftModifier)
754 || event->modifiers().testFlag(flag: Qt::MetaModifier))) {
755 m_parentMenu->selectHighlightingSetVisibility(pItem: m_parentMenu->m_list->currentItem(), bHideMenu: false);
756 }
757 // Return/Enter selects an item and hide the menu
758 else if (event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter) {
759 m_parentMenu->selectHighlightingSetVisibility(pItem: m_parentMenu->m_list->currentItem(), bHideMenu: true);
760 } else {
761 QListView::keyPressEvent(event);
762 }
763}
764
765void KateModeMenuListData::SearchLine::keyPressEvent(QKeyEvent *event)
766{
767 if (m_parentMenu->m_list
768 && (event->matches(key: QKeySequence::MoveToNextLine) || event->matches(key: QKeySequence::SelectNextLine) || event->matches(key: QKeySequence::MoveToPreviousLine)
769 || event->matches(key: QKeySequence::SelectPreviousLine) || event->matches(key: QKeySequence::MoveToNextPage) || event->matches(key: QKeySequence::SelectNextPage)
770 || event->matches(key: QKeySequence::MoveToPreviousPage) || event->matches(key: QKeySequence::SelectPreviousPage) || event->key() == Qt::Key_Return
771 || event->key() == Qt::Key_Enter)) {
772 QApplication::sendEvent(receiver: m_parentMenu->m_list, event);
773 } else {
774 QLineEdit::keyPressEvent(event);
775 }
776}
777
778void KateModeMenuListData::SearchLine::init()
779{
780 connect(sender: this, signal: &KateModeMenuListData::SearchLine::textChanged, context: this, slot: &KateModeMenuListData::SearchLine::_k_queueSearch);
781
782 setEnabled(true);
783 setClearButtonEnabled(true);
784}
785
786void KateModeMenuListData::SearchLine::clear()
787{
788 m_queuedSearches = 0;
789 m_bSearchStateAutoScroll = (text().trimmed().isEmpty()) ? false : true;
790 /*
791 * NOTE: This calls "SearchLine::_k_queueSearch()" with an empty string.
792 * The search clearing should be done without delays.
793 */
794 QLineEdit::clear();
795}
796
797void KateModeMenuListData::SearchLine::_k_queueSearch(const QString &s)
798{
799 m_queuedSearches++;
800 m_search = s;
801
802 if (m_search.isEmpty()) {
803 _k_activateSearch(); // Clear search without delay
804 } else {
805 QTimer::singleShot(interval: m_searchDelay, receiver: this, slot: &KateModeMenuListData::SearchLine::_k_activateSearch);
806 }
807}
808
809void KateModeMenuListData::SearchLine::_k_activateSearch()
810{
811 m_queuedSearches--;
812
813 if (m_queuedSearches <= 0) {
814 updateSearch(s: m_search);
815 m_queuedSearches = 0;
816 }
817}
818
819void KateModeMenuListData::SearchLine::updateSearch(const QString &s)
820{
821 if (m_parentMenu->m_emptyListMsg) {
822 m_parentMenu->m_emptyListMsg->hide();
823 }
824 if (m_parentMenu->m_scroll && m_parentMenu->m_scroll->isHidden()) {
825 m_parentMenu->m_scroll->show();
826 }
827
828 KateModeMenuListData::ListView *listView = m_parentMenu->m_list;
829 QStandardItemModel *listModel = m_parentMenu->m_model;
830
831 const QString searchText = (s.isNull() ? text() : s).simplified();
832
833 /*
834 * Clean "Best Search Matches" section, move items to their original places.
835 */
836 if (!listView->isRowHidden(row: 0)) {
837 listView->setRowHidden(row: 0, hide: true);
838 }
839 if (!m_bestResults.isEmpty()) {
840 const int sizeBestResults = m_bestResults.size();
841 for (int i = 0; i < sizeBestResults; ++i) {
842 listModel->takeRow(row: m_bestResults.at(i).first->index().row());
843 listModel->insertRow(arow: m_bestResults.at(i).second + sizeBestResults - i - 1, aitem: m_bestResults.at(i).first);
844 }
845 m_bestResults.clear();
846 }
847
848 /*
849 * Empty search bar.
850 * Show all items and scroll to the selected item or to the first item.
851 */
852 if (searchText.isEmpty() || (searchText.size() == 1 && searchText[0].isSpace())) {
853 for (int i = 1; i < listModel->rowCount(); ++i) {
854 if (listView->isRowHidden(row: i)) {
855 listView->setRowHidden(row: i, hide: false);
856 }
857 }
858
859 // Don't auto-scroll if the search is already clear
860 if (m_bSearchStateAutoScroll) {
861 m_parentMenu->autoScroll();
862 }
863 m_bSearchStateAutoScroll = false;
864 return;
865 }
866
867 /*
868 * Prepare item filter.
869 */
870 int lastItem = -1;
871 int lastSection = -1;
872 int firstSection = -1;
873 bool bEmptySection = true;
874 bool bSectionSeparator = false;
875 bool bSectionName = false;
876 bool bNotShowBestResults = false;
877 bool bSearchExtensions = true;
878 bool bExactMatch = false; // If the search name will not be used
879 /*
880 * It's used for two purposes, it's true if searchText is a
881 * single alphanumeric character or if it starts with a point.
882 * Both cases don't conflict, so a single bool is used.
883 */
884 bool bIsAlphaOrPointExt = false;
885
886 /*
887 * Don't search for extensions if the search text has only one character,
888 * to avoid unwanted results. In this case, the items that start with
889 * that character are displayed.
890 */
891 if (searchText.length() < 2) {
892 bSearchExtensions = false;
893 if (searchText[0].isLetterOrNumber()) {
894 bIsAlphaOrPointExt = true;
895 }
896 }
897 // If the search text has a point at the beginning, match extensions
898 else if (searchText.length() > 1 && searchText[0].toLatin1() == 46) {
899 bIsAlphaOrPointExt = true;
900 bSearchExtensions = true;
901 bExactMatch = true;
902 }
903 // Two characters: search using the normal name of the items
904 else if (searchText.length() == 2) {
905 bExactMatch = true;
906 // if it contains the '*' character, don't match extensions
907 if (searchText[1].toLatin1() == 42 || searchText[0].toLatin1() == 42) {
908 bSearchExtensions = false;
909 }
910 }
911 /*
912 * Don't use the search name if the search text has delimiters.
913 * Don't search in extensions if it contains the '*' character.
914 */
915 else {
916 QString::const_iterator srcText = searchText.constBegin();
917 QString::const_iterator endText = searchText.constEnd();
918
919 for (int it = 0; it < searchText.length() / 2 + searchText.length() % 2; ++it) {
920 --endText;
921 const ushort ucsrc = srcText->unicode();
922 const ushort ucend = endText->unicode();
923
924 // If searchText contains "*"
925 if (ucsrc == 42 || ucend == 42) {
926 bSearchExtensions = false;
927 bExactMatch = true;
928 break;
929 }
930 if (!bExactMatch && (isDelimiter(c: ucsrc) || (ucsrc != ucend && isDelimiter(c: ucend)))) {
931 bExactMatch = true;
932 }
933 ++srcText;
934 }
935 }
936
937 /*
938 * Filter items.
939 */
940 for (int i = 1; i < listModel->rowCount(); ++i) {
941 QString itemName = listModel->item(row: i, column: 0)->text();
942
943 /*
944 * Hide/show the name of the section. If the text of the item
945 * is empty, then it corresponds to the name of the section.
946 */
947 if (itemName.isEmpty()) {
948 listView->setRowHidden(row: i, hide: false);
949
950 if (bSectionSeparator) {
951 bSectionName = true;
952 } else {
953 bSectionSeparator = true;
954 }
955
956 /*
957 * This hides the name of the previous section
958 * (and the separator) if this section has no items.
959 */
960 if (bSectionName && bEmptySection && lastSection > 0) {
961 listView->setRowHidden(row: lastSection, hide: true);
962 listView->setRowHidden(row: lastSection - 1, hide: true);
963 }
964
965 // Find the section name
966 if (bSectionName) {
967 bSectionName = false;
968 bSectionSeparator = false;
969 bEmptySection = true;
970 lastSection = i;
971 }
972 continue;
973 }
974
975 /*
976 * Start filtering items.
977 */
978 KateModeMenuListData::ListItem *item = static_cast<KateModeMenuListData::ListItem *>(listModel->item(row: i, column: 0));
979
980 if (!item->hasMode()) {
981 listView->setRowHidden(row: i, hide: true);
982 continue;
983 }
984 if (item->getSearchName().isEmpty()) {
985 item->generateSearchName(itemName: item->getMode()->translatedName.isEmpty() ? item->getMode()->name : item->getMode()->translatedName);
986 }
987
988 /*
989 * Add item to the "Best Search Matches" section if there is an exact match in the search.
990 * However, if the "exact match" is already the first search result, that section will not
991 * be displayed, as it isn't necessary.
992 */
993 if (!bNotShowBestResults
994 && (item->getSearchName().compare(s: searchText, cs: m_caseSensitivity) == 0
995 || (bExactMatch && item->getMode()->nameTranslated().compare(s: searchText, cs: m_caseSensitivity) == 0))) {
996 if (lastItem == -1) {
997 bNotShowBestResults = true;
998 } else {
999 m_bestResults.append(t: qMakePair(value1&: item, value2&: i));
1000 continue;
1001 }
1002 }
1003
1004 // Only a character is written in the search bar
1005 if (searchText.length() == 1) {
1006 if (bIsAlphaOrPointExt) {
1007 /*
1008 * Add item to the "Best Search Matches" section, if there is a single letter.
1009 * Also look for coincidence in the raw name, some translations use delimiters
1010 * instead of spaces and this can lead to inaccurate results.
1011 */
1012 bool bMatchCharDel = true;
1013 if (item->getMode()->name.startsWith(s: searchText + QLatin1Char(' '), cs: m_caseSensitivity)) {
1014 if (QString(QLatin1Char(' ') + item->getSearchName() + QLatin1Char(' '))
1015 .contains(s: QLatin1Char(' ') + searchText + QLatin1Char(' '), cs: m_caseSensitivity)) {
1016 m_bestResults.append(t: qMakePair(value1&: item, value2&: i));
1017 continue;
1018 } else {
1019 bMatchCharDel = false;
1020 }
1021 }
1022
1023 // CASE 1: All the items that start with that character will be displayed.
1024 if (item->getSearchName().startsWith(s: searchText, cs: m_caseSensitivity)) {
1025 setSearchResult(rowItem: i, bEmptySection, lastSection, firstSection, lastItem);
1026 continue;
1027 }
1028
1029 // CASE 2: Matches considering delimiters. For example, when writing "c",
1030 // "Objective-C" will be displayed in the results, but not "Yacc/Bison".
1031 if (bMatchCharDel
1032 && QString(QLatin1Char(' ') + item->getSearchName() + QLatin1Char(' '))
1033 .contains(s: QLatin1Char(' ') + searchText + QLatin1Char(' '), cs: m_caseSensitivity)) {
1034 setSearchResult(rowItem: i, bEmptySection, lastSection, firstSection, lastItem);
1035 continue;
1036 }
1037 }
1038 // CASE 3: The character isn't a letter or number, do an exact search.
1039 else if (item->getMode()->nameTranslated().contains(c: searchText[0], cs: m_caseSensitivity)) {
1040 setSearchResult(rowItem: i, bEmptySection, lastSection, firstSection, lastItem);
1041 continue;
1042 }
1043 }
1044 // CASE 4: Search text, using the search name or the normal name.
1045 else if (!bExactMatch && item->getSearchName().contains(s: searchText, cs: m_caseSensitivity)) {
1046 setSearchResult(rowItem: i, bEmptySection, lastSection, firstSection, lastItem);
1047 continue;
1048 } else if (bExactMatch && item->getMode()->nameTranslated().contains(s: searchText, cs: m_caseSensitivity)) {
1049 setSearchResult(rowItem: i, bEmptySection, lastSection, firstSection, lastItem);
1050 continue;
1051 }
1052
1053 // CASE 5: Exact matches in extensions.
1054 if (bSearchExtensions) {
1055 if (bIsAlphaOrPointExt && item->matchExtension(text: searchText.mid(position: 1))) {
1056 setSearchResult(rowItem: i, bEmptySection, lastSection, firstSection, lastItem);
1057 continue;
1058 } else if (item->matchExtension(text: searchText)) {
1059 setSearchResult(rowItem: i, bEmptySection, lastSection, firstSection, lastItem);
1060 continue;
1061 }
1062 }
1063
1064 // Item not found, hide
1065 listView->setRowHidden(row: i, hide: true);
1066 }
1067
1068 // Remove last section name, if it's empty.
1069 if (bEmptySection && lastSection > 0 && !listModel->item(row: listModel->rowCount() - 1, column: 0)->text().isEmpty()) {
1070 listView->setRowHidden(row: lastSection, hide: true);
1071 listView->setRowHidden(row: lastSection - 1, hide: true);
1072 }
1073
1074 // Hide the separator line in the name of the first section.
1075 if (m_bestResults.isEmpty()) {
1076 listView->setRowHidden(row: 0, hide: true);
1077 if (firstSection > 0) {
1078 listView->setRowHidden(row: firstSection - 1, hide: true);
1079 }
1080 } else {
1081 /*
1082 * Show "Best Search Matches" section, if there are items.
1083 */
1084
1085 // Show title in singular or plural, depending on the number of items.
1086 QLabel *labelSection = static_cast<QLabel *>(listView->indexWidget(index: listModel->index(row: 0, column: 0)));
1087 if (m_bestResults.size() == 1) {
1088 labelSection->setText(
1089 i18nc("Title (in singular) of the best result in an item search. Please, that the translation doesn't have more than 34 characters, since the "
1090 "menu where it's displayed is small and fixed.",
1091 "Best Search Match"));
1092 } else {
1093 labelSection->setText(
1094 i18nc("Title (in plural) of the best results in an item search. Please, that the translation doesn't have more than 34 characters, since the "
1095 "menu where it's displayed is small and fixed.",
1096 "Best Search Matches"));
1097 }
1098
1099 int heightSectionMargin = m_parentMenu->m_defaultHeightItemSection - labelSection->sizeHint().height();
1100 if (heightSectionMargin < 2) {
1101 heightSectionMargin = 2;
1102 }
1103 int maxWidthText = listView->getContentWidth(overlayScrollbarMargin: 1, classicScrollbarMargin: 3);
1104 // NOTE: labelSection->sizeHint().width() == labelSection->indent() + labelSection->fontMetrics().horizontalAdvance(labelSection->text())
1105 const bool bSectionMultiline = labelSection->sizeHint().width() > maxWidthText;
1106 maxWidthText -= labelSection->indent();
1107 if (!bSectionMultiline) {
1108 listModel->item(row: 0, column: 0)->setSizeHint(QSize(listModel->item(row: 0, column: 0)->sizeHint().width(), labelSection->sizeHint().height() + heightSectionMargin));
1109 listView->setRowHidden(row: 0, hide: false);
1110 }
1111
1112 /*
1113 * Show items in "Best Search Matches" section.
1114 */
1115 int rowModelBestResults = 0; // New position in the model
1116
1117 // Special Case: always show the "R Script" mode first by typing "r" in the search box
1118 if (searchText.length() == 1 && searchText.compare(other: QLatin1String("r"), cs: m_caseSensitivity) == 0) {
1119 for (const QPair<ListItem *, int> &itemBestResults : std::as_const(t&: m_bestResults)) {
1120 listModel->takeRow(row: itemBestResults.second);
1121 ++rowModelBestResults;
1122 if (itemBestResults.first->getMode()->name == QLatin1String("R Script")) {
1123 listModel->insertRow(arow: 1, aitem: itemBestResults.first);
1124 listView->setRowHidden(row: 1, hide: false);
1125 } else {
1126 listModel->insertRow(arow: rowModelBestResults, aitem: itemBestResults.first);
1127 listView->setRowHidden(row: rowModelBestResults, hide: false);
1128 }
1129 }
1130 } else {
1131 // Move items to the "Best Search Matches" section
1132 for (const QPair<ListItem *, int> &itemBestResults : std::as_const(t&: m_bestResults)) {
1133 listModel->takeRow(row: itemBestResults.second);
1134 listModel->insertRow(arow: ++rowModelBestResults, aitem: itemBestResults.first);
1135 listView->setRowHidden(row: rowModelBestResults, hide: false);
1136 }
1137 }
1138 if (lastItem == -1) {
1139 lastItem = rowModelBestResults;
1140 }
1141
1142 // Add word wrap in long section titles.
1143 if (bSectionMultiline) {
1144 if (listView->visualRect(index: listModel->index(row: lastItem, column: 0)).bottom() + labelSection->sizeHint().height() + heightSectionMargin
1145 > listView->geometry().height()
1146 || labelSection->sizeHint().width() > listView->getWidth() - 1) {
1147 labelSection->setText(m_parentMenu->setWordWrap(text: labelSection->text(), maxWidth: maxWidthText, fontMetrics: labelSection->fontMetrics()));
1148 }
1149 listModel->item(row: 0, column: 0)->setSizeHint(QSize(listModel->item(row: 0, column: 0)->sizeHint().width(), labelSection->sizeHint().height() + heightSectionMargin));
1150 listView->setRowHidden(row: 0, hide: false);
1151 }
1152
1153 m_parentMenu->m_list->setCurrentItem(1);
1154 }
1155
1156 listView->scrollToTop();
1157
1158 // Show message of empty list
1159 if (lastItem == -1) {
1160 if (m_parentMenu->m_emptyListMsg == nullptr) {
1161 m_parentMenu->loadEmptyMsg();
1162 }
1163 if (m_parentMenu->m_scroll) {
1164 m_parentMenu->m_scroll->hide();
1165 }
1166 m_parentMenu->m_emptyListMsg->show();
1167 }
1168 // Hide scroll bar if it isn't necessary
1169 else if (m_parentMenu->m_scroll && listView->visualRect(index: listModel->index(row: lastItem, column: 0)).bottom() <= listView->geometry().height()) {
1170 m_parentMenu->m_scroll->hide();
1171 }
1172
1173 m_bSearchStateAutoScroll = true;
1174}
1175
1176void KateModeMenuListData::SearchLine::setSearchResult(const int rowItem, bool &bEmptySection, int &lastSection, int &firstSection, int &lastItem)
1177{
1178 if (lastItem == -1) {
1179 /*
1180 * Detect the first result of the search and "select" it.
1181 * This allows you to scroll through the list using
1182 * the Up/Down keys after entering a search.
1183 */
1184 m_parentMenu->m_list->setCurrentItem(rowItem);
1185
1186 // Position of the first section visible.
1187 if (lastSection > 0) {
1188 firstSection = lastSection;
1189 }
1190 }
1191 if (bEmptySection) {
1192 bEmptySection = false;
1193 }
1194
1195 lastItem = rowItem;
1196 if (m_parentMenu->m_list->isRowHidden(row: rowItem)) {
1197 m_parentMenu->m_list->setRowHidden(row: rowItem, hide: false);
1198 }
1199}
1200
1201void KateModeMenuList::updateMenu(KTextEditor::Document *doc)
1202{
1203 m_doc = static_cast<KTextEditor::DocumentPrivate *>(doc);
1204}
1205

source code of ktexteditor/src/mode/katemodemenulist.cpp