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 | setSizePolicy(hor: QSizePolicy::MinimumExpanding, ver: QSizePolicy::Fixed); |
59 | setFocusPolicy(Qt::TabFocus); |
60 | setMouseTracking(true); |
61 | } |
62 | |
63 | KCollapsibleGroupBox::~KCollapsibleGroupBox() |
64 | { |
65 | if (d->animation->state() == QTimeLine::Running) { |
66 | d->animation->stop(); |
67 | } |
68 | } |
69 | |
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(id: d->shortcutId); |
80 | } |
81 | |
82 | d->shortcutId = grabShortcut(key: QKeySequence::mnemonic(text: title)); |
83 | |
84 | #ifndef QT_NO_ACCESSIBILITY |
85 | setAccessibleName(title); |
86 | #endif |
87 | |
88 | Q_EMIT titleChanged(); |
89 | } |
90 | |
91 | QString KCollapsibleGroupBox::title() const |
92 | { |
93 | return d->title; |
94 | } |
95 | |
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(a: 1, b: style()->styleHint(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 | |
122 | bool KCollapsibleGroupBox::isExpanded() const |
123 | { |
124 | return d->isExpanded; |
125 | } |
126 | |
127 | void KCollapsibleGroupBox::collapse() |
128 | { |
129 | setExpanded(false); |
130 | } |
131 | |
132 | void KCollapsibleGroupBox::expand() |
133 | { |
134 | setExpanded(true); |
135 | } |
136 | |
137 | void KCollapsibleGroupBox::toggle() |
138 | { |
139 | setExpanded(!d->isExpanded); |
140 | } |
141 | |
142 | void KCollapsibleGroupBox::paintEvent(QPaintEvent *event) |
143 | { |
144 | QPainter p(this); |
145 | |
146 | QStyleOptionButton baseOption; |
147 | baseOption.initFrom(w: 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(subElement: QStyle::SE_CheckBoxIndicator, option: &indicatorOption, widget: this); |
164 | style()->drawPrimitive(pe: element, opt: &indicatorOption, p: &p, w: this); |
165 | |
166 | QStyleOptionButton labelOption = baseOption; |
167 | labelOption.rect = style()->subElementRect(subElement: QStyle::SE_CheckBoxContents, option: &labelOption, widget: this); |
168 | style()->drawControl(element: QStyle::CE_CheckBoxLabel, opt: &labelOption, p: &p, w: this); |
169 | |
170 | Q_UNUSED(event) |
171 | } |
172 | |
173 | 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 | |
182 | QSize KCollapsibleGroupBox::minimumSizeHint() const |
183 | { |
184 | int minimumWidth = qMax(a: d->contentSize().width(), b: d->headerSize.width()); |
185 | return QSize(minimumWidth, d->headerSize.height()); |
186 | } |
187 | |
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 | auto focusFunc = [this, widget]() { |
212 | overrideFocusPolicyOf(widget); |
213 | }; |
214 | QMetaObject::invokeMethod(object: this, function&: focusFunc, type: Qt::QueuedConnection); |
215 | } |
216 | break; |
217 | } |
218 | case QEvent::LayoutRequest: |
219 | if (d->animation->state() == QTimeLine::NotRunning) { |
220 | setFixedHeight(sizeHint().height()); |
221 | } |
222 | break; |
223 | default: |
224 | break; |
225 | } |
226 | |
227 | return QWidget::event(event); |
228 | } |
229 | |
230 | void KCollapsibleGroupBox::mousePressEvent(QMouseEvent *event) |
231 | { |
232 | const QRect (0, 0, width(), d->headerSize.height()); |
233 | if (headerRect.contains(p: event->pos())) { |
234 | toggle(); |
235 | } |
236 | event->setAccepted(true); |
237 | } |
238 | |
239 | // if mouse has changed whether it is in the top bar or not refresh to change arrow icon |
240 | void KCollapsibleGroupBox::mouseMoveEvent(QMouseEvent *event) |
241 | { |
242 | const QRect (0, 0, width(), d->headerSize.height()); |
243 | bool = headerRect.contains(p: event->pos()); |
244 | |
245 | if (headerContainsMouse != d->headerContainsMouse) { |
246 | d->headerContainsMouse = headerContainsMouse; |
247 | update(); |
248 | } |
249 | |
250 | QWidget::mouseMoveEvent(event); |
251 | } |
252 | |
253 | void KCollapsibleGroupBox::leaveEvent(QEvent *event) |
254 | { |
255 | d->headerContainsMouse = false; |
256 | update(); |
257 | QWidget::leaveEvent(event); |
258 | } |
259 | |
260 | void KCollapsibleGroupBox::keyPressEvent(QKeyEvent *event) |
261 | { |
262 | // event might have just propagated up from a child, if so we don't want to react to it |
263 | if (!hasFocus()) { |
264 | return; |
265 | } |
266 | const int key = event->key(); |
267 | if (key == Qt::Key_Space || key == Qt::Key_Enter || key == Qt::Key_Return) { |
268 | toggle(); |
269 | event->setAccepted(true); |
270 | } |
271 | } |
272 | |
273 | void KCollapsibleGroupBox::resizeEvent(QResizeEvent *event) |
274 | { |
275 | const QMargins margins = contentsMargins(); |
276 | |
277 | if (layout()) { |
278 | // we don't want the layout trying to fit the current frame of the animation so always set it to the target height |
279 | layout()->setGeometry(QRect(margins.left(), margins.top(), width() - margins.left() - margins.right(), layout()->sizeHint().height())); |
280 | } |
281 | |
282 | QWidget::resizeEvent(event); |
283 | } |
284 | |
285 | void KCollapsibleGroupBox::overrideFocusPolicyOf(QWidget *widget) |
286 | { |
287 | d->focusMap.insert(key: widget, value: widget->focusPolicy()); |
288 | |
289 | if (!isExpanded()) { |
290 | // Prevent tab focus if not expanded. |
291 | widget->setFocusPolicy(Qt::NoFocus); |
292 | } |
293 | } |
294 | |
295 | void KCollapsibleGroupBoxPrivate::() |
296 | { |
297 | QStyleOption option; |
298 | option.initFrom(w: q); |
299 | |
300 | QSize textSize = q->style()->itemTextRect(fm: option.fontMetrics, r: QRect(), flags: Qt::TextShowMnemonic, enabled: false, text: title).size(); |
301 | |
302 | headerSize = q->style()->sizeFromContents(ct: QStyle::CT_CheckBox, opt: &option, contentsSize: textSize, w: q); |
303 | q->setContentsMargins(left: q->style()->pixelMetric(metric: QStyle::PM_IndicatorWidth), top: headerSize.height(), right: 0, bottom: 0); |
304 | } |
305 | |
306 | void KCollapsibleGroupBoxPrivate::updateChildrenFocus(bool expanded) |
307 | { |
308 | const auto children = q->children(); |
309 | for (QObject *child : children) { |
310 | QWidget *widget = qobject_cast<QWidget *>(o: child); |
311 | if (!widget) { |
312 | continue; |
313 | } |
314 | // Restore old focus policy if expanded, remove from focus chain otherwise. |
315 | if (expanded) { |
316 | widget->setFocusPolicy(focusMap.value(key: widget)); |
317 | } else { |
318 | widget->setFocusPolicy(Qt::NoFocus); |
319 | } |
320 | } |
321 | } |
322 | |
323 | QSize KCollapsibleGroupBoxPrivate::contentSize() const |
324 | { |
325 | if (q->layout()) { |
326 | const QMargins margins = q->contentsMargins(); |
327 | const QSize marginSize(margins.left() + margins.right(), margins.top() + margins.bottom()); |
328 | return q->layout()->sizeHint() + marginSize; |
329 | } |
330 | return QSize(0, 0); |
331 | } |
332 | |
333 | QSize KCollapsibleGroupBoxPrivate::contentMinimumSize() const |
334 | { |
335 | if (q->layout()) { |
336 | const QMargins margins = q->contentsMargins(); |
337 | const QSize marginSize(margins.left() + margins.right(), margins.top() + margins.bottom()); |
338 | return q->layout()->minimumSize() + marginSize; |
339 | } |
340 | return QSize(0, 0); |
341 | } |
342 | |
343 | #include "moc_kcollapsiblegroupbox.cpp" |
344 | |