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 = false; |
32 | QSize ; |
33 | int shortcutId = 0; |
34 | QMap<QWidget *, Qt::FocusPolicy> focusMap; // Used to restore focus policy of widgets. |
35 | }; |
36 | |
37 | KCollapsibleGroupBoxPrivate::KCollapsibleGroupBoxPrivate(KCollapsibleGroupBox *qq) |
38 | : q(qq) |
39 | { |
40 | } |
41 | |
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(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 | |
66 | KCollapsibleGroupBox::~KCollapsibleGroupBox() |
67 | { |
68 | if (d->animation->state() == QTimeLine::Running) { |
69 | d->animation->stop(); |
70 | } |
71 | } |
72 | |
73 | void 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 | |
94 | QString KCollapsibleGroupBox::title() const |
95 | { |
96 | return d->title; |
97 | } |
98 | |
99 | void 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 | |
130 | bool KCollapsibleGroupBox::isExpanded() const |
131 | { |
132 | return d->isExpanded; |
133 | } |
134 | |
135 | void KCollapsibleGroupBox::collapse() |
136 | { |
137 | setExpanded(false); |
138 | } |
139 | |
140 | void KCollapsibleGroupBox::expand() |
141 | { |
142 | setExpanded(true); |
143 | } |
144 | |
145 | void KCollapsibleGroupBox::toggle() |
146 | { |
147 | setExpanded(!d->isExpanded); |
148 | } |
149 | |
150 | void 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 | |
181 | QSize 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 | |
190 | QSize 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 | |
196 | bool 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 | |
238 | void KCollapsibleGroupBox::mousePressEvent(QMouseEvent *event) |
239 | { |
240 | const QRect (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 |
248 | void KCollapsibleGroupBox::mouseMoveEvent(QMouseEvent *event) |
249 | { |
250 | const QRect (0, 0, width(), d->headerSize.height()); |
251 | bool = 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 | |
261 | void KCollapsibleGroupBox::leaveEvent(QEvent *event) |
262 | { |
263 | d->headerContainsMouse = false; |
264 | update(); |
265 | QWidget::leaveEvent(event); |
266 | } |
267 | |
268 | void 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 | |
281 | void 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 | |
293 | void 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 | |
303 | void KCollapsibleGroupBoxPrivate::() |
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 | |
314 | void 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 | |
331 | QSize 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 | |
341 | QSize 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 | |