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
130bool KCollapsibleGroupBox::isExpanded() const
131{
132 return d->isExpanded;
133}
134
135void KCollapsibleGroupBox::collapse()
136{
137 setExpanded(false);
138}
139
140void KCollapsibleGroupBox::expand()
141{
142 setExpanded(true);
143}
144
145void KCollapsibleGroupBox::toggle()
146{
147 setExpanded(!d->isExpanded);
148}
149
150void KCollapsibleGroupBox::paintEvent(QPaintEvent *event)
151{
152 QPainter p(this);
153
154 QStyleOptionButton baseOption;
155 baseOption.initFrom(w: this);
156 baseOption.rect = QRect(0, 0, width(), d->headerSize.height());
157 baseOption.text = d->title;
158
159 if (d->headerContainsMouse) {
160 baseOption.state |= QStyle::State_MouseOver;
161 }
162
163 QStyle::PrimitiveElement element;
164 if (d->isExpanded) {
165 element = QStyle::PE_IndicatorArrowDown;
166 } else {
167 element = isLeftToRight() ? QStyle::PE_IndicatorArrowRight : QStyle::PE_IndicatorArrowLeft;
168 }
169
170 QStyleOptionButton indicatorOption = baseOption;
171 indicatorOption.rect = style()->subElementRect(subElement: QStyle::SE_CheckBoxIndicator, option: &indicatorOption, widget: this);
172 style()->drawPrimitive(pe: element, opt: &indicatorOption, p: &p, w: this);
173
174 QStyleOptionButton labelOption = baseOption;
175 labelOption.rect = style()->subElementRect(subElement: QStyle::SE_CheckBoxContents, option: &labelOption, widget: this);
176 style()->drawControl(element: QStyle::CE_CheckBoxLabel, opt: &labelOption, p: &p, w: this);
177
178 Q_UNUSED(event)
179}
180
181QSize KCollapsibleGroupBox::sizeHint() const
182{
183 if (d->isExpanded) {
184 return d->contentSize() + QSize(0, d->headerSize.height());
185 } else {
186 return QSize(d->contentSize().width(), d->headerSize.height());
187 }
188}
189
190QSize KCollapsibleGroupBox::minimumSizeHint() const
191{
192 int minimumWidth = qMax(a: d->contentSize().width(), b: d->headerSize.width());
193 return QSize(minimumWidth, d->headerSize.height());
194}
195
196bool KCollapsibleGroupBox::event(QEvent *event)
197{
198 switch (event->type()) {
199 case QEvent::StyleChange:
200 /*fall through*/
201 case QEvent::FontChange:
202 d->recalculateHeaderSize();
203 break;
204 case QEvent::Shortcut: {
205 QShortcutEvent *se = static_cast<QShortcutEvent *>(event);
206 if (d->shortcutId == se->shortcutId()) {
207 toggle();
208 return true;
209 }
210 break;
211 }
212 case QEvent::ChildAdded: {
213 QChildEvent *ce = static_cast<QChildEvent *>(event);
214 if (ce->child()->isWidgetType()) {
215 auto widget = static_cast<QWidget *>(ce->child());
216 // Needs to be called asynchronously because at this point the widget is likely a "real" QWidget,
217 // i.e. the QWidget base class whose constructor sets the focus policy to NoPolicy.
218 // But the constructor of the child class (not yet called) could set a different focus policy later.
219 auto focusFunc = [this, widget]() {
220 overrideFocusPolicyOf(widget);
221 };
222 QMetaObject::invokeMethod(object: this, function&: focusFunc, type: Qt::QueuedConnection);
223 }
224 break;
225 }
226 case QEvent::LayoutRequest:
227 if (d->animation->state() == QTimeLine::NotRunning) {
228 setFixedHeight(sizeHint().height());
229 }
230 break;
231 default:
232 break;
233 }
234
235 return QWidget::event(event);
236}
237
238void KCollapsibleGroupBox::mousePressEvent(QMouseEvent *event)
239{
240 const QRect headerRect(0, 0, width(), d->headerSize.height());
241 if (headerRect.contains(p: event->pos())) {
242 toggle();
243 }
244 event->setAccepted(true);
245}
246
247// if mouse has changed whether it is in the top bar or not refresh to change arrow icon
248void KCollapsibleGroupBox::mouseMoveEvent(QMouseEvent *event)
249{
250 const QRect headerRect(0, 0, width(), d->headerSize.height());
251 bool headerContainsMouse = headerRect.contains(p: event->pos());
252
253 if (headerContainsMouse != d->headerContainsMouse) {
254 d->headerContainsMouse = headerContainsMouse;
255 update();
256 }
257
258 QWidget::mouseMoveEvent(event);
259}
260
261void KCollapsibleGroupBox::leaveEvent(QEvent *event)
262{
263 d->headerContainsMouse = false;
264 update();
265 QWidget::leaveEvent(event);
266}
267
268void KCollapsibleGroupBox::keyPressEvent(QKeyEvent *event)
269{
270 // event might have just propagated up from a child, if so we don't want to react to it
271 if (!hasFocus()) {
272 return;
273 }
274 const int key = event->key();
275 if (key == Qt::Key_Space || key == Qt::Key_Enter || key == Qt::Key_Return) {
276 toggle();
277 event->setAccepted(true);
278 }
279}
280
281void KCollapsibleGroupBox::resizeEvent(QResizeEvent *event)
282{
283 const QMargins margins = contentsMargins();
284
285 if (layout()) {
286 // we don't want the layout trying to fit the current frame of the animation so always set it to the target height
287 layout()->setGeometry(QRect(margins.left(), margins.top(), width() - margins.left() - margins.right(), layout()->sizeHint().height()));
288 }
289
290 QWidget::resizeEvent(event);
291}
292
293void KCollapsibleGroupBox::overrideFocusPolicyOf(QWidget *widget)
294{
295 d->focusMap.insert(key: widget, value: widget->focusPolicy());
296
297 if (!isExpanded()) {
298 // Prevent tab focus if not expanded.
299 widget->setFocusPolicy(Qt::NoFocus);
300 }
301}
302
303void KCollapsibleGroupBoxPrivate::recalculateHeaderSize()
304{
305 QStyleOption option;
306 option.initFrom(w: q);
307
308 QSize textSize = q->style()->itemTextRect(fm: option.fontMetrics, r: QRect(), flags: Qt::TextShowMnemonic, enabled: false, text: title).size();
309
310 headerSize = q->style()->sizeFromContents(ct: QStyle::CT_CheckBox, opt: &option, contentsSize: textSize, w: q);
311 q->setContentsMargins(left: q->style()->pixelMetric(metric: QStyle::PM_IndicatorWidth), top: headerSize.height(), right: 0, bottom: 0);
312}
313
314void KCollapsibleGroupBoxPrivate::updateChildrenFocus(bool expanded)
315{
316 const auto children = q->children();
317 for (QObject *child : children) {
318 QWidget *widget = qobject_cast<QWidget *>(o: child);
319 if (!widget) {
320 continue;
321 }
322 // Restore old focus policy if expanded, remove from focus chain otherwise.
323 if (expanded) {
324 widget->setFocusPolicy(focusMap.value(key: widget));
325 } else {
326 widget->setFocusPolicy(Qt::NoFocus);
327 }
328 }
329}
330
331QSize KCollapsibleGroupBoxPrivate::contentSize() const
332{
333 if (q->layout()) {
334 const QMargins margins = q->contentsMargins();
335 const QSize marginSize(margins.left() + margins.right(), margins.top() + margins.bottom());
336 return q->layout()->sizeHint() + marginSize;
337 }
338 return QSize(0, 0);
339}
340
341QSize KCollapsibleGroupBoxPrivate::contentMinimumSize() const
342{
343 if (q->layout()) {
344 const QMargins margins = q->contentsMargins();
345 const QSize marginSize(margins.left() + margins.right(), margins.top() + margins.bottom());
346 return q->layout()->minimumSize() + marginSize;
347 }
348 return QSize(0, 0);
349}
350
351#include "moc_kcollapsiblegroupbox.cpp"
352

source code of kwidgetsaddons/src/kcollapsiblegroupbox.cpp