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