/* * SPDX-FileCopyrightText: 2020 Arjen Hiemstra * * SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL */ #include "toolbarlayout.h" #include #include #include #include #include #include "loggingcategory.h" #include "toolbarlayoutdelegate.h" ToolBarLayoutAttached::ToolBarLayoutAttached(QObject *parent) : QObject(parent) { } QObject *ToolBarLayoutAttached::action() const { return m_action; } void ToolBarLayoutAttached::setAction(QObject *action) { m_action = action; } class ToolBarLayoutPrivate { ToolBarLayout *const q; public: ToolBarLayoutPrivate(ToolBarLayout *qq) : q(qq) { } ~ToolBarLayoutPrivate() { if (moreButtonIncubator) { moreButtonIncubator->clear(); delete moreButtonIncubator; } } void calculateImplicitSize(); void performLayout(); QList createDelegates(); ToolBarLayoutDelegate *createDelegate(QObject *action); qreal layoutStart(qreal layoutWidth); void maybeHideDelegate(int index, qreal ¤tWidth, qreal totalWidth); QList actions; ToolBarLayout::ActionsProperty actionsProperty; QList hiddenActions; QQmlComponent *fullDelegate = nullptr; QQmlComponent *iconDelegate = nullptr; QQmlComponent *separatorDelegate = nullptr; QQmlComponent *moreButton = nullptr; qreal spacing = 0.0; Qt::Alignment alignment = Qt::AlignLeft; qreal visibleActionsWidth = 0.0; qreal visibleWidth = 0.0; Qt::LayoutDirection layoutDirection = Qt::LeftToRight; ToolBarLayout::HeightMode heightMode = ToolBarLayout::ConstrainIfLarger; bool completed = false; bool actionsChanged = false; bool implicitSizeValid = false; std::unordered_map> delegates; QList sortedDelegates; QQuickItem *moreButtonInstance = nullptr; ToolBarDelegateIncubator *moreButtonIncubator = nullptr; bool shouldShowMoreButton = false; int firstHiddenIndex = -1; QList removedActions; QTimer *removalTimer = nullptr; QElapsedTimer performanceTimer; static void appendAction(ToolBarLayout::ActionsProperty *list, QObject *action); static qsizetype actionCount(ToolBarLayout::ActionsProperty *list); static QObject *action(ToolBarLayout::ActionsProperty *list, qsizetype index); static void clearActions(ToolBarLayout::ActionsProperty *list); }; ToolBarLayout::ToolBarLayout(QQuickItem *parent) : QQuickItem(parent) , d(std::make_unique(this)) { d->actionsProperty = ActionsProperty(this, this, ToolBarLayoutPrivate::appendAction, ToolBarLayoutPrivate::actionCount, ToolBarLayoutPrivate::action, ToolBarLayoutPrivate::clearActions); // To prevent multiple assignments to actions from constantly recreating // delegates, we cache the delegates and only remove them once they are no // longer being used. This timer is responsible for triggering that removal. d->removalTimer = new QTimer{this}; d->removalTimer->setInterval(1000); d->removalTimer->setSingleShot(true); connect(d->removalTimer, &QTimer::timeout, this, [this]() { for (auto action : std::as_const(d->removedActions)) { if (!d->actions.contains(action)) { d->delegates.erase(action); } } d->removedActions.clear(); }); } ToolBarLayout::~ToolBarLayout() { } ToolBarLayout::ActionsProperty ToolBarLayout::actionsProperty() const { return d->actionsProperty; } void ToolBarLayout::addAction(QObject *action) { if (action == nullptr) { return; } d->actions.append(action); d->actionsChanged = true; connect(action, &QObject::destroyed, this, [this](QObject *action) { auto itr = d->delegates.find(action); if (itr != d->delegates.end()) { d->delegates.erase(itr); } d->actions.removeOne(action); d->actionsChanged = true; relayout(); }); relayout(); } void ToolBarLayout::removeAction(QObject *action) { auto itr = d->delegates.find(action); if (itr != d->delegates.end()) { itr->second->hide(); } d->actions.removeOne(action); d->removedActions.append(action); d->removalTimer->start(); d->actionsChanged = true; relayout(); } void ToolBarLayout::clearActions() { for (auto action : std::as_const(d->actions)) { auto itr = d->delegates.find(action); if (itr != d->delegates.end()) { itr->second->hide(); } } d->removedActions.append(d->actions); d->actions.clear(); d->actionsChanged = true; relayout(); } QList ToolBarLayout::hiddenActions() const { return d->hiddenActions; } QQmlComponent *ToolBarLayout::fullDelegate() const { return d->fullDelegate; } void ToolBarLayout::setFullDelegate(QQmlComponent *newFullDelegate) { if (newFullDelegate == d->fullDelegate) { return; } d->fullDelegate = newFullDelegate; d->delegates.clear(); relayout(); Q_EMIT fullDelegateChanged(); } QQmlComponent *ToolBarLayout::iconDelegate() const { return d->iconDelegate; } void ToolBarLayout::setIconDelegate(QQmlComponent *newIconDelegate) { if (newIconDelegate == d->iconDelegate) { return; } d->iconDelegate = newIconDelegate; d->delegates.clear(); relayout(); Q_EMIT iconDelegateChanged(); } QQmlComponent *ToolBarLayout::separatorDelegate() const { return d->separatorDelegate; } void ToolBarLayout::setSeparatorDelegate(QQmlComponent *newSeparatorDelegate) { if (newSeparatorDelegate == d->separatorDelegate) { return; } d->separatorDelegate = newSeparatorDelegate; d->delegates.clear(); relayout(); Q_EMIT separatorDelegateChanged(); } QQmlComponent *ToolBarLayout::moreButton() const { return d->moreButton; } void ToolBarLayout::setMoreButton(QQmlComponent *newMoreButton) { if (newMoreButton == d->moreButton) { return; } d->moreButton = newMoreButton; if (d->moreButtonInstance) { d->moreButtonInstance->deleteLater(); d->moreButtonInstance = nullptr; } relayout(); Q_EMIT moreButtonChanged(); } qreal ToolBarLayout::spacing() const { return d->spacing; } void ToolBarLayout::setSpacing(qreal newSpacing) { if (newSpacing == d->spacing) { return; } d->spacing = newSpacing; relayout(); Q_EMIT spacingChanged(); } Qt::Alignment ToolBarLayout::alignment() const { return d->alignment; } void ToolBarLayout::setAlignment(Qt::Alignment newAlignment) { if (newAlignment == d->alignment) { return; } d->alignment = newAlignment; relayout(); Q_EMIT alignmentChanged(); } qreal ToolBarLayout::visibleWidth() const { return d->visibleWidth; } qreal ToolBarLayout::minimumWidth() const { return d->moreButtonInstance ? d->moreButtonInstance->width() : 0; } Qt::LayoutDirection ToolBarLayout::layoutDirection() const { return d->layoutDirection; } void ToolBarLayout::setLayoutDirection(Qt::LayoutDirection &newLayoutDirection) { if (newLayoutDirection == d->layoutDirection) { return; } d->layoutDirection = newLayoutDirection; relayout(); Q_EMIT layoutDirectionChanged(); } ToolBarLayout::HeightMode ToolBarLayout::heightMode() const { return d->heightMode; } void ToolBarLayout::setHeightMode(HeightMode newHeightMode) { if (newHeightMode == d->heightMode) { return; } d->heightMode = newHeightMode; relayout(); Q_EMIT heightModeChanged(); } void ToolBarLayout::relayout() { d->implicitSizeValid = false; polish(); } void ToolBarLayout::componentComplete() { QQuickItem::componentComplete(); d->completed = true; relayout(); } void ToolBarLayout::geometryChange(const QRectF &newGeometry, const QRectF &oldGeometry) { if (newGeometry != oldGeometry) { if (newGeometry.size() != QSizeF{implicitWidth(), implicitHeight()}) { relayout(); } else { polish(); } } QQuickItem::geometryChange(newGeometry, oldGeometry); } void ToolBarLayout::itemChange(QQuickItem::ItemChange change, const QQuickItem::ItemChangeData &data) { if (change == ItemVisibleHasChanged || change == ItemSceneChange) { relayout(); } QQuickItem::itemChange(change, data); } void ToolBarLayout::updatePolish() { d->performLayout(); } /** * Calculate the implicit size for this layout. * * This is a separate step from performing the actual layout, because of a nasty * little issue with Control, where it will unconditionally set the height of * its contentItem, which means QQuickItem::heightValid() becomes useless. So * instead, we first calculate our implicit size, ignoring any explicitly set * item size. Then we follow that by performing the actual layouting, using the * width and height retrieved from the item, as those will return the explicitly * set width/height if set and the implicit size otherwise. Since control * watches for implicit size changes, we end up with correct behaviour both when * we get an explicit size set and when we're relying on implicit size * calculation. */ void ToolBarLayoutPrivate::calculateImplicitSize() { if (!completed) { return; } if (!fullDelegate || !iconDelegate || !separatorDelegate || !moreButton) { qCWarning(KirigamiLayoutsLog) << "ToolBarLayout: Unable to layout, required properties are not set"; return; } if (actions.isEmpty()) { q->setImplicitSize(0., 0.); return; } hiddenActions.clear(); firstHiddenIndex = -1; sortedDelegates = createDelegates(); bool ready = std::all_of(delegates.cbegin(), delegates.cend(), [](const std::pair> &entry) { return entry.second->isReady(); }); if (!ready || !moreButtonInstance) { return; } qreal maxHeight = 0.0; qreal maxWidth = 0.0; // First, calculate the total width and maximum height of all delegates. // This will be used to determine which actions to show, which ones to // collapse to icon-only etc. for (auto entry : std::as_const(sortedDelegates)) { if (!entry->isActionVisible()) { entry->hide(); continue; } if (entry->isHidden()) { entry->hide(); hiddenActions.append(entry->action()); continue; } if (entry->isIconOnly()) { entry->showIcon(); } else { entry->showFull(); } maxWidth += entry->width() + spacing; maxHeight = std::max(maxHeight, entry->maxHeight()); } // The last entry also gets spacing but shouldn't, so remove that. maxWidth -= spacing; visibleActionsWidth = 0.0; if (maxWidth > q->width() - (hiddenActions.isEmpty() ? 0.0 : moreButtonInstance->width() + spacing)) { // We have more items than fit into the view, so start hiding some. qreal layoutWidth = q->width() - (moreButtonInstance->width() + spacing); if (alignment & Qt::AlignHCenter) { // When centering, we need to reserve space on both sides to make sure // things are properly centered, otherwise we will be to the right of // the center. layoutWidth -= (moreButtonInstance->width() + spacing); } for (int i = 0; i < sortedDelegates.size(); ++i) { auto delegate = sortedDelegates.at(i); maybeHideDelegate(i, visibleActionsWidth, layoutWidth); if (delegate->isVisible()) { visibleActionsWidth += delegate->width() + spacing; } } if (!qFuzzyIsNull(visibleActionsWidth)) { // Like above, remove spacing on the last element that incorrectly gets spacing added. visibleActionsWidth -= spacing; } } else { visibleActionsWidth = maxWidth; } if (!hiddenActions.isEmpty()) { maxHeight = std::max(maxHeight, moreButtonInstance->implicitHeight()); }; q->setImplicitSize(maxWidth, maxHeight); Q_EMIT q->hiddenActionsChanged(); implicitSizeValid = true; q->polish(); } void ToolBarLayoutPrivate::performLayout() { if (!completed || actions.isEmpty()) { return; } if (!implicitSizeValid) { calculateImplicitSize(); } if (sortedDelegates.isEmpty()) { sortedDelegates = createDelegates(); } bool ready = std::all_of(delegates.cbegin(), delegates.cend(), [](const std::pair> &entry) { return entry.second->isReady(); }); if (!ready || !moreButtonInstance) { return; } qreal width = q->width(); qreal height = q->height(); if (!hiddenActions.isEmpty()) { if (layoutDirection == Qt::LeftToRight) { moreButtonInstance->setX(width - moreButtonInstance->width()); } else { moreButtonInstance->setX(0.0); } if (heightMode == ToolBarLayout::AlwaysFill) { moreButtonInstance->setHeight(height); } else if (heightMode == ToolBarLayout::ConstrainIfLarger) { if (moreButtonInstance->implicitHeight() > height) { moreButtonInstance->setHeight(height); } else { moreButtonInstance->resetHeight(); } } else { moreButtonInstance->resetHeight(); } moreButtonInstance->setY(qRound((height - moreButtonInstance->height()) / 2.0)); shouldShowMoreButton = true; moreButtonInstance->setVisible(true); } else { shouldShowMoreButton = false; moreButtonInstance->setVisible(false); } qreal currentX = layoutStart(visibleActionsWidth); for (auto entry : std::as_const(sortedDelegates)) { if (!entry->isVisible()) { continue; } if (heightMode == ToolBarLayout::AlwaysFill) { entry->setHeight(height); } else if (heightMode == ToolBarLayout::ConstrainIfLarger) { if (entry->implicitHeight() > height) { entry->setHeight(height); } else { entry->resetHeight(); } } else { entry->resetHeight(); } qreal y = qRound((height - entry->height()) / 2.0); if (layoutDirection == Qt::LeftToRight) { entry->setPosition(currentX, y); currentX += entry->width() + spacing; } else { entry->setPosition(currentX - entry->width(), y); currentX -= entry->width() + spacing; } entry->show(); } qreal newVisibleWidth = visibleActionsWidth; if (moreButtonInstance->isVisible()) { newVisibleWidth += moreButtonInstance->width() + (newVisibleWidth > 0.0 ? spacing : 0.0); } if (!qFuzzyCompare(newVisibleWidth, visibleWidth)) { visibleWidth = newVisibleWidth; Q_EMIT q->visibleWidthChanged(); } if (actionsChanged) { // Due to the way QQmlListProperty works, if we emit changed every time // an action is added/removed, we end up emitting way too often. So // instead only do it after everything else is done. Q_EMIT q->actionsChanged(); actionsChanged = false; } sortedDelegates.clear(); } QList ToolBarLayoutPrivate::createDelegates() { QList result; for (auto action : std::as_const(actions)) { if (delegates.find(action) != delegates.end()) { result.append(delegates.at(action).get()); } else if (action) { auto delegate = std::unique_ptr(createDelegate(action)); if (delegate) { result.append(delegate.get()); delegates.emplace(action, std::move(delegate)); } } } if (!moreButtonInstance && !moreButtonIncubator) { moreButtonIncubator = new ToolBarDelegateIncubator(moreButton, qmlContext(moreButton)); moreButtonIncubator->setStateCallback([this](QQuickItem *item) { item->setParentItem(q); }); moreButtonIncubator->setCompletedCallback([this](ToolBarDelegateIncubator *incubator) { moreButtonInstance = qobject_cast(incubator->object()); moreButtonInstance->setVisible(false); QObject::connect(moreButtonInstance, &QQuickItem::visibleChanged, q, [this]() { moreButtonInstance->setVisible(shouldShowMoreButton); }); QObject::connect(moreButtonInstance, &QQuickItem::widthChanged, q, &ToolBarLayout::minimumWidthChanged); q->relayout(); Q_EMIT q->minimumWidthChanged(); QTimer::singleShot(0, q, [this]() { delete moreButtonIncubator; moreButtonIncubator = nullptr; }); }); moreButtonIncubator->create(); } return result; } ToolBarLayoutDelegate *ToolBarLayoutPrivate::createDelegate(QObject *action) { QQmlComponent *fullComponent = nullptr; auto displayComponent = action->property("displayComponent"); if (displayComponent.isValid()) { fullComponent = displayComponent.value(); } if (!fullComponent) { fullComponent = fullDelegate; } auto separator = action->property("separator"); if (separator.isValid() && separator.toBool()) { fullComponent = separatorDelegate; } auto result = new ToolBarLayoutDelegate(q); result->setAction(action); result->createItems(fullComponent, iconDelegate, [this, action](QQuickItem *newItem) { newItem->setParentItem(q); auto attached = static_cast(qmlAttachedPropertiesObject(newItem, true)); attached->setAction(action); if (!q->childItems().isEmpty() && q->childItems().first() != newItem) { // Due to asynchronous item creation, we end up creating the last item // first. So move items before previously inserted items to ensure // we have a more sensible tab order. // Note that this will be incorrect if we end up completing in random // order. newItem->stackBefore(q->childItems().first()); } }); return result; } qreal ToolBarLayoutPrivate::layoutStart(qreal layoutWidth) { qreal availableWidth = moreButtonInstance->isVisible() ? q->width() - (moreButtonInstance->width() + spacing) : q->width(); if (alignment & Qt::AlignLeft) { return layoutDirection == Qt::LeftToRight ? 0.0 : q->width(); } else if (alignment & Qt::AlignHCenter) { return (q->width() / 2) + (layoutDirection == Qt::LeftToRight ? -layoutWidth / 2.0 : layoutWidth / 2.0); } else if (alignment & Qt::AlignRight) { qreal offset = availableWidth - layoutWidth; return layoutDirection == Qt::LeftToRight ? offset : q->width() - offset; } return 0.0; } void ToolBarLayoutPrivate::maybeHideDelegate(int index, qreal ¤tWidth, qreal totalWidth) { auto delegate = sortedDelegates.at(index); if (!delegate->isVisible()) { // If the delegate isn't visible anyway, do nothing. return; } if (currentWidth + delegate->width() < totalWidth && (firstHiddenIndex < 0 || index < firstHiddenIndex)) { // If the delegate is fully visible and we have not already hidden // actions, do nothing. return; } if (delegate->isKeepVisible()) { // If the action is marked as KeepVisible, we need to try our best to // keep it in view. If the full size delegate does not fit, we try the // icon-only delegate. If that also does not fit, try and find other // actions to hide. Finally, if that also fails, we will hide the // delegate. if (currentWidth + delegate->iconWidth() > totalWidth) { // First, hide any earlier actions that are not marked as KeepVisible. for (auto currentIndex = index - 1; currentIndex >= 0; --currentIndex) { auto previousDelegate = sortedDelegates.at(currentIndex); if (!previousDelegate->isVisible() || previousDelegate->isKeepVisible()) { continue; } auto width = previousDelegate->width(); previousDelegate->hide(); hiddenActions.append(previousDelegate->action()); currentWidth -= (width + spacing); if (currentWidth + delegate->fullWidth() <= totalWidth) { delegate->showFull(); break; } else if (currentWidth + delegate->iconWidth() <= totalWidth) { delegate->showIcon(); break; } } if (currentWidth + delegate->width() <= totalWidth) { return; } // Hiding normal actions did not help enough, so go through actions // with KeepVisible set and try and collapse them to IconOnly. for (auto currentIndex = index - 1; currentIndex >= 0; --currentIndex) { auto previousDelegate = sortedDelegates.at(currentIndex); if (!previousDelegate->isVisible() || !previousDelegate->isKeepVisible()) { continue; } auto extraSpace = previousDelegate->width() - previousDelegate->iconWidth(); previousDelegate->showIcon(); currentWidth -= extraSpace; if (currentWidth + delegate->fullWidth() <= totalWidth) { delegate->showFull(); break; } else if (currentWidth + delegate->iconWidth() <= totalWidth) { delegate->showIcon(); break; } } // If that also did not work, then hide this action after all. if (currentWidth + delegate->width() > totalWidth) { delegate->hide(); hiddenActions.append(delegate->action()); } } else { delegate->showIcon(); } } else { // The action is not marked as KeepVisible and it does not fit within // the current layout, so hide it. delegate->hide(); hiddenActions.append(delegate->action()); // If this is the first item to be hidden, mark it so we know we should // also hide the following items. if (firstHiddenIndex < 0) { firstHiddenIndex = index; } } } void ToolBarLayoutPrivate::appendAction(ToolBarLayout::ActionsProperty *list, QObject *action) { auto layout = reinterpret_cast(list->data); layout->addAction(action); } qsizetype ToolBarLayoutPrivate::actionCount(ToolBarLayout::ActionsProperty *list) { return reinterpret_cast(list->data)->d->actions.count(); } QObject *ToolBarLayoutPrivate::action(ToolBarLayout::ActionsProperty *list, qsizetype index) { return reinterpret_cast(list->data)->d->actions.at(index); } void ToolBarLayoutPrivate::clearActions(ToolBarLayout::ActionsProperty *list) { reinterpret_cast(list->data)->clearActions(); } #include "moc_toolbarlayout.cpp"