]> git.lyx.org Git - lyx.git/blob - src/frontends/qt/CategorizedCombo.cpp
81cf89ab617d98c9bb5839a20a6cc991b3dfc1b2
[lyx.git] / src / frontends / qt / CategorizedCombo.cpp
1 /**
2  * \file qt/CategorizedCombo.cpp
3  * This file is part of LyX, the document processor.
4  * Licence details can be found in the file COPYING.
5  *
6  * \author Lars Gullik Bjønnes
7  * \author John Levon
8  * \author Jean-Marc Lasgouttes
9  * \author Angus Leeming
10  * \author Stefan Schimanski
11  * \author Jürgen Spitzmüller
12  * \author Abdelrazak Younes
13  *
14  * Full author contact details are available in file CREDITS.
15  */
16
17 #include <config.h>
18
19 #include "CategorizedCombo.h"
20
21 #include "qt_helpers.h"
22
23 #include "support/debug.h"
24 #include "support/gettext.h"
25 #include "support/lassert.h"
26 #include "support/lstrings.h"
27 #include "support/qstring_helpers.h"
28
29 #include <QAbstractTextDocumentLayout>
30 #include <QComboBox>
31 #include <QHeaderView>
32 #include <QItemDelegate>
33 #include <QPainter>
34 #include <QSortFilterProxyModel>
35 #include <QStandardItemModel>
36 #include <QTextFrame>
37 #if QT_VERSION >= 0x060000
38 #include <QtCore5Compat/QRegExp>
39 #endif
40
41 using namespace lyx::support;
42
43 namespace lyx {
44 namespace frontend {
45
46
47 class CCItemDelegate : public QItemDelegate {
48 public:
49         ///
50         explicit CCItemDelegate(CategorizedCombo * cc)
51                 : QItemDelegate(cc), cc_(cc)
52         {}
53         ///
54         void paint(QPainter * painter, QStyleOptionViewItem const & option,
55                 QModelIndex const & index) const override;
56         ///
57         void drawDisplay(QPainter * painter, QStyleOptionViewItem const & opt,
58                 const QRect & /*rect*/, const QString & text ) const override;
59         ///
60         QSize sizeHint(QStyleOptionViewItem const & opt,
61                 QModelIndex const & index) const override;
62
63 private:
64         ///
65         void drawCategoryHeader(QPainter * painter, QStyleOptionViewItem const & opt,
66                 QString const & category) const;
67         ///
68         QString underlineFilter(QString const & s) const;
69         ///
70         CategorizedCombo * cc_;
71 };
72
73
74 class CCFilterModel : public QSortFilterProxyModel {
75 public:
76         ///
77         CCFilterModel(QObject * parent = nullptr)
78                 : QSortFilterProxyModel(parent)
79         {}
80 };
81
82
83 /////////////////////////////////////////////////////////////////////
84 //
85 // CategorizedCombo::Private
86 //
87 /////////////////////////////////////////////////////////////////////
88
89 struct CategorizedCombo::Private
90 {
91         Private(CategorizedCombo * parent) : p(parent),
92                 // set the model with four columns
93                 // 1st: translated item names
94                 // 2nd: raw names
95                 // 3rd: category
96                 // 4th: availability (bool)
97                 model_(new QStandardItemModel(0, 4, p)),
98                 filterModel_(new CCFilterModel(p)),
99                 lastSel_(-1),
100                 CCItemDelegate_(new CCItemDelegate(parent)),
101                 visibleCategories_(0), inShowPopup_(false)
102         {
103                 filterModel_->setSourceModel(model_);
104         }
105
106         void resetFilter() { setFilter(QString()); }
107         ///
108         void setFilter(QString const & s);
109         ///
110         void countCategories();
111         ///
112         CategorizedCombo * p;
113
114         /** the layout model:
115          * 1st column: translated GUI name,
116          * 2nd column: raw item name,
117          * 3rd column: category,
118          * 4th column: availability
119         **/
120         QStandardItemModel * model_;
121         /// the proxy model filtering \c model_
122         CCFilterModel * filterModel_;
123         /// the (model-) index of the last successful selection
124         int lastSel_;
125         /// the character filter
126         QString filter_;
127         ///
128         CCItemDelegate * CCItemDelegate_;
129         ///
130         unsigned visibleCategories_;
131         ///
132         bool inShowPopup_;
133 };
134
135
136 static QString categoryCC(QAbstractItemModel const & model, int row)
137 {
138         return model.data(model.index(row, 2), Qt::DisplayRole).toString();
139 }
140
141
142 static int headerHeightCC(QStyleOptionViewItem const & opt)
143 {
144         return opt.fontMetrics.height();
145 }
146
147
148 void CCItemDelegate::paint(QPainter * painter, QStyleOptionViewItem const & option,
149                            QModelIndex const & index) const
150 {
151         QStyleOptionViewItem opt = option;
152
153         // default background
154         painter->fillRect(opt.rect, opt.palette.color(QPalette::Base));
155
156         QString cat = categoryCC(*index.model(), index.row());
157
158         // not the same as in the previous line?
159         if (cc_->d->visibleCategories_ > 0
160             && (index.row() == 0 || cat != categoryCC(*index.model(), index.row() - 1))) {
161                 painter->save();
162
163                 // draw unselected background
164                 QStyle::State state = opt.state;
165                 opt.state = opt.state & ~QStyle::State_Selected;
166                 drawBackground(painter, opt, index);
167                 opt.state = state;
168
169                 // draw category header
170                 drawCategoryHeader(painter, opt,
171                         categoryCC(*index.model(), index.row()));
172
173                 // move rect down below header
174                 opt.rect.setTop(opt.rect.top() + headerHeightCC(opt));
175
176                 painter->restore();
177         }
178
179         QItemDelegate::paint(painter, opt, index);
180 }
181
182
183 void CCItemDelegate::drawDisplay(QPainter * painter, QStyleOptionViewItem const & opt,
184                                  const QRect & /*rect*/, const QString & text) const
185 {
186         QString utext = underlineFilter(text);
187
188         // Draw the rich text.
189         painter->save();
190         QColor col = opt.palette.text().color();
191         // grey out unavailable items
192         if (text.startsWith(qt_("Unavailable:")))
193                 col = opt.palette.color(QPalette::Disabled, QPalette::Text);
194         if (opt.state & QStyle::State_Selected)
195                 col = opt.palette.highlightedText().color();
196         QAbstractTextDocumentLayout::PaintContext context;
197         context.palette.setColor(QPalette::Text, col);
198
199         QTextDocument doc;
200         doc.setDefaultFont(opt.font);
201         doc.setHtml(utext);
202
203         QTextFrameFormat fmt = doc.rootFrame()->frameFormat();
204         fmt.setMargin(0);
205         doc.rootFrame()->setFrameFormat(fmt);
206
207         painter->translate(opt.rect.x() + 5,
208                 opt.rect.y() + (opt.rect.height() - opt.fontMetrics.height()) / 2);
209         doc.documentLayout()->draw(painter, context);
210         painter->restore();
211 }
212
213
214 QSize CCItemDelegate::sizeHint(QStyleOptionViewItem const & opt,
215                                QModelIndex const & index) const
216 {
217         QSize size = QItemDelegate::sizeHint(opt, index);
218
219         /// QComboBox uses the first row height to estimate the
220         /// complete popup height during QComboBox::showPopup().
221         /// To avoid scrolling we have to sneak in space for the headers.
222         /// So we tweak this value accordingly. It's not nice, but the
223         /// only possible way it seems.
224         // Add space for the category headers here
225         QString cat = categoryCC(*index.model(), index.row());
226         if (index.row() == 0 || cat != categoryCC(*index.model(), index.row() - 1)) {
227                 size.setHeight(size.height() + headerHeightCC(opt));
228         }
229
230         return size;
231 }
232
233
234 void CCItemDelegate::drawCategoryHeader(QPainter * painter, QStyleOptionViewItem const & opt,
235                                         QString const & category) const
236 {
237         // slightly blended color
238         QColor lcol = opt.palette.text().color();
239         lcol.setAlpha(127);
240         painter->setPen(lcol);
241
242         // set 80% scaled, bold font
243         QFont font = opt.font;
244         font.setBold(true);
245         font.setWeight(QFont::Black);
246         font.setPointSize(opt.font.pointSize() * 8 / 10);
247         painter->setFont(font);
248
249         // draw the centered text
250         QFontMetrics fm(font);
251         int w = fm.boundingRect(category).width();
252         int x = opt.rect.x() + (opt.rect.width() - w) / 2;
253         int y = opt.rect.y() + 3 * fm.ascent() / 2;
254         int left = x;
255         int right = x + w;
256         painter->drawText(x, y, category);
257
258         // the vertical position of the line: middle of lower case chars
259         int ymid = y - 1 - fm.xHeight() / 2; // -1 for the baseline
260
261         // draw the horizontal line
262         if (!category.isEmpty()) {
263                 painter->drawLine(opt.rect.x(), ymid, left - 1, ymid);
264                 painter->drawLine(right + 1, ymid, opt.rect.right(), ymid);
265         } else
266                 painter->drawLine(opt.rect.x(), ymid, opt.rect.right(), ymid);
267 }
268
269
270 QString CCItemDelegate::underlineFilter(QString const & s) const
271 {
272         QString const & f = cc_->filter();
273         if (f.isEmpty())
274                 return s;
275         QString r(s);
276 #if QT_VERSION < 0x060000
277         QRegExp pattern(charFilterRegExpC(f));
278 #else
279         QRegularExpression pattern(charFilterRegExpC(f));
280 #endif
281         r.replace(pattern, "<u><b>\\1</b></u>");
282         return r;
283 }
284
285
286 void CategorizedCombo::Private::setFilter(QString const & s)
287 {
288         bool enabled = p->view()->updatesEnabled();
289         p->view()->setUpdatesEnabled(false);
290
291         // remember old selection
292         int sel = p->currentIndex();
293         if (sel != -1)
294                 lastSel_ = filterModel_->mapToSource(filterModel_->index(sel, 0)).row();
295
296         filter_ = s;
297 #if QT_VERSION < 0x060000
298         filterModel_->setFilterRegExp(charFilterRegExp(filter_));
299 #else
300         filterModel_->setFilterRegularExpression(charFilterRegExp(filter_));
301 #endif
302         countCategories();
303
304         // restore old selection
305         if (lastSel_ != -1) {
306                 QModelIndex i = filterModel_->mapFromSource(model_->index(lastSel_, 0));
307                 if (i.isValid())
308                         p->setCurrentIndex(i.row());
309         }
310
311         // Workaround to resize to content size
312         // FIXME: There must be a better way. The QComboBox::AdjustToContents)
313         //        does not help.
314         if (p->view()->isVisible()) {
315                 // call QComboBox::showPopup. But set the inShowPopup_ flag to switch on
316                 // the hack in the item delegate to make space for the headers.
317                 // We do not call our implementation of showPopup because that
318                 // would reset the filter again. This is only needed if the user clicks
319                 // on the QComboBox.
320                 LASSERT(!inShowPopup_, /**/);
321                 inShowPopup_ = true;
322                 p->QComboBox::showPopup();
323                 inShowPopup_ = false;
324         }
325
326         p->view()->setUpdatesEnabled(enabled);
327 }
328
329
330 CategorizedCombo::CategorizedCombo(QWidget * parent)
331         : QComboBox(parent), d(new Private(this))
332 {
333         setSizeAdjustPolicy(QComboBox::AdjustToContents);
334         setMinimumWidth(sizeHint().width());
335         setMaxVisibleItems(100);
336
337         setModel(d->filterModel_);
338
339         // for the filtering we have to intercept characters
340         view()->installEventFilter(this);
341         view()->setItemDelegateForColumn(0, d->CCItemDelegate_);
342
343         updateCombo();
344 }
345
346
347 CategorizedCombo::~CategorizedCombo() {
348         delete d;
349 }
350
351
352 void CategorizedCombo::Private::countCategories()
353 {
354         int n = filterModel_->rowCount();
355         visibleCategories_ = 0;
356         if (n == 0)
357                 return;
358
359         QString prevCat = model_->index(0, 2).data().toString();
360
361         // count categories
362         for (int i = 1; i < n; ++i) {
363                 QString cat = filterModel_->index(i, 2).data().toString();
364                 if (cat != prevCat)
365                         ++visibleCategories_;
366                 prevCat = cat;
367         }
368 }
369
370
371 void CategorizedCombo::showPopup()
372 {
373         lastCurrentIndex_ = currentIndex();
374         bool enabled = view()->updatesEnabled();
375         view()->setUpdatesEnabled(false);
376
377         d->resetFilter();
378
379         // call QComboBox::showPopup. But set the inShowPopup_ flag to switch on
380         // the hack in the item delegate to make space for the headers.
381         LASSERT(!d->inShowPopup_, /**/);
382         d->inShowPopup_ = true;
383         QComboBox::showPopup();
384         d->inShowPopup_ = false;
385
386         view()->setUpdatesEnabled(enabled);
387 }
388
389
390 bool CategorizedCombo::eventFilter(QObject * o, QEvent * e)
391 {
392         if (e->type() != QEvent::KeyPress)
393                 return QComboBox::eventFilter(o, e);
394
395         QKeyEvent * ke = static_cast<QKeyEvent*>(e);
396         bool modified = (ke->modifiers() == Qt::ControlModifier)
397                 || (ke->modifiers() == Qt::AltModifier)
398                 || (ke->modifiers() == Qt::MetaModifier);
399
400         switch (ke->key()) {
401         case Qt::Key_Escape:
402                 if (!modified && !d->filter_.isEmpty()) {
403                         d->resetFilter();
404                         setCurrentIndex(lastCurrentIndex_);
405                         return true;
406                 }
407                 break;
408         case Qt::Key_Backspace:
409                 if (!modified) {
410                         // cut off one character
411                         d->setFilter(d->filter_.left(d->filter_.length() - 1));
412                 }
413                 break;
414         default:
415                 if (modified || ke->text().isEmpty())
416                         break;
417                 // find chars for the filter string
418                 QString s;
419                 for (int i = 0; i < ke->text().length(); ++i) {
420                         QChar c = ke->text()[i];
421                         if (c.isLetterOrNumber()
422                             || c.isSymbol()
423                             || c.isPunct()
424                             || c.category() == QChar::Separator_Space) {
425                                 s += c;
426                         }
427                 }
428                 if (!s.isEmpty()) {
429                         // append new chars to the filter string
430                         d->setFilter(d->filter_ + s);
431                         return true;
432                 }
433                 break;
434         }
435
436         return QComboBox::eventFilter(o, e);
437 }
438
439
440 void CategorizedCombo::setIconSize(QSize size)
441 {
442 #ifdef Q_OS_MAC
443         bool small = size.height() < 20;
444         setAttribute(Qt::WA_MacSmallSize, small);
445         setAttribute(Qt::WA_MacNormalSize, !small);
446 #else
447         (void)size; // suppress warning
448 #endif
449 }
450
451
452 bool CategorizedCombo::set(QString const & item, bool const report_missing)
453 {
454         d->resetFilter();
455
456         int const curItem = currentIndex();
457         QModelIndex const mindex =
458                 d->filterModel_->mapToSource(d->filterModel_->index(curItem, 1));
459         QString const & currentItem = d->model_->itemFromIndex(mindex)->text();
460         if (item == currentItem) {
461                 LYXERR(Debug::GUI, "Already had " << item << " selected.");
462                 return true;
463         }
464
465         QList<QStandardItem *> r = d->model_->findItems(item, Qt::MatchExactly, 1);
466         if (r.empty()) {
467                 if (report_missing)
468                         LYXERR0("Trying to select non existent layout type " << item);
469                 return false;
470         }
471
472         setCurrentIndex(d->filterModel_->mapFromSource(r.first()->index()).row());
473         return true;
474 }
475
476
477 void CategorizedCombo::addItemSort(QString const & item, QString const & guiname,
478                                    QString const & category, QString const & tooltip,
479                                    bool sorted, bool sortedByCat, bool sortCats,
480                                    bool available, bool nocategories)
481 {
482         QString titem = available ? guiname
483                                   : toqstr(bformat(_("Unavailable: %1$s"),
484                                                    qstring_to_ucs4(guiname)));
485         bool const uncategorized = category.isEmpty();
486         QString qcat = (uncategorized && !nocategories) ? qt_("Uncategorized") : category;
487
488         QList<QStandardItem *> row;
489         QStandardItem * gui = new QStandardItem(titem);
490         if (!tooltip.isEmpty())
491                 gui->setToolTip(tooltip);
492         row.append(gui);
493         row.append(new QStandardItem(item));
494         row.append(new QStandardItem(qcat));
495         row.append(new QStandardItem(available));
496
497         // the first entry is easy
498         int const end = d->model_->rowCount();
499         if (end == 0) {
500                 d->model_->appendRow(row);
501                 return;
502         }
503
504         // find category
505         int i = 0;
506         if (sortedByCat) {
507                 // If sortCats == true, sort categories alphabetically, uncategorized at the end.
508                 while (i < end && d->model_->item(i, 2)->text() != qcat
509                        && (!sortCats
510                            || (!uncategorized && d->model_->item(i, 2)->text().localeAwareCompare(qcat) < 0
511                                && d->model_->item(i, 2)->text() != qt_("Uncategorized"))
512                            || (uncategorized && d->model_->item(i, 2)->text() != qt_("Uncategorized"))))
513                         ++i;
514         }
515
516         // the simple unsorted case
517         if (!sorted) {
518                 if (sortedByCat) {
519                         // jump to the end of the category group
520                         while (i < end && d->model_->item(i, 2)->text() == qcat)
521                                 ++i;
522                         d->model_->insertRow(i, row);
523                 } else
524                         d->model_->appendRow(row);
525                 return;
526         }
527
528         // find row to insert the item, after the separator if it exists
529         if (i < end) {
530                 // find alphabetic position, unavailable at the end
531                 while (i != end
532                        && ((available && d->model_->item(i, 0)->text().localeAwareCompare(titem) < 0)
533                            || ((!available && d->model_->item(i, 3))
534                                || d->model_->item(i, 0)->text().localeAwareCompare(titem) < 0))
535                        && (!sortedByCat || d->model_->item(i, 2)->text() == qcat))
536                         ++i;
537         }
538
539         d->model_->insertRow(i, row);
540 }
541
542
543 QString CategorizedCombo::getData(int row) const
544 {
545         int srow = d->filterModel_->mapToSource(d->filterModel_->index(row, 1)).row();
546         return d->model_->data(d->model_->index(srow, 1), Qt::DisplayRole).toString();
547 }
548
549
550 void CategorizedCombo::reset()
551 {
552         d->resetFilter();
553         d->model_->clear();
554 }
555
556 void CategorizedCombo::resetFilter()
557 {
558         d->resetFilter();
559 }
560
561
562 void CategorizedCombo::updateCombo()
563 {
564         d->countCategories();
565
566         // needed to recalculate size hint
567         hide();
568         setMinimumWidth(sizeHint().width());
569         show();
570 }
571
572
573 QString const & CategorizedCombo::filter() const
574 {
575         return d->filter_;
576 }
577
578 } // namespace frontend
579 } // namespace lyx
580
581
582 #include "moc_CategorizedCombo.cpp"