1 /*=========================================================================
2
3 Library: CTK
4
5 Copyright (c) Kitware Inc.
6
7 Licensed under the Apache License, Version 2.0 (the "License");
8 you may not use this file except in compliance with the License.
9 You may obtain a copy of the License at
10
11 http://www.apache.org/licenses/LICENSE-2.0.txt
12
13 Unless required by applicable law or agreed to in writing, software
14 distributed under the License is distributed on an "AS IS" BASIS,
15 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16 See the License for the specific language governing permissions and
17 limitations under the License.
18
19 =========================================================================*/
20
21 // Qt includes
22 #include <QAbstractItemView>
23 #include <QActionEvent>
24 #include <QCompleter>
25 #include <QDebug>
26 #include <QEvent>
27 #include <QHBoxLayout>
28 #include <QLineEdit>
29 #include <QStringList>
30 #include <QStringListModel>
31 #include <QToolButton>
32
33 // CTK includes
34 #include "ctkCompleter.h"
35 #include "ctkSearchBox.h"
36 #include "ctkMenuComboBox_p.h"
37
38 // -------------------------------------------------------------------------
ctkMenuComboBoxInternal()39 ctkMenuComboBoxInternal::ctkMenuComboBoxInternal()
40 {
41 }
42 // -------------------------------------------------------------------------
~ctkMenuComboBoxInternal()43 ctkMenuComboBoxInternal::~ctkMenuComboBoxInternal()
44 {
45 }
46
47 // -------------------------------------------------------------------------
showPopup()48 void ctkMenuComboBoxInternal::showPopup()
49 {
50 QMenu* menu = this->Menu.data();
51 if (!menu)
52 {
53 return;
54 }
55 menu->popup(this->mapToGlobal(this->rect().bottomLeft()));
56 static int minWidth = menu->sizeHint().width();
57 menu->setFixedWidth(qMax(this->width(), minWidth));
58 emit popupShown();
59 }
60
61 // -------------------------------------------------------------------------
minimumSizeHint() const62 QSize ctkMenuComboBoxInternal::minimumSizeHint()const
63 {
64 // Cached QComboBox::minimumSizeHint is not recomputed when the current
65 // index change, however QComboBox::sizeHint is. Use it instead.
66 return this->sizeHint();
67 }
68
69 // -------------------------------------------------------------------------
ctkMenuComboBoxPrivate(ctkMenuComboBox & object)70 ctkMenuComboBoxPrivate::ctkMenuComboBoxPrivate(ctkMenuComboBox& object)
71 :q_ptr(&object)
72 {
73 this->MenuComboBox = 0;
74 this->SearchCompleter = 0;
75 this->EditBehavior = ctkMenuComboBox::NotEditable;
76 this->IsDefaultTextCurrent = true;
77 this->IsDefaultIconCurrent = true;
78 }
79
80 // -------------------------------------------------------------------------
init()81 void ctkMenuComboBoxPrivate::init()
82 {
83 Q_Q(ctkMenuComboBox);
84 this->setParent(q);
85
86 QHBoxLayout* layout = new QHBoxLayout(q);
87 layout->setContentsMargins(0,0,0,0);
88 layout->setSizeConstraint(QLayout::SetMinimumSize);
89 layout->setSpacing(0);
90
91 // SearchButton
92 this->SearchButton = new QToolButton();
93 this->SearchButton->setText(q->tr("Search"));
94 this->SearchButton->setIcon(QIcon(":/Icons/search.svg"));
95 this->SearchButton->setCheckable(true);
96 this->SearchButton->setAutoRaise(true);
97 layout->addWidget(this->SearchButton);
98 q->connect(this->SearchButton, SIGNAL(toggled(bool)),
99 this, SLOT(setComboBoxEditable(bool)));
100
101 // MenuComboBox
102 this->MenuComboBox = new ctkMenuComboBoxInternal();
103 this->MenuComboBox->setMinimumContentsLength(12);
104 layout->addWidget(this->MenuComboBox);
105 this->MenuComboBox->installEventFilter(q);
106 this->MenuComboBox->setInsertPolicy(QComboBox::NoInsert);
107 this->MenuComboBox->setSizeAdjustPolicy(QComboBox::AdjustToContents);
108 this->MenuComboBox->addItem(this->DefaultIcon, this->DefaultText);
109 q->connect(this->MenuComboBox, SIGNAL(popupShown()),
110 q, SIGNAL(popupShown()));
111
112 this->SearchCompleter = new ctkCompleter(QStringList(), this->MenuComboBox);
113 this->SearchCompleter->popup()->setParent(q);
114 this->SearchCompleter->setCaseSensitivity(Qt::CaseInsensitive);
115 this->SearchCompleter->setModelFiltering(ctkCompleter::FilterWordStartsWith);
116 q->connect(this->SearchCompleter, SIGNAL(activated(QString)),
117 this, SLOT(onCompletion(QString)));
118
119 // Automatically set the minimumSizeHint of the layout to the widget
120 layout->setSizeConstraint(QLayout::SetMinimumSize);
121 // Behave like a QComboBox
122 q->setSizePolicy(QSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed,
123 QSizePolicy::ComboBox));
124
125 q->setDefaultText(ctkMenuComboBox::tr("Search..."));
126 }
127
128 // ------------------------------------------------------------------------
actionByTitle(const QString & text,const QMenu * parentMenu)129 QAction* ctkMenuComboBoxPrivate::actionByTitle(const QString& text, const QMenu* parentMenu)
130 {
131 if (!parentMenu || parentMenu->title() == text)
132 {
133 return 0;
134 }
135 foreach(QAction* action, parentMenu->actions())
136 {
137 if (!action->menu() && action->text().toLower() == text.toLower())
138 {
139 return action;
140 }
141 if (action->menu())
142 {
143 QAction* subAction = this->actionByTitle(text, action->menu());
144 if(subAction)
145 {
146 return subAction;
147 }
148 }
149 }
150 return 0;
151 }
152
153 // ------------------------------------------------------------------------
setCurrentText(const QString & newCurrentText)154 void ctkMenuComboBoxPrivate::setCurrentText(const QString& newCurrentText)
155 {
156 if (this->MenuComboBox->lineEdit())
157 {
158 static_cast<ctkSearchBox*>(this->MenuComboBox->lineEdit())
159 ->setPlaceholderText(newCurrentText);
160 }
161
162 this->MenuComboBox->setItemText(this->MenuComboBox->currentIndex(),
163 newCurrentText);
164 }
165
166 // ------------------------------------------------------------------------
currentText() const167 QString ctkMenuComboBoxPrivate::currentText()const
168 {
169 return this->MenuComboBox->itemText(this->MenuComboBox->currentIndex());
170 }
171
172 // ------------------------------------------------------------------------
currentIcon() const173 QIcon ctkMenuComboBoxPrivate::currentIcon()const
174 {
175 return this->MenuComboBox->itemIcon(this->MenuComboBox->currentIndex());
176 }
177
178 // ------------------------------------------------------------------------
setCurrentIcon(const QIcon & newCurrentIcon)179 void ctkMenuComboBoxPrivate::setCurrentIcon(const QIcon& newCurrentIcon)
180 {
181 this->MenuComboBox->setItemIcon(this->MenuComboBox->currentIndex(),
182 newCurrentIcon);
183 }
184
185 // -------------------------------------------------------------------------
setComboBoxEditable(bool edit)186 void ctkMenuComboBoxPrivate::setComboBoxEditable(bool edit)
187 {
188 Q_Q(ctkMenuComboBox);
189 if(edit)
190 {
191 if (!this->MenuComboBox->lineEdit())
192 {
193 ctkSearchBox* line = new ctkSearchBox();
194 this->MenuComboBox->setLineEdit(line);
195 if (q->isSearchIconVisible())
196 {
197 this->MenuComboBox->lineEdit()->selectAll();
198 this->MenuComboBox->setFocus();
199 }
200 q->connect(line, SIGNAL(editingFinished()),
201 q,SLOT(onEditingFinished()));
202 }
203 this->MenuComboBox->setCompleter(this->SearchCompleter);
204 }
205
206 this->MenuComboBox->setEditable(edit);
207 }
208
209 // -------------------------------------------------------------------------
onCompletion(const QString & text)210 void ctkMenuComboBoxPrivate::onCompletion(const QString& text)
211 {
212 Q_Q(ctkMenuComboBox);
213
214 // In Qt5, when QCompleter sends its activated() signal, QComboBox sets
215 // its current index to the activated item, if found. Work around that behavior
216 // by re-selecting the original item.
217 this->MenuComboBox->setCurrentIndex(0);
218
219 // Set text to the completed string
220 if (this->MenuComboBox->lineEdit())
221 {
222 this->MenuComboBox->lineEdit()->setText(text);
223 }
224
225 q->onEditingFinished();
226 }
227
228 // -------------------------------------------------------------------------
addAction(QAction * action)229 void ctkMenuComboBoxPrivate::addAction(QAction *action)
230 {
231 if (action->menu())
232 {
233 this->addMenuToCompleter(action->menu());
234 }
235 else
236 {
237 this->addActionToCompleter(action);
238 }
239 }
240
241 // -------------------------------------------------------------------------
addMenuToCompleter(QMenu * menu)242 void ctkMenuComboBoxPrivate::addMenuToCompleter(QMenu* menu)
243 {
244 Q_Q(ctkMenuComboBox);
245 menu->installEventFilter(q);
246
247 // Bug QT : see this link for more details
248 // https://bugreports.qt.nokia.com/browse/QTBUG-20929?focusedCommentId=161370#comment-161370
249 // if the submenu doesn't have a parent, the submenu triggered(QAction*)
250 // signal is not propagated. So we listened this submenu to fix the bug.
251 QObject* emptyObject = 0;
252 if(menu->parent() == emptyObject)
253 {
254 q->connect(menu, SIGNAL(triggered(QAction*)),
255 q, SLOT(onActionSelected(QAction*)), Qt::UniqueConnection);
256 }
257
258 foreach (QAction* action, menu->actions())
259 {
260 this->addAction(action);
261 }
262 }
263
264 // -------------------------------------------------------------------------
addActionToCompleter(QAction * action)265 void ctkMenuComboBoxPrivate::addActionToCompleter(QAction *action)
266 {
267 QStringListModel* model = qobject_cast<QStringListModel* >(
268 this->SearchCompleter->sourceModel());
269 Q_ASSERT(model);
270 QModelIndex start = model->index(0,0);
271 QModelIndexList indexList = model->match(start, 0, action->text(), 1, Qt::MatchFixedString|Qt::MatchWrap);
272 if (indexList.count())
273 {
274 return;
275 }
276
277 int actionCount = model->rowCount();
278 model->insertRow(actionCount);
279 QModelIndex index = model->index(actionCount, 0);
280 model->setData(index, action->text());
281 }
282
283 // ------------------------------------------------------------------------
removeActionToCompleter(QAction * action)284 void ctkMenuComboBoxPrivate::removeActionToCompleter(QAction *action)
285 {
286 QStringListModel* model = qobject_cast<QStringListModel* >(
287 this->SearchCompleter->sourceModel());
288 Q_ASSERT(model);
289 if (!model->stringList().contains(action->text()) )
290 {
291 return;
292 }
293
294 // Maybe the action is present multiple times in different submenus
295 // Don't remove its entry from the completer model if there are still some action instances
296 // in the menus.
297 if (this->actionByTitle(action->text(), this->Menu.data()))
298 {
299 return;
300 }
301
302 QModelIndex start = model->index(0,0);
303 QModelIndexList indexList = model->match(start, 0, action->text());
304 Q_ASSERT(indexList.count() == 1);
305 foreach (QModelIndex index, indexList)
306 {
307 // Search completer model is a flat list
308 model->removeRow(index.row());
309 }
310 }
311
312 // ------------------------------------------------------------------------
ctkMenuComboBox(QWidget * _parent)313 ctkMenuComboBox::ctkMenuComboBox(QWidget* _parent)
314 :QWidget(_parent)
315 , d_ptr(new ctkMenuComboBoxPrivate(*this))
316 {
317 Q_D(ctkMenuComboBox);
318 d->init();
319 }
320
321 // ------------------------------------------------------------------------
~ctkMenuComboBox()322 ctkMenuComboBox::~ctkMenuComboBox()
323 {
324 }
325
326 // ------------------------------------------------------------------------
setMenu(QMenu * menu)327 void ctkMenuComboBox::setMenu(QMenu* menu)
328 {
329 Q_D(ctkMenuComboBox);
330 if (d->Menu.data() == menu)
331 {
332 return;
333 }
334
335 if (d->Menu)
336 {
337 this->removeAction(d->Menu.data()->menuAction());
338 QObject::disconnect(d->Menu.data(),SIGNAL(triggered(QAction*)),
339 this,SLOT(onActionSelected(QAction*)));
340 }
341
342 d->Menu = menu;
343 d->MenuComboBox->Menu = menu;
344 d->addMenuToCompleter(menu);
345
346 if (d->Menu)
347 {
348 this->addAction(d->Menu.data()->menuAction());
349 QObject::connect(d->Menu.data(),SIGNAL(triggered(QAction*)),
350 this,SLOT(onActionSelected(QAction*)), Qt::UniqueConnection);
351 }
352 }
353
354 // -------------------------------------------------------------------------
menu() const355 QMenu* ctkMenuComboBox::menu()const
356 {
357 Q_D(const ctkMenuComboBox);
358 return d->Menu.data();
359 }
360
361 // -------------------------------------------------------------------------
setDefaultText(const QString & newDefaultText)362 void ctkMenuComboBox::setDefaultText(const QString& newDefaultText)
363 {
364 Q_D(ctkMenuComboBox);
365 d->DefaultText = newDefaultText;
366 if (d->IsDefaultTextCurrent)
367 {
368 d->setCurrentText(d->DefaultText);
369 }
370 }
371
372 // -------------------------------------------------------------------------
defaultText() const373 QString ctkMenuComboBox::defaultText()const
374 {
375 Q_D(const ctkMenuComboBox);
376 return d->DefaultText;
377 }
378
379 // -------------------------------------------------------------------------
setDefaultIcon(const QIcon & newIcon)380 void ctkMenuComboBox::setDefaultIcon(const QIcon& newIcon)
381 {
382 Q_D(ctkMenuComboBox);
383 d->DefaultIcon = newIcon;
384 if (d->IsDefaultIconCurrent)
385 {
386 d->setCurrentIcon(d->DefaultIcon);
387 }
388 }
389
390 // -------------------------------------------------------------------------
defaultIcon() const391 QIcon ctkMenuComboBox::defaultIcon()const
392 {
393 Q_D(const ctkMenuComboBox);
394 return d->DefaultIcon;
395 }
396
397 // -------------------------------------------------------------------------
setEditableBehavior(ctkMenuComboBox::EditableBehavior edit)398 void ctkMenuComboBox::setEditableBehavior(ctkMenuComboBox::EditableBehavior edit)
399 {
400 Q_D(ctkMenuComboBox);
401 d->EditBehavior = edit;
402 this->disconnect(d->MenuComboBox, SIGNAL(popupShown()),
403 d, SLOT(setComboBoxEditable()));
404 switch (edit)
405 {
406 case ctkMenuComboBox::Editable:
407 d->MenuComboBox->setContextMenuPolicy(Qt::DefaultContextMenu);
408 d->setComboBoxEditable(true);
409 break;
410 case ctkMenuComboBox::NotEditable:
411 d->MenuComboBox->setContextMenuPolicy(Qt::DefaultContextMenu);
412 d->setComboBoxEditable(false);
413 break;
414 case ctkMenuComboBox::EditableOnFocus:
415 d->setComboBoxEditable(this->hasFocus());
416 // Here we set the context menu policy to fix a crash on the right click.
417 // Opening the context menu removes the focus on the line edit,
418 // the comboBox becomes not editable, and the line edit is deleted.
419 // The opening of the context menu is done in the line edit and lead to
420 // a crash because it infers that the line edit is valid. Another fix
421 // could be to delete the line edit later (deleteLater()).
422 d->MenuComboBox->setContextMenuPolicy(Qt::NoContextMenu);
423 break;
424 case ctkMenuComboBox::EditableOnPopup:
425 d->setComboBoxEditable(false);
426 this->connect(d->MenuComboBox, SIGNAL(popupShown()),
427 d, SLOT(setComboBoxEditable()));
428 // Same reason as in ctkMenuComboBox::EditableOnFocus.
429 d->MenuComboBox->setContextMenuPolicy(Qt::NoContextMenu);
430 break;
431 }
432 }
433
434 // -------------------------------------------------------------------------
editableBehavior() const435 ctkMenuComboBox::EditableBehavior ctkMenuComboBox::editableBehavior()const
436 {
437 Q_D(const ctkMenuComboBox);
438 return d->EditBehavior;
439 }
440
441 // -------------------------------------------------------------------------
setSearchIconVisible(bool state)442 void ctkMenuComboBox::setSearchIconVisible(bool state)
443 {
444 Q_D(ctkMenuComboBox);
445 d->SearchButton->setVisible(state);
446 }
447
448 // -------------------------------------------------------------------------
isSearchIconVisible() const449 bool ctkMenuComboBox::isSearchIconVisible() const
450 {
451 Q_D(const ctkMenuComboBox);
452 return d->SearchButton->isVisibleTo(const_cast<ctkMenuComboBox*>(this));
453 }
454
455 // -------------------------------------------------------------------------
setToolButtonStyle(Qt::ToolButtonStyle style)456 void ctkMenuComboBox::setToolButtonStyle(Qt::ToolButtonStyle style)
457 {
458 Q_D(ctkMenuComboBox);
459 d->SearchButton->setToolButtonStyle(style);
460 }
461
462 // -------------------------------------------------------------------------
toolButtonStyle() const463 Qt::ToolButtonStyle ctkMenuComboBox::toolButtonStyle() const
464 {
465 Q_D(const ctkMenuComboBox);
466 return d->SearchButton->toolButtonStyle();
467 }
468 // -------------------------------------------------------------------------
setMinimumContentsLength(int characters)469 void ctkMenuComboBox::setMinimumContentsLength(int characters)
470 {
471 Q_D(ctkMenuComboBox);
472 d->MenuComboBox->setMinimumContentsLength(characters);
473 }
474
475 // -------------------------------------------------------------------------
menuComboBoxInternal() const476 QComboBox* ctkMenuComboBox::menuComboBoxInternal() const
477 {
478 Q_D(const ctkMenuComboBox);
479 return d->MenuComboBox;
480 }
481
482 // -------------------------------------------------------------------------
toolButtonInternal() const483 QToolButton* ctkMenuComboBox::toolButtonInternal() const
484 {
485 Q_D(const ctkMenuComboBox);
486 return d->SearchButton;
487 }
488
489 // -------------------------------------------------------------------------
searchCompleter() const490 ctkCompleter* ctkMenuComboBox::searchCompleter() const
491 {
492 Q_D(const ctkMenuComboBox);
493 return d->SearchCompleter;
494 }
495
496 // -------------------------------------------------------------------------
onActionSelected(QAction * action)497 void ctkMenuComboBox::onActionSelected(QAction* action)
498 {
499 Q_D(ctkMenuComboBox);
500 /// Set the action selected in the combobox.
501
502 d->IsDefaultTextCurrent = true;
503 QString newText = d->DefaultText;
504 if (action && !action->text().isEmpty())
505 {
506 newText = action->text();
507 d->IsDefaultTextCurrent = false;
508 }
509 d->setCurrentText(newText);
510
511 d->IsDefaultIconCurrent = true;
512 QIcon newIcon = d->DefaultIcon;
513 if (action && !action->icon().isNull())
514 {
515 d->IsDefaultIconCurrent = false;
516 newIcon = action->icon();
517 }
518 d->setCurrentIcon(newIcon);
519
520 d->MenuComboBox->clearFocus();
521
522 emit ctkMenuComboBox::actionChanged(action);
523 }
524
525 // -------------------------------------------------------------------------
clearActiveAction()526 void ctkMenuComboBox::clearActiveAction()
527 {
528 this->onActionSelected(0);
529 }
530
531 // -------------------------------------------------------------------------
onEditingFinished()532 void ctkMenuComboBox::onEditingFinished()
533 {
534 Q_D(ctkMenuComboBox);
535 if (!d->MenuComboBox->lineEdit())
536 {
537 return;
538 }
539 QAction* action = d->actionByTitle(d->MenuComboBox->lineEdit()->text(), d->Menu.data());
540 if (!action)
541 {
542 return;
543 }
544 if (this->isSearchIconVisible())
545 {
546 d->SearchButton->setChecked(false);
547 }
548
549 action->trigger();
550 }
551
552 // -------------------------------------------------------------------------
eventFilter(QObject * target,QEvent * event)553 bool ctkMenuComboBox::eventFilter(QObject* target, QEvent* event)
554 {
555 Q_D(ctkMenuComboBox);
556
557 if (target == d->MenuComboBox)
558 {
559 if (event->type() == QEvent::Resize)
560 {
561 this->layout()->invalidate();
562 }
563 if (event->type() == QEvent::FocusIn &&
564 d->EditBehavior == ctkMenuComboBox::EditableOnFocus)
565 {
566 d->setComboBoxEditable(true);
567 }
568 if (event->type() == QEvent::FocusOut &&
569 (d->EditBehavior == ctkMenuComboBox::EditableOnFocus ||
570 d->EditBehavior == ctkMenuComboBox::EditableOnPopup))
571 {
572 d->setComboBoxEditable(false);
573 }
574 }
575 else if (event->type() == QEvent::ActionAdded)
576 {
577 QActionEvent* actionEvent = static_cast<QActionEvent *>(event);
578 d->addAction(actionEvent->action());
579 }
580 else if (event->type() == QEvent::ActionRemoved)
581 {
582 QActionEvent* actionEvent = static_cast<QActionEvent *>(event);
583 d->removeActionToCompleter(actionEvent->action());
584 }
585 return this->Superclass::eventFilter(target, event);
586 }
587