1 /*
2     This file is part of the KDE project
3     SPDX-FileCopyrightText: 2015 David Edmundson <davidedmundson@kde.org>
4 
5     SPDX-License-Identifier: LGPL-2.0-or-later
6 */
7 
8 #include "kcollapsiblegroupbox.h"
9 
10 #include <QLabel>
11 #include <QLayout>
12 #include <QMouseEvent>
13 #include <QPainter>
14 #include <QStyle>
15 #include <QStyleOption>
16 #include <QTimeLine>
17 
18 class KCollapsibleGroupBoxPrivate
19 {
20 public:
21     KCollapsibleGroupBoxPrivate(KCollapsibleGroupBox *qq);
22     void updateChildrenFocus(bool expanded);
23     void recalculateHeaderSize();
24     QSize contentSize() const;
25     QSize contentMinimumSize() const;
26 
27     KCollapsibleGroupBox *const q;
28     QTimeLine *animation;
29     QString title;
30     bool isExpanded = false;
31     bool headerContainsMouse = false;
32     QSize headerSize;
33     int shortcutId = 0;
34     QMap<QWidget *, Qt::FocusPolicy> focusMap; // Used to restore focus policy of widgets.
35 };
36 
KCollapsibleGroupBoxPrivate(KCollapsibleGroupBox * qq)37 KCollapsibleGroupBoxPrivate::KCollapsibleGroupBoxPrivate(KCollapsibleGroupBox *qq)
38     : q(qq)
39 {
40 }
41 
KCollapsibleGroupBox(QWidget * parent)42 KCollapsibleGroupBox::KCollapsibleGroupBox(QWidget *parent)
43     : QWidget(parent)
44     , d(new KCollapsibleGroupBoxPrivate(this))
45 {
46     d->recalculateHeaderSize();
47 
48     d->animation = new QTimeLine(500, this); // duration matches kmessagewidget
49     connect(d->animation, &QTimeLine::valueChanged, this, [this](qreal value) {
50         setFixedHeight((d->contentSize().height() * value) + d->headerSize.height());
51     });
52     connect(d->animation, &QTimeLine::stateChanged, this, [this](QTimeLine::State state) {
53         if (state == QTimeLine::NotRunning) {
54             d->updateChildrenFocus(d->isExpanded);
55         }
56     });
57 
58     setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Fixed);
59     setFocusPolicy(Qt::TabFocus);
60     setMouseTracking(true);
61 }
62 
~KCollapsibleGroupBox()63 KCollapsibleGroupBox::~KCollapsibleGroupBox()
64 {
65     if (d->animation->state() == QTimeLine::Running) {
66         d->animation->stop();
67     }
68 }
69 
setTitle(const QString & title)70 void KCollapsibleGroupBox::setTitle(const QString &title)
71 {
72     d->title = title;
73     d->recalculateHeaderSize();
74 
75     update();
76     updateGeometry();
77 
78     if (d->shortcutId) {
79         releaseShortcut(d->shortcutId);
80     }
81 
82     d->shortcutId = grabShortcut(QKeySequence::mnemonic(title));
83 
84 #ifndef QT_NO_ACCESSIBILITY
85     setAccessibleName(title);
86 #endif
87 
88     Q_EMIT titleChanged();
89 }
90 
title() const91 QString KCollapsibleGroupBox::title() const
92 {
93     return d->title;
94 }
95 
setExpanded(bool expanded)96 void KCollapsibleGroupBox::setExpanded(bool expanded)
97 {
98     if (expanded == d->isExpanded) {
99         return;
100     }
101 
102     d->isExpanded = expanded;
103     Q_EMIT expandedChanged();
104 
105     d->updateChildrenFocus(expanded);
106 
107     d->animation->setDirection(expanded ? QTimeLine::Forward : QTimeLine::Backward);
108     // QTimeLine::duration() must be > 0
109     const int duration = qMax(1, style()->styleHint(QStyle::SH_Widget_Animation_Duration));
110     d->animation->stop();
111     d->animation->setDuration(duration);
112     d->animation->start();
113 
114     // when going from collapsed to expanded changing the child visibility calls an updateGeometry
115     // which calls sizeHint with expanded true before the first frame of the animation kicks in
116     // trigger an effective frame 0
117     if (expanded) {
118         setFixedHeight(d->headerSize.height());
119     }
120 }
121 
isExpanded() const122 bool KCollapsibleGroupBox::isExpanded() const
123 {
124     return d->isExpanded;
125 }
126 
collapse()127 void KCollapsibleGroupBox::collapse()
128 {
129     setExpanded(false);
130 }
131 
expand()132 void KCollapsibleGroupBox::expand()
133 {
134     setExpanded(true);
135 }
136 
toggle()137 void KCollapsibleGroupBox::toggle()
138 {
139     setExpanded(!d->isExpanded);
140 }
141 
paintEvent(QPaintEvent * event)142 void KCollapsibleGroupBox::paintEvent(QPaintEvent *event)
143 {
144     QPainter p(this);
145 
146     QStyleOptionButton baseOption;
147     baseOption.initFrom(this);
148     baseOption.rect = QRect(0, 0, width(), d->headerSize.height());
149     baseOption.text = d->title;
150 
151     if (d->headerContainsMouse) {
152         baseOption.state |= QStyle::State_MouseOver;
153     }
154 
155     QStyle::PrimitiveElement element;
156     if (d->isExpanded) {
157         element = QStyle::PE_IndicatorArrowDown;
158     } else {
159         element = isLeftToRight() ? QStyle::PE_IndicatorArrowRight : QStyle::PE_IndicatorArrowLeft;
160     }
161 
162     QStyleOptionButton indicatorOption = baseOption;
163     indicatorOption.rect = style()->subElementRect(QStyle::SE_CheckBoxIndicator, &indicatorOption, this);
164     style()->drawPrimitive(element, &indicatorOption, &p, this);
165 
166     QStyleOptionButton labelOption = baseOption;
167     labelOption.rect = style()->subElementRect(QStyle::SE_CheckBoxContents, &labelOption, this);
168     style()->drawControl(QStyle::CE_CheckBoxLabel, &labelOption, &p, this);
169 
170     Q_UNUSED(event)
171 }
172 
sizeHint() const173 QSize KCollapsibleGroupBox::sizeHint() const
174 {
175     if (d->isExpanded) {
176         return d->contentSize() + QSize(0, d->headerSize.height());
177     } else {
178         return QSize(d->contentSize().width(), d->headerSize.height());
179     }
180 }
181 
minimumSizeHint() const182 QSize KCollapsibleGroupBox::minimumSizeHint() const
183 {
184     int minimumWidth = qMax(d->contentSize().width(), d->headerSize.width());
185     return QSize(minimumWidth, d->headerSize.height());
186 }
187 
event(QEvent * event)188 bool KCollapsibleGroupBox::event(QEvent *event)
189 {
190     switch (event->type()) {
191     case QEvent::StyleChange:
192         /*fall through*/
193     case QEvent::FontChange:
194         d->recalculateHeaderSize();
195         break;
196     case QEvent::Shortcut: {
197         QShortcutEvent *se = static_cast<QShortcutEvent *>(event);
198         if (d->shortcutId == se->shortcutId()) {
199             toggle();
200             return true;
201         }
202         break;
203     }
204     case QEvent::ChildAdded: {
205         QChildEvent *ce = static_cast<QChildEvent *>(event);
206         if (ce->child()->isWidgetType()) {
207             auto widget = static_cast<QWidget *>(ce->child());
208             // Needs to be called asynchronously because at this point the widget is likely a "real" QWidget,
209             // i.e. the QWidget base class whose constructor sets the focus policy to NoPolicy.
210             // But the constructor of the child class (not yet called) could set a different focus policy later.
211             QMetaObject::invokeMethod(this, "overrideFocusPolicyOf", Qt::QueuedConnection, Q_ARG(QWidget *, widget));
212         }
213         break;
214     }
215     case QEvent::LayoutRequest:
216         if (d->animation->state() == QTimeLine::NotRunning) {
217             setFixedHeight(sizeHint().height());
218         }
219         break;
220     default:
221         break;
222     }
223 
224     return QWidget::event(event);
225 }
226 
mousePressEvent(QMouseEvent * event)227 void KCollapsibleGroupBox::mousePressEvent(QMouseEvent *event)
228 {
229     const QRect headerRect(0, 0, width(), d->headerSize.height());
230     if (headerRect.contains(event->pos())) {
231         toggle();
232     }
233     event->setAccepted(true);
234 }
235 
236 // if mouse has changed whether it is in the top bar or not refresh to change arrow icon
mouseMoveEvent(QMouseEvent * event)237 void KCollapsibleGroupBox::mouseMoveEvent(QMouseEvent *event)
238 {
239     const QRect headerRect(0, 0, width(), d->headerSize.height());
240     bool headerContainsMouse = headerRect.contains(event->pos());
241 
242     if (headerContainsMouse != d->headerContainsMouse) {
243         d->headerContainsMouse = headerContainsMouse;
244         update();
245     }
246 
247     QWidget::mouseMoveEvent(event);
248 }
249 
leaveEvent(QEvent * event)250 void KCollapsibleGroupBox::leaveEvent(QEvent *event)
251 {
252     d->headerContainsMouse = false;
253     update();
254     QWidget::leaveEvent(event);
255 }
256 
keyPressEvent(QKeyEvent * event)257 void KCollapsibleGroupBox::keyPressEvent(QKeyEvent *event)
258 {
259     // event might have just propagated up from a child, if so we don't want to react to it
260     if (!hasFocus()) {
261         return;
262     }
263     const int key = event->key();
264     if (key == Qt::Key_Space || key == Qt::Key_Enter || key == Qt::Key_Return) {
265         toggle();
266         event->setAccepted(true);
267     }
268 }
269 
resizeEvent(QResizeEvent * event)270 void KCollapsibleGroupBox::resizeEvent(QResizeEvent *event)
271 {
272     const QMargins margins = contentsMargins();
273 
274     if (layout()) {
275         // we don't want the layout trying to fit the current frame of the animation so always set it to the target height
276         layout()->setGeometry(QRect(margins.left(), margins.top(), width() - margins.left() - margins.right(), layout()->sizeHint().height()));
277     }
278 
279     QWidget::resizeEvent(event);
280 }
281 
overrideFocusPolicyOf(QWidget * widget)282 void KCollapsibleGroupBox::overrideFocusPolicyOf(QWidget *widget)
283 {
284     d->focusMap.insert(widget, widget->focusPolicy());
285 
286     if (!isExpanded()) {
287         // Prevent tab focus if not expanded.
288         widget->setFocusPolicy(Qt::NoFocus);
289     }
290 }
291 
recalculateHeaderSize()292 void KCollapsibleGroupBoxPrivate::recalculateHeaderSize()
293 {
294     QStyleOption option;
295     option.initFrom(q);
296 
297     QSize textSize = q->style()->itemTextRect(option.fontMetrics, QRect(), Qt::TextShowMnemonic, false, title).size();
298 
299     headerSize = q->style()->sizeFromContents(QStyle::CT_CheckBox, &option, textSize, q);
300     q->setContentsMargins(q->style()->pixelMetric(QStyle::PM_IndicatorWidth), headerSize.height(), 0, 0);
301 }
302 
updateChildrenFocus(bool expanded)303 void KCollapsibleGroupBoxPrivate::updateChildrenFocus(bool expanded)
304 {
305     const auto children = q->children();
306     for (QObject *child : children) {
307         QWidget *widget = qobject_cast<QWidget *>(child);
308         if (!widget) {
309             continue;
310         }
311         // Restore old focus policy if expanded, remove from focus chain otherwise.
312         if (expanded) {
313             widget->setFocusPolicy(focusMap.value(widget));
314         } else {
315             widget->setFocusPolicy(Qt::NoFocus);
316         }
317     }
318 }
319 
contentSize() const320 QSize KCollapsibleGroupBoxPrivate::contentSize() const
321 {
322     if (q->layout()) {
323         const QMargins margins = q->contentsMargins();
324         const QSize marginSize(margins.left() + margins.right(), margins.top() + margins.bottom());
325         return q->layout()->sizeHint() + marginSize;
326     }
327     return QSize(0, 0);
328 }
329 
contentMinimumSize() const330 QSize KCollapsibleGroupBoxPrivate::contentMinimumSize() const
331 {
332     if (q->layout()) {
333         const QMargins margins = q->contentsMargins();
334         const QSize marginSize(margins.left() + margins.right(), margins.top() + margins.bottom());
335         return q->layout()->minimumSize() + marginSize;
336     }
337     return QSize(0, 0);
338 }
339