| 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 | |