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
18class KCollapsibleGroupBoxPrivate
19{
20public:
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
37KCollapsibleGroupBoxPrivate::KCollapsibleGroupBoxPrivate(KCollapsibleGroupBox *qq)
38 : q(qq)
39{
40}
41
42KCollapsibleGroupBox::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(sender: d->animation, signal: &QTimeLine::valueChanged, context: this, slot: [this](qreal value) {
50 setFixedHeight((d->contentSize().height() * value) + d->headerSize.height());
51 });
52 connect(sender: d->animation, signal: &QTimeLine::stateChanged, context: this, slot: [this](QTimeLine::State state) {
53 if (state == QTimeLine::NotRunning) {
54 d->updateChildrenFocus(expanded: d->isExpanded);
55 }
56 });
57
58 // Start out in collapsed height lest we blow up our window size unnecessarily.
59 // https://bugs.kde.org/show_bug.cgi?id=505820
60 setFixedHeight(d->headerSize.height());
61 setSizePolicy(hor: QSizePolicy::MinimumExpanding, ver: QSizePolicy::Fixed);
62 setFocusPolicy(Qt::TabFocus);
63 setMouseTracking(true);
64}
65
66KCollapsibleGroupBox::~KCollapsibleGroupBox()
67{
68 if (d->animation->state() == QTimeLine::Running) {
69 d->animation->stop();
70 }
71}
72
73void KCollapsibleGroupBox::setTitle(const QString &title)
74{
75 d->title = title;
76 d->recalculateHeaderSize();
77
78 update();
79 updateGeometry();
80
81 if (d->shortcutId) {
82 releaseShortcut(id: d->shortcutId);
83 }
84
85 d->shortcutId = grabShortcut(key: QKeySequence::mnemonic(text: title));
86
87#ifndef QT_NO_ACCESSIBILITY
88 setAccessibleName(title);
89#endif
90
91 Q_EMIT titleChanged();
92}
93
94QString KCollapsibleGroupBox::title() const
95{
96 return d->title;
97}
98
99void KCollapsibleGroupBox::setExpanded(bool expanded)
100{
101 if (expanded == d->isExpanded) {
102 return;
103 }
104
105 d->isExpanded = expanded;
106 Q_EMIT expandedChanged();
107
108 d->updateChildrenFocus(expanded);
109
110 // Only animate when expanding/collapsing while visible.
111 if (isVisible()) {
112 d->animation->setDirection(expanded ? QTimeLine::Forward : QTimeLine::Backward);
113 // QTimeLine::duration() must be > 0
114 const int duration = qMax(a: 1, b: style()->styleHint(stylehint: QStyle::SH_Widget_Animation_Duration));
115 d->animation->stop();
116 d->animation->setDuration(duration);
117 d->animation->start();
118
119 // when going from collapsed to expanded changing the child visibility calls an updateGeometry
120 // which calls sizeHint with expanded true before the first frame of the animation kicks in
121 // trigger an effective frame 0
122 if (expanded) {
123 setFixedHeight(d->headerSize.height());
124 }
125 } else {
126 setFixedHeight(sizeHint().height());
127 }
128
129 // Force update to show rotated arrow indicator immediately
130 update();
131}
132
133bool KCollapsibleGroupBox::isExpanded() const
134{
135 return d->isExpanded;
136}
137
138void KCollapsibleGroupBox::collapse()
139{
140 setExpanded(false);
141}
142
143void KCollapsibleGroupBox::expand()
144{
145 setExpanded(true);
146}
147
148void KCollapsibleGroupBox::toggle()
149{
150 setExpanded(!d->isExpanded);
151}
152
153void KCollapsibleGroupBox::paintEvent(QPaintEvent *event)
154{
155 QPainter p(this);
156
157 QStyleOptionButton baseOption;
158 baseOption.initFrom(w: this);
159 baseOption.rect = QRect(0, 0, width(), d->headerSize.height());
160 baseOption.text = d->title;
161
162 if (d->headerContainsMouse) {
163 baseOption.state |= QStyle::State_MouseOver;
164 }
165
166 QStyle::PrimitiveElement element;
167 if (d->isExpanded) {
168 element = QStyle::PE_IndicatorArrowDown;
169 } else {
170 element = isLeftToRight() ? QStyle::PE_IndicatorArrowRight : QStyle::PE_IndicatorArrowLeft;
171 }
172
173 QStyleOptionButton indicatorOption = baseOption;
174 indicatorOption.rect = style()->subElementRect(subElement: QStyle::SE_CheckBoxIndicator, option: &indicatorOption, widget: this);
175 style()->drawPrimitive(pe: element, opt: &indicatorOption, p: &p, w: this);
176
177 QStyleOptionButton labelOption = baseOption;
178 labelOption.rect = style()->subElementRect(subElement: QStyle::SE_CheckBoxContents, option: &labelOption, widget: this);
179 style()->drawControl(element: QStyle::CE_CheckBoxLabel, opt: &labelOption, p: &p, w: this);
180
181 Q_UNUSED(event)
182}
183
184QSize KCollapsibleGroupBox::sizeHint() const
185{
186 if (d->isExpanded) {
187 return d->contentSize() + QSize(0, d->headerSize.height());
188 } else {
189 return QSize(d->contentSize().width(), d->headerSize.height());
190 }
191}
192
193QSize KCollapsibleGroupBox::minimumSizeHint() const
194{
195 int minimumWidth = qMax(a: d->contentSize().width(), b: d->headerSize.width());
196 return QSize(minimumWidth, d->headerSize.height());
197}
198
199bool KCollapsibleGroupBox::event(QEvent *event)
200{
201 switch (event->type()) {
202 case QEvent::StyleChange:
203 /*fall through*/
204 case QEvent::FontChange:
205 d->recalculateHeaderSize();
206 break;
207 case QEvent::Shortcut: {
208 QShortcutEvent *se = static_cast<QShortcutEvent *>(event);
209 if (d->shortcutId == se->shortcutId()) {
210 toggle();
211 return true;
212 }
213 break;
214 }
215 case QEvent::ChildAdded: {
216 QChildEvent *ce = static_cast<QChildEvent *>(event);
217 if (ce->child()->isWidgetType()) {
218 auto widget = static_cast<QWidget *>(ce->child());
219 // Needs to be called asynchronously because at this point the widget is likely a "real" QWidget,
220 // i.e. the QWidget base class whose constructor sets the focus policy to NoPolicy.
221 // But the constructor of the child class (not yet called) could set a different focus policy later.
222 auto focusFunc = [this, widget]() {
223 overrideFocusPolicyOf(widget);
224 };
225 QMetaObject::invokeMethod(object: this, function&: focusFunc, type: Qt::QueuedConnection);
226 }
227 break;
228 }
229 case QEvent::LayoutRequest:
230 if (d->animation->state() == QTimeLine::NotRunning) {
231 setFixedHeight(sizeHint().height());
232 }
233 break;
234 default:
235 break;
236 }
237
238 return QWidget::event(event);
239}
240
241void KCollapsibleGroupBox::mousePressEvent(QMouseEvent *event)
242{
243 const QRect headerRect(0, 0, width(), d->headerSize.height());
244 if (headerRect.contains(p: event->pos())) {
245 toggle();
246 }
247 event->setAccepted(true);
248}
249
250// if mouse has changed whether it is in the top bar or not refresh to change arrow icon
251void KCollapsibleGroupBox::mouseMoveEvent(QMouseEvent *event)
252{
253 const QRect headerRect(0, 0, width(), d->headerSize.height());
254 bool headerContainsMouse = headerRect.contains(p: event->pos());
255
256 if (headerContainsMouse != d->headerContainsMouse) {
257 d->headerContainsMouse = headerContainsMouse;
258 update();
259 }
260
261 QWidget::mouseMoveEvent(event);
262}
263
264void KCollapsibleGroupBox::leaveEvent(QEvent *event)
265{
266 d->headerContainsMouse = false;
267 update();
268 QWidget::leaveEvent(event);
269}
270
271void KCollapsibleGroupBox::keyPressEvent(QKeyEvent *event)
272{
273 // event might have just propagated up from a child, if so we don't want to react to it
274 if (!hasFocus()) {
275 return;
276 }
277 const int key = event->key();
278 if (key == Qt::Key_Space || key == Qt::Key_Enter || key == Qt::Key_Return) {
279 toggle();
280 event->setAccepted(true);
281 }
282}
283
284void KCollapsibleGroupBox::resizeEvent(QResizeEvent *event)
285{
286 const QMargins margins = contentsMargins();
287
288 if (layout()) {
289 // we don't want the layout trying to fit the current frame of the animation so always set it to the target height
290 layout()->setGeometry(QRect(margins.left(), margins.top(), width() - margins.left() - margins.right(), layout()->sizeHint().height()));
291 }
292
293 QWidget::resizeEvent(event);
294}
295
296void KCollapsibleGroupBox::overrideFocusPolicyOf(QWidget *widget)
297{
298 d->focusMap.insert(key: widget, value: widget->focusPolicy());
299
300 if (!isExpanded()) {
301 // Prevent tab focus if not expanded.
302 widget->setFocusPolicy(Qt::NoFocus);
303 }
304}
305
306void KCollapsibleGroupBoxPrivate::recalculateHeaderSize()
307{
308 QStyleOption option;
309 option.initFrom(w: q);
310
311 QSize textSize = q->style()->itemTextRect(fm: option.fontMetrics, r: QRect(), flags: Qt::TextShowMnemonic, enabled: false, text: title).size();
312
313 headerSize = q->style()->sizeFromContents(ct: QStyle::CT_CheckBox, opt: &option, contentsSize: textSize, w: q);
314 q->setContentsMargins(left: q->style()->pixelMetric(metric: QStyle::PM_IndicatorWidth), top: headerSize.height(), right: 0, bottom: 0);
315}
316
317void KCollapsibleGroupBoxPrivate::updateChildrenFocus(bool expanded)
318{
319 const auto children = q->children();
320 for (QObject *child : children) {
321 QWidget *widget = qobject_cast<QWidget *>(o: child);
322 if (!widget) {
323 continue;
324 }
325 // Restore old focus policy if expanded, remove from focus chain otherwise.
326 if (expanded) {
327 widget->setFocusPolicy(focusMap.value(key: widget));
328 } else {
329 widget->setFocusPolicy(Qt::NoFocus);
330 }
331 }
332}
333
334QSize KCollapsibleGroupBoxPrivate::contentSize() const
335{
336 if (q->layout()) {
337 const QMargins margins = q->contentsMargins();
338 const QSize marginSize(margins.left() + margins.right(), margins.top() + margins.bottom());
339 return q->layout()->sizeHint() + marginSize;
340 }
341 return QSize(0, 0);
342}
343
344QSize KCollapsibleGroupBoxPrivate::contentMinimumSize() const
345{
346 if (q->layout()) {
347 const QMargins margins = q->contentsMargins();
348 const QSize marginSize(margins.left() + margins.right(), margins.top() + margins.bottom());
349 return q->layout()->minimumSize() + marginSize;
350 }
351 return QSize(0, 0);
352}
353
354#include "moc_kcollapsiblegroupbox.cpp"
355

source code of kwidgetsaddons/src/kcollapsiblegroupbox.cpp