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