diff --git a/src/controls/GlobalDrawer.qml b/src/controls/GlobalDrawer.qml index 30a01b1f..59296590 100644 --- a/src/controls/GlobalDrawer.qml +++ b/src/controls/GlobalDrawer.qml @@ -1,486 +1,504 @@ /* * Copyright 2015 Marco Martin * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU Library General Public License as * published by the Free Software Foundation; either version 2, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Library General Public License for more details * * You should have received a copy of the GNU Library General Public * License along with this program; if not, write to the * Free Software Foundation, Inc., * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import QtQuick 2.6 +import QtQuick 2.12 import QtQuick.Templates 2.0 as T2 -import QtQuick.Controls 2.0 as QQC2 +import QtQuick.Controls 2.2 as QQC2 import QtQuick.Layouts 1.2 import QtGraphicalEffects 1.0 import org.kde.kirigami 2.4 import "private" import "templates/private" /** * A drawer specialization intended for the global actions of the application * valid regardless of the application state (think about the menubar * of a desktop application). * * Example usage: * @code * import org.kde.kirigami 2.4 as Kirigami * * Kirigami.ApplicationWindow { * [...] * globalDrawer: Kirigami.GlobalDrawer { * actions: [ * Kirigami.Action { * text: "View" * iconName: "view-list-icons" * Kirigami.Action { * text: "action 1" * } * Kirigami.Action { * text: "action 2" * } * Kirigami.Action { * text: "action 3" * } * }, * Kirigami.Action { * text: "Sync" * iconName: "folder-sync" * } * ] * } * [...] * } * @endcode * */ OverlayDrawer { id: root edge: Qt.application.layoutDirection == Qt.RightToLeft ? Qt.RightEdge : Qt.LeftEdge handleClosedIcon.source: null handleOpenIcon.source: null handleVisible: (modal || !drawerOpen) && (typeof(applicationWindow)===typeof(Function) && applicationWindow() ? applicationWindow().controlsVisible : true) && (!isMenu || Settings.isMobile) enabled: !isMenu || Settings.isMobile /** * title: string * A title to be displayed on top of the drawer */ property alias title: bannerImage.title /** * icon: var * An icon to be displayed alongside the title. * It can be a QIcon, a fdo-compatible icon name, or any url understood by Image */ property alias titleIcon: bannerImage.titleIcon /** * bannerImageSource: string * An image to be used as background for the title and icon for * a decorative purpose. * It accepts any url format supported by Image */ property alias bannerImageSource: bannerImage.source /** * actions: list * The list of actions can be nested having a tree structure. * A tree depth bigger than 2 is discouraged. * * Example usage: * @code * import org.kde.kirigami 2.4 as Kirigami * * Kirigami.ApplicationWindow { * [...] * globalDrawer: Kirigami.GlobalDrawer { * actions: [ * Kirigami.Action { * text: "View" * iconName: "view-list-icons" * Kirigami.Action { * text: "action 1" * } * Kirigami.Action { * text: "action 2" * } * Kirigami.Action { * text: "action 3" * } * }, * Kirigami.Action { * text: "Sync" * iconName: "folder-sync" * } * ] * } * [...] * } * @endcode */ property list actions + /** + * header: Item + * an item that will stay on top of the drawer, + * and if the drawer contents can be scrolled, + * this item will stay still and won't scroll. + * Indended mainly for toolbars + * @since 2.12 + */ + property Item header + + /** + * bannerVisible: bool + * if true the banner area, which can contain an image, + * an icon and a title will be visible. + * By default the banner will be visible only on mobile platforms + * @since 2.12 + */ + property bool bannerVisible: Settings.isMobile /** * content: list default property * Any random Item can be instantiated inside the drawer and * will be displayed underneath the actions list. * * Example usage: * @code * import org.kde.kirigami 2.4 as Kirigami * * Kirigami.ApplicationWindow { * [...] * globalDrawer: Kirigami.GlobalDrawer { * actions: [...] * Button { * text: "Button" * onClicked: //do stuff * } * } * [...] * } * @endcode */ default property alias content: mainContent.data /** * topContent: list default property * Items that will be instantiated inside the drawer and * will be displayed on top of the actions list. * * Example usage: * @code * import org.kde.kirigami 2.4 as Kirigami * * Kirigami.ApplicationWindow { * [...] * globalDrawer: Kirigami.GlobalDrawer { * actions: [...] * topContent: [Button { * text: "Button" * onClicked: //do stuff * }] * } * [...] * } * @endcode */ property alias topContent: topContent.data /** * showContentWhenCollapsed: bool * If true, when the drawer is collapsed as a sidebar, the content items * at the bottom will be hidden (default false). * If you want to keep some items visible and some invisible, set this to * false and control the visibility/opacity of individual items, * binded to the collapsed property * @since 2.5 */ property bool showContentWhenCollapsed: false /** * showTopContentWhenCollapsed: bool * If true, when the drawer is collapsed as a sidebar, the top content items * at the top will be hidden (default false). * If you want to keep some items visible and some invisible, set this to * false and control the visibility/opacity of individual items, * binded to the collapsed property * @since 2.5 */ property bool showTopContentWhenCollapsed: false + //TODO + property bool showHeaderWhenCollapsed: false + /** * resetMenuOnTriggered: bool * * On the actions menu, whenever a leaf action is triggered, the menu * will reset to its parent. */ property bool resetMenuOnTriggered: true /** * currentSubMenu: Action * * Points to the action acting as a submenu */ readonly property Action currentSubMenu: stackView.currentItem ? stackView.currentItem.current: null /** * isMenu: bool * When true the global drawer becomes a menu on the desktop. Defauls to false. * @since 2.11 */ property bool isMenu: false /** * Notifies that the banner has been clicked */ signal bannerClicked() /** * Reverts the menu back to its initial state */ function resetMenu() { stackView.pop(stackView.get(0, T2.StackView.DontLoad)); if (root.modal) { root.drawerOpen = false; } } - rightPadding: !Settings.isMobile && mainFlickable.contentHeight > mainFlickable.height ? Units.gridUnit : Units.smallSpacing + //rightPadding: !Settings.isMobile && mainFlickable.contentHeight > mainFlickable.height ? Units.gridUnit : Units.smallSpacing - contentItem: ScrollView { + onHeaderChanged: { + if (header) { + header.parent = headerContainer + header.Layout.fillWidth = true; + } + } + + contentItem: QQC2.ScrollView { id: scrollView //ensure the attached property exists Theme.inherit: true anchors.fill: parent implicitWidth: Math.min (Units.gridUnit * 20, root.parent.width * 0.8) - horizontalScrollBarPolicy: Qt.ScrollBarAlwaysOff + QQC2.ScrollBar.horizontal.policy: QQC2.ScrollBar.AlwaysOff + QQC2.ScrollBar.vertical.anchors { + top: scrollView.top + bottom: scrollView.bottom + topMargin: headerParent.height + headerParent.y + } Flickable { id: mainFlickable contentWidth: width contentHeight: mainColumn.Layout.minimumHeight - + topMargin: headerParent.height + ColumnLayout { - id: mainColumn - width: mainFlickable.width + id: headerParent + parent: mainFlickable + anchors { + left: parent.left + right: parent.right + } spacing: 0 - height: Math.max(root.height, Layout.minimumHeight) + y: bannerImage.visible ? Math.max(headerContainer.height, -mainFlickable.contentY) - height : 0 - //TODO: cable visible of bannerimage - Item { - implicitHeight: root.collapsible - ? Math.max(collapseButton.height + Units.smallSpacing, bannerImage.Layout.preferredHeight) - : bannerImage.Layout.preferredHeight + Layout.fillWidth: true + //visible: !bannerImage.empty || root.collapsible - Layout.fillWidth: true - visible: !bannerImage.empty || root.collapsible + BannerImage { + id: bannerImage - BannerImage { - id: bannerImage - anchors.fill: parent - opacity: !root.collapsed - fillMode: Image.PreserveAspectCrop - Behavior on opacity { - OpacityAnimator { - duration: Units.longDuration - easing.type: Easing.InOutQuad - } - } - leftPadding: root.collapsible ? collapseButton.width + Units.smallSpacing*2 : topPadding - MouseArea { - anchors.fill: parent - onClicked: root.bannerClicked() - } - EdgeShadow { - edge: Qt.BottomEdge - visible: bannerImageSource != "" - anchors { - left: parent.left - right: parent.right - bottom: parent.top - } + visible: !bannerImage.empty && opacity > 0 && root.bannerVisible + opacity: !root.collapsed + fillMode: Image.PreserveAspectCrop + + Behavior on opacity { + OpacityAnimator { + duration: Units.longDuration + easing.type: Easing.InOutQuad } } - PrivateActionToolButton { - id: collapseButton - readonly property bool noTitle: (!root.title || root.title.length===0) && (!root.titleIcon || root.title.length===0) + //leftPadding: root.collapsible ? collapseButton.width + Units.smallSpacing*2 : topPadding + MouseArea { + anchors.fill: parent + onClicked: root.bannerClicked() + } + EdgeShadow { + edge: Qt.BottomEdge + visible: bannerImageSource != "" anchors { - top: parent.top left: parent.left - topMargin: root.collapsed || noTitle ? 0 : Units.smallSpacing + Units.iconSizes.large/2 - height/2 - leftMargin: root.collapsed || noTitle ? 0 : Units.smallSpacing - Behavior on leftMargin { - NumberAnimation { - duration: Units.longDuration - easing.type: Easing.InOutQuad - } - } - Behavior on topMargin { - NumberAnimation { - duration: Units.longDuration - easing.type: Easing.InOutQuad - } - } - } - - width: Units.iconSizes.smallMedium + Units.largeSpacing * 2 - height: width - - Behavior on y { - YAnimator { - duration: Units.longDuration - easing.type: Easing.InOutQuad - } + right: parent.right + bottom: parent.top } + } + } + RowLayout { + id: headerContainer + Theme.inherit: false + Theme.colorSet: Theme.Window - visible: root.collapsible - kirigamiAction: Action { - icon.name: "application-menu" - checkable: true - checked: !root.collapsed - onCheckedChanged: root.collapsed = !checked + Layout.fillWidth: true + visible: contentItem && opacity > 0 + Layout.preferredHeight: implicitHeight * opacity + opacity: !root.collapsed || showHeaderWhenCollapsed + Behavior on opacity { + //not an animator as is binded + NumberAnimation { + duration: Units.longDuration + easing.type: Easing.InOutQuad } } } + } + + + ColumnLayout { + id: mainColumn + width: mainFlickable.width + spacing: 0 + height: Math.max(root.height - headerParent.height, Layout.minimumHeight) ColumnLayout { id: topContent spacing: 0 Layout.alignment: Qt.AlignHCenter Layout.leftMargin: root.leftPadding Layout.rightMargin: root.rightPadding Layout.bottomMargin: Units.smallSpacing Layout.topMargin: root.topPadding Layout.fillWidth: true Layout.fillHeight: true Layout.preferredHeight: implicitHeight * opacity //NOTE: why this? just Layout.fillWidth: true doesn't seem sufficient //as items are added only after this column creation Layout.minimumWidth: parent.width - root.leftPadding - root.rightPadding visible: children.length > 0 && childrenRect.height > 0 && opacity > 0 opacity: !root.collapsed || showTopContentWhenCollapsed Behavior on opacity { //not an animator as is binded NumberAnimation { duration: Units.longDuration easing.type: Easing.InOutQuad } } } T2.StackView { id: stackView Layout.fillWidth: true Layout.minimumHeight: currentItem ? currentItem.implicitHeight : 0 Layout.maximumHeight: Layout.minimumHeight property ActionsMenu openSubMenu initialItem: menuComponent //NOTE: it's important those are NumberAnimation and not XAnimators // as while the animation is running the drawer may close, and //the animator would stop when not drawing see BUG 381576 popEnter: Transition { NumberAnimation { property: "x"; from: (stackView.mirrored ? -1 : 1) * -stackView.width; to: 0; duration: 400; easing.type: Easing.OutCubic } } popExit: Transition { NumberAnimation { property: "x"; from: 0; to: (stackView.mirrored ? -1 : 1) * stackView.width; duration: 400; easing.type: Easing.OutCubic } } pushEnter: Transition { NumberAnimation { property: "x"; from: (stackView.mirrored ? -1 : 1) * stackView.width; to: 0; duration: 400; easing.type: Easing.OutCubic } } pushExit: Transition { NumberAnimation { property: "x"; from: 0; to: (stackView.mirrored ? -1 : 1) * -stackView.width; duration: 400; easing.type: Easing.OutCubic } } replaceEnter: Transition { NumberAnimation { property: "x"; from: (stackView.mirrored ? -1 : 1) * stackView.width; to: 0; duration: 400; easing.type: Easing.OutCubic } } replaceExit: Transition { NumberAnimation { property: "x"; from: 0; to: (stackView.mirrored ? -1 : 1) * -stackView.width; duration: 400; easing.type: Easing.OutCubic } } } Item { Layout.fillWidth: true Layout.fillHeight: root.actions.length>0 Layout.minimumHeight: Units.smallSpacing } ColumnLayout { id: mainContent Layout.alignment: Qt.AlignHCenter Layout.leftMargin: root.leftPadding Layout.rightMargin: root.rightPadding Layout.fillWidth: true Layout.fillHeight: true //NOTE: why this? just Layout.fillWidth: true doesn't seem sufficient //as items are added only after this column creation Layout.minimumWidth: parent.width - root.leftPadding - root.rightPadding visible: children.length > 0 && (opacity > 0 || mainContentAnimator.running) opacity: !root.collapsed || showContentWhenCollapsed Behavior on opacity { OpacityAnimator { id: mainContentAnimator duration: Units.longDuration easing.type: Easing.InOutQuad } } } Item { Layout.minimumWidth: Units.smallSpacing Layout.minimumHeight: root.bottomPadding } Component { id: menuComponent Column { spacing: 0 property alias model: actionsRepeater.model property Action current property int level: 0 Layout.maximumHeight: Layout.minimumHeight BasicListItem { id: backItem visible: level > 0 supportsMouseEvents: true icon: (LayoutMirroring.enabled ? "go-previous-symbolic-rtl" : "go-previous-symbolic") label: MnemonicData.richTextLabel MnemonicData.enabled: backItem.enabled && backItem.visible MnemonicData.controlType: MnemonicData.MenuItem MnemonicData.label: qsTr("Back") separatorVisible: false onClicked: stackView.pop() } Shortcut { sequence: backItem.MnemonicData.sequence onActivated: backItem.clicked() } Repeater { id: actionsRepeater model: root.actions delegate: Column { width: parent.width GlobalDrawerActionItem { id: drawerItem width: parent.width } Repeater { model: drawerItem.visible && modelData.hasOwnProperty("expandible") && modelData.expandible ? modelData.children : null delegate: GlobalDrawerActionItem { width: parent.width leftPadding: Units.largeSpacing * 2 opacity: !root.collapsed } } } } } } } } } } diff --git a/src/controls/private/globaltoolbar/PageRowGlobalToolBarStyleGroup.qml b/src/controls/private/globaltoolbar/PageRowGlobalToolBarStyleGroup.qml index 98687c57..36020c85 100644 --- a/src/controls/private/globaltoolbar/PageRowGlobalToolBarStyleGroup.qml +++ b/src/controls/private/globaltoolbar/PageRowGlobalToolBarStyleGroup.qml @@ -1,52 +1,53 @@ /* * Copyright 2018 Marco Martin * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU Library General Public License as * published by the Free Software Foundation; either version 2 or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Library General Public License for more details * * You should have received a copy of the GNU Library General Public * License along with this program; if not, write to the * Free Software Foundation, Inc., * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import QtQuick 2.1 import org.kde.kirigami 2.4 as Kirigami QtObject { id: globalToolBar property int style: Kirigami.ApplicationHeaderStyle.None readonly property int actualStyle: { if (style == Kirigami.ApplicationHeaderStyle.Auto) { //Legacy: if ApplicationHeader or ToolbarApplicationHeader are in the header or footer, disable the toolbar here if (typeof applicationWindow !== "undefined" && applicationWindow().header && applicationWindow().header.toString().indexOf("ApplicationHeader") !== -1) { return Kirigami.ApplicationHeaderStyle.None } //non legacy logic return (Kirigami.Settings.isMobile ? (root.wideMode ? Kirigami.ApplicationHeaderStyle.Titles : Kirigami.ApplicationHeaderStyle.Breadcrumb) : Kirigami.ApplicationHeaderStyle.ToolBar) } else { //forbid ToolBar on mobile systems return Kirigami.Settings.isMobile && style == Kirigami.ApplicationHeaderStyle.ToolBar ? Kirigami.ApplicationHeaderStyle.Breadcrumb : style; } } property var showNavigationButtons: (style != Kirigami.ApplicationHeaderStyle.TabBar && (!Kirigami.Settings.isMobile || Qt.platform.os == "ios")) ? (Kirigami.ApplicationHeaderStyle.ShowBackButton | Kirigami.ApplicationHeaderStyle.ShowForwardButton) : Kirigami.ApplicationHeaderStyle.NoNavigationButtons property bool separatorVisible: true property int toolbarActionAlignment: Qt.AlignRight property int minimumHeight: 0 + // FIXME: Figure out the exact standard size of a Toolbar property int preferredHeight: (actualStyle == Kirigami.ApplicationHeaderStyle.ToolBar ? Kirigami.Units.iconSizes.medium : Kirigami.Units.gridUnit * 1.8) + Kirigami.Units.smallSpacing * 2 property int maximumHeight: preferredHeight } diff --git a/src/controls/templates/AbstractApplicationHeader.qml b/src/controls/templates/AbstractApplicationHeader.qml index 959f9cd7..79d5cebd 100644 --- a/src/controls/templates/AbstractApplicationHeader.qml +++ b/src/controls/templates/AbstractApplicationHeader.qml @@ -1,205 +1,205 @@ /* * Copyright 2015 Marco Martin * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU Library General Public License as * published by the Free Software Foundation; either version 2 or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Library General Public License for more details * * You should have received a copy of the GNU Library General Public * License along with this program; if not, write to the * Free Software Foundation, Inc., * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import QtQuick 2.7 import QtQuick.Layouts 1.2 import "private" import org.kde.kirigami 2.4 /** * An item that can be used as a title for the application. * Scrolling the main page will make it taller or shorter (trough the point of going away) * It's a behavior similar to the typical mobile web browser addressbar * the minimum, preferred and maximum heights of the item can be controlled with * * minimumHeight: default is 0, i.e. hidden * * preferredHeight: default is Units.gridUnit * 1.6 * * preferredHeight: default is Units.gridUnit * 3 * * To achieve a titlebar that stays completely fixed just set the 3 sizes as the same * @inherit QtQuick.Item */ Item { id: root z: 90 property int minimumHeight: 0 - property int preferredHeight: Units.gridUnit * 2 + property int preferredHeight: Units.iconSizes.medium + Units.smallSpacing * 2 property int maximumHeight: Units.gridUnit * 3 property PageRow pageRow: __appWindow.pageStack property Page page: pageRow.currentItem default property alias contentItem: mainItem.data readonly property int paintedHeight: headerItem.y + headerItem.height - 1 property int leftPadding: 0 property int topPadding: 0 property int rightPadding: 0 property int bottomPadding: 0 property bool separatorVisible: true LayoutMirroring.enabled: Qt.application.layoutDirection == Qt.RightToLeft LayoutMirroring.childrenInherit: true //FIXME: remove property QtObject __appWindow: applicationWindow(); anchors { left: parent.left right: parent.right } implicitHeight: preferredHeight /** * background: Item * This property holds the background item. * Note: the background will be automatically sized as the whole control */ property Item background onBackgroundChanged: { background.z = -1; background.parent = headerItem; background.anchors.fill = headerItem; } onMinimumHeightChanged: implicitHeight = preferredHeight; onPreferredHeightChanged: implicitHeight = preferredHeight; opacity: height > 0 ? 1 : 0 NumberAnimation { id: heightAnim target: root property: "implicitHeight" duration: Units.longDuration easing.type: Easing.InOutQuad } Connections { target: __appWindow onControlsVisibleChanged: { heightAnim.from = root.implicitHeight heightAnim.to = __appWindow.controlsVisible ? root.preferredHeight : 0; heightAnim.restart(); } } Item { id: headerItem property real computedRootHeight: root.preferredHeight anchors { left: parent.left right: parent.right bottom: parent.bottom } height: __appWindow.reachableMode && __appWindow.reachableModeEnabled ? root.maximumHeight : (root.minimumHeight > 0 ? Math.max(root.height, root.minimumHeight) : root.preferredHeight) //FIXME: see FIXME below Connections { target: root.page ? root.page.globalToolBarItem : null enabled: headerSlideConnection.passive && target onImplicitHeightChanged: root.implicitHeight = root.page.globalToolBarItem.implicitHeight } Connections { id: headerSlideConnection target: root.page ? root.page.flickable : null enabled: !passive property int oldContentY property bool updatingContentY: false //FIXME HACK: if we are in global mode, meaning if we are the toolbar showing the global breadcrumb (but the pages are showing their own toolbar), not to try to mess with page contentY. //A better solution is needed readonly property bool passive: root.pageRow && parent.parent == root.pageRow && root.pageRow.globalToolBar.actualStyle !== ApplicationHeaderStyle.TabBar && root.pageRow.globalToolBar.actualStyle != ApplicationHeaderStyle.Breadcrumb onContentYChanged: { if (updatingContentY || !Settings.isMobile || !__appWindow.controlsVisible || !root.page) { oldContentY = root.page.flickable.contentY; return; //TODO: merge //if moves but not dragging, just update oldContentY } else if (!root.page.flickable.dragging) { oldContentY = root.page.flickable.contentY; return; } if ((root.pageRow ? root.pageRow.wideMode : __appWindow.wideScreen) || !Settings.isMobile) { root.implicitHeight = root.preferredHeight; } else { var oldHeight = root.implicitHeight; root.implicitHeight = Math.max(root.minimumHeight, Math.min(root.preferredHeight, root.implicitHeight + oldContentY - root.page.flickable.contentY)); //if the implicitHeight is changed, use that to simulate scroll if (oldHeight !== implicitHeight) { updatingContentY = true; root.page.flickable.contentY -= (oldHeight - root.implicitHeight); updatingContentY = false; } else { oldContentY = root.page.flickable.contentY; } } } onMovementEnded: { if ((root.pageRow ? root.pageRow.wideMode : __appWindow.wideScreen) || !Settings.isMobile) { return; } if (root.height > root.minimumHeight + (root.preferredHeight - root.minimumHeight)/2 ) { root.implicitHeight = root.preferredHeight; } else { root.implicitHeight = root.minimumHeight; } } } Connections { target: pageRow onCurrentItemChanged: { if (!root.page) { return; } if (root.page.flickable) { headerSlideConnection.oldContentY = root.page.flickable.contentY; } else { headerSlideConnection.oldContentY = 0; } root.implicitHeight = root.preferredHeight; } } Item { id: mainItem clip: childrenRect.width > width anchors { fill: parent leftMargin: root.leftPadding topMargin: root.topPadding rightMargin: root.rightPadding bottomMargin: root.bottomPadding } } } } diff --git a/src/icon.cpp b/src/icon.cpp index f7e6db6c..69ba7aa9 100644 --- a/src/icon.cpp +++ b/src/icon.cpp @@ -1,593 +1,594 @@ /* * Copyright 2011 Marco Martin * Copyright 2014 Aleix Pol Gonzalez * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU Library General Public License as * published by the Free Software Foundation; either version 2, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details * * You should have received a copy of the GNU Library General Public * License along with this program; if not, write to the * Free Software Foundation, Inc., * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ #include "icon.h" #include "libkirigami/platformtheme.h" #include #include #include #include #include #include #include #include #include #include #include #include #include class ManagedTextureNode : public QSGSimpleTextureNode { Q_DISABLE_COPY(ManagedTextureNode) public: ManagedTextureNode(); void setTexture(QSharedPointer texture); private: QSharedPointer m_texture; }; ManagedTextureNode::ManagedTextureNode() {} void ManagedTextureNode::setTexture(QSharedPointer texture) { m_texture = texture; QSGSimpleTextureNode::setTexture(texture.data()); } typedef QHash > > TexturesCache; struct ImageTexturesCachePrivate { TexturesCache cache; }; class ImageTexturesCache { public: ImageTexturesCache(); ~ImageTexturesCache(); /** * @returns the texture for a given @p window and @p image. * * If an @p image id is the same as one already provided before, we won't create * a new texture and return a shared pointer to the existing texture. */ QSharedPointer loadTexture(QQuickWindow *window, const QImage &image, QQuickWindow::CreateTextureOptions options); QSharedPointer loadTexture(QQuickWindow *window, const QImage &image); private: QScopedPointer d; }; ImageTexturesCache::ImageTexturesCache() : d(new ImageTexturesCachePrivate) { } ImageTexturesCache::~ImageTexturesCache() { } QSharedPointer ImageTexturesCache::loadTexture(QQuickWindow *window, const QImage &image, QQuickWindow::CreateTextureOptions options) { qint64 id = image.cacheKey(); QSharedPointer texture = d->cache.value(id).value(window).toStrongRef(); if (!texture) { auto cleanAndDelete = [this, window, id](QSGTexture* texture) { QHash >& textures = (d->cache)[id]; textures.remove(window); if (textures.isEmpty()) d->cache.remove(id); delete texture; }; texture = QSharedPointer(window->createTextureFromImage(image, options), cleanAndDelete); (d->cache)[id][window] = texture.toWeakRef(); } //if we have a cache in an atlas but our request cannot use an atlassed texture //create a new texture and use that //don't use removedFromAtlas() as that requires keeping a reference to the non atlased version if (!(options & QQuickWindow::TextureCanUseAtlas) && texture->isAtlasTexture()) { texture = QSharedPointer(window->createTextureFromImage(image, options)); } return texture; } QSharedPointer ImageTexturesCache::loadTexture(QQuickWindow *window, const QImage &image) { return loadTexture(window, image, nullptr); } Q_GLOBAL_STATIC(ImageTexturesCache, s_iconImageCache) Icon::Icon(QQuickItem *parent) : QQuickItem(parent), m_smooth(false), m_changed(false), m_active(false), m_selected(false), m_isMask(false) { setFlag(ItemHasContents, true); //FIXME: not necessary anymore connect(qApp, &QGuiApplication::paletteChanged, this, &QQuickItem::polish); connect(this, &QQuickItem::enabledChanged, this, &QQuickItem::polish); } Icon::~Icon() { } void Icon::setSource(const QVariant &icon) { if (m_source == icon) { return; } m_source = icon; m_monochromeHeuristics.clear(); if (!m_theme) { m_theme = static_cast(qmlAttachedPropertiesObject(this, true)); Q_ASSERT(m_theme); connect(m_theme, &Kirigami::PlatformTheme::colorsChanged, this, &QQuickItem::polish); } if (icon.type() == QVariant::String) { const QString iconSource = icon.toString(); m_isMaskHeuristic = (iconSource.endsWith(QLatin1String("-symbolic")) || iconSource.endsWith(QLatin1String("-symbolic-rtl")) || iconSource.endsWith(QLatin1String("-symbolic-ltr"))); emit isMaskChanged(); } if (m_networkReply) { //if there was a network query going on, interrupt it m_networkReply->close(); } m_loadedImage = QImage(); polish(); emit sourceChanged(); + emit validChanged(); } QVariant Icon::source() const { return m_source; } void Icon::setActive(const bool active) { if (active == m_active) { return; } m_active = active; polish(); emit activeChanged(); } bool Icon::active() const { return m_active; } bool Icon::valid() const { return !m_source.isNull(); } void Icon::setSelected(const bool selected) { if (selected == m_selected) { return; } m_selected = selected; polish(); emit selectedChanged(); } bool Icon::selected() const { return m_selected; } void Icon::setIsMask(bool mask) { if (m_isMask == mask) { return; } m_isMask = mask; m_isMaskHeuristic = mask; polish(); emit isMaskChanged(); } bool Icon::isMask() const { return m_isMask || m_isMaskHeuristic; } void Icon::setColor(const QColor &color) { if (m_color == color) { return; } m_color = color; polish(); emit colorChanged(); } QColor Icon::color() const { return m_color; } int Icon::implicitWidth() const { return 32; } int Icon::implicitHeight() const { return 32; } void Icon::setSmooth(const bool smooth) { if (smooth == m_smooth) { return; } m_smooth = smooth; polish(); emit smoothChanged(); } bool Icon::smooth() const { return m_smooth; } QSGNode* Icon::updatePaintNode(QSGNode* node, QQuickItem::UpdatePaintNodeData* /*data*/) { if (m_source.isNull()) { delete node; return Q_NULLPTR; } if (m_changed || node == nullptr) { const QSize itemSize(width(), height()); QRect nodeRect(QPoint(0,0), itemSize); ManagedTextureNode* mNode = dynamic_cast(node); if (!mNode) { delete node; mNode = new ManagedTextureNode; } if (itemSize.width() != 0 && itemSize.height() != 0) { const auto multiplier = QCoreApplication::instance()->testAttribute(Qt::AA_UseHighDpiPixmaps) ? 1 : (window() ? window()->devicePixelRatio() : qGuiApp->devicePixelRatio()); const QSize size = itemSize * multiplier; mNode->setTexture(s_iconImageCache->loadTexture(window(), m_icon)); if (m_icon.size() != size) { // At this point, the image will already be scaled, but we need to output it in // the correct aspect ratio, painted centered in the viewport. So: QRect destination(QPoint(0, 0), m_icon.size().scaled(itemSize, Qt::KeepAspectRatio)); destination.moveCenter(nodeRect.center()); nodeRect = destination; } } mNode->setRect(nodeRect); node = mNode; if (m_smooth) { mNode->setFiltering(QSGTexture::Linear); } m_changed = false; } return node; } void Icon::geometryChanged(const QRectF &newGeometry, const QRectF &oldGeometry) { QQuickItem::geometryChanged(newGeometry, oldGeometry); if (newGeometry.size() != oldGeometry.size()) { polish(); } } void Icon::handleRedirect(QNetworkReply* reply) { QNetworkAccessManager* qnam = reply->manager(); if (reply->error() != QNetworkReply::NoError) { return; } const QUrl possibleRedirectUrl = reply->attribute(QNetworkRequest::RedirectionTargetAttribute).toUrl(); if (!possibleRedirectUrl.isEmpty()) { const QUrl redirectUrl = reply->url().resolved(possibleRedirectUrl); if (redirectUrl == reply->url()) { // no infinite redirections thank you very much reply->deleteLater(); return; } reply->deleteLater(); QNetworkRequest request(possibleRedirectUrl); request.setAttribute(QNetworkRequest::CacheLoadControlAttribute, QNetworkRequest::PreferCache); m_networkReply = qnam->get(request); connect(m_networkReply.data(), &QNetworkReply::finished, this, [this](){handleFinished(m_networkReply); }); } } void Icon::handleFinished(QNetworkReply* reply) { reply->deleteLater(); if (!reply->attribute(QNetworkRequest::RedirectionTargetAttribute).isNull()) { handleRedirect(reply); return; } m_loadedImage = QImage(); const QString filename = reply->url().fileName(); if (!m_loadedImage.load(reply, filename.mid(filename.indexOf(QLatin1Char('.'))).toLatin1().constData())) { qWarning() << "received broken image" << reply->url(); // broken image from data, inform the user of this with some useful broken-image thing... const QSize size = QSize(width(), height()) * (window() ? window()->devicePixelRatio() : qApp->devicePixelRatio()); m_loadedImage = QIcon::fromTheme(m_fallback).pixmap(size, iconMode(), QIcon::On).toImage(); } polish(); } void Icon::updatePolish() { QQuickItem::updatePolish(); const QSize itemSize(width(), height()); if (itemSize.width() != 0 && itemSize.height() != 0) { const auto multiplier = QCoreApplication::instance()->testAttribute(Qt::AA_UseHighDpiPixmaps) ? 1 : (window() ? window()->devicePixelRatio() : qGuiApp->devicePixelRatio()); const QSize size = itemSize * multiplier; switch(m_source.type()){ case QVariant::Pixmap: m_icon = m_source.value().toImage(); break; case QVariant::Image: m_icon = m_source.value(); break; case QVariant::Bitmap: m_icon = m_source.value().toImage(); break; case QVariant::Icon: m_icon = m_source.value().pixmap(size, iconMode(), QIcon::On).toImage(); break; case QVariant::Url: case QVariant::String: m_icon = findIcon(size); break; case QVariant::Brush: //todo: fill here too? case QVariant::Color: m_icon = QImage(size, QImage::Format_Alpha8); m_icon.fill(m_source.value()); break; default: break; } if (m_icon.isNull()){ m_icon = QImage(size, QImage::Format_Alpha8); m_icon.fill(Qt::transparent); } const QColor tintColor = !m_color.isValid() || m_color == Qt::transparent ? (m_selected ? m_theme->highlightedTextColor() : m_theme->textColor()) : m_color; //TODO: initialize m_isMask with icon.isMask() if (tintColor.alpha() > 0 && (isMask() || guessMonochrome(m_icon))) { QPainter p(&m_icon); p.setCompositionMode(QPainter::CompositionMode_SourceIn); p.fillRect(m_icon.rect(), tintColor); p.end(); } } m_changed = true; update(); } QImage Icon::findIcon(const QSize &size) { QImage img; QString iconSource = m_source.toString(); if (iconSource.startsWith(QLatin1String("image://"))) { QUrl iconUrl(iconSource); QString iconProviderId = iconUrl.host(); QString iconId = iconUrl.path(); // QRC paths are not correctly handled by .path() if (iconId.size() >=2 && iconId.startsWith(QLatin1String("/:"))) { iconId.remove(0, 1); } QSize actualSize; QQuickImageProvider* imageProvider = dynamic_cast( qmlEngine(this)->imageProvider(iconProviderId)); if (!imageProvider) return img; switch(imageProvider->imageType()){ case QQmlImageProviderBase::Image: img = imageProvider->requestImage(iconId, &actualSize, size); break; case QQmlImageProviderBase::Pixmap: img = imageProvider->requestPixmap(iconId, &actualSize, size).toImage(); break; case QQmlImageProviderBase::Texture: case QQmlImageProviderBase::Invalid: case QQmlImageProviderBase::ImageResponse: //will have to investigate this more break; } } else if(iconSource.startsWith(QLatin1String("http://")) || iconSource.startsWith(QLatin1String("https://"))) { if(!m_loadedImage.isNull()) { return m_loadedImage.scaled(size, Qt::KeepAspectRatio, m_smooth ? Qt::SmoothTransformation : Qt::FastTransformation ); } const auto url = m_source.toUrl(); QQmlEngine* engine = qmlEngine(this); QNetworkAccessManager* qnam; if (engine && (qnam = engine->networkAccessManager()) && (!m_networkReply || m_networkReply->url() != url)) { QNetworkRequest request(url); request.setAttribute(QNetworkRequest::CacheLoadControlAttribute, QNetworkRequest::PreferCache); m_networkReply = qnam->get(request); connect(m_networkReply.data(), &QNetworkReply::finished, this, [this](){ handleFinished(m_networkReply); }); } // Temporary icon while we wait for the real image to load... img = QIcon::fromTheme(QStringLiteral("image-x-icon")).pixmap(size, iconMode(), QIcon::On).toImage(); } else { if (iconSource.startsWith(QLatin1String("qrc:/"))) { iconSource = iconSource.mid(3); } else if (iconSource.startsWith(QLatin1String("file:/"))) { iconSource = QUrl(iconSource).path(); } QIcon icon; const bool isPath = iconSource.contains(QLatin1String("/")); if (isPath) { icon = QIcon(iconSource); } else { if (icon.isNull()) { icon = m_theme->iconFromTheme(iconSource, m_color); } } if (!icon.isNull()) { img = icon.pixmap(size, iconMode(), QIcon::On).toImage(); /*const QColor tintColor = !m_color.isValid() || m_color == Qt::transparent ? (m_selected ? m_theme->highlightedTextColor() : m_theme->textColor()) : m_color; if (m_isMask || icon.isMask() || iconSource.endsWith(QLatin1String("-symbolic")) || iconSource.endsWith(QLatin1String("-symbolic-rtl")) || iconSource.endsWith(QLatin1String("-symbolic-ltr")) || guessMonochrome(img)) { QPainter p(&img); p.setCompositionMode(QPainter::CompositionMode_SourceIn); p.fillRect(img.rect(), tintColor); p.end(); }*/ } } return img; } QIcon::Mode Icon::iconMode() const { if (!isEnabled()) { return QIcon::Disabled; } else if (m_selected) { return QIcon::Selected; } else if (m_active) { return QIcon::Active; } return QIcon::Normal; } bool Icon::guessMonochrome(const QImage &img) { //don't try for too big images if (img.width() >= 256 || m_theme->supportsIconColoring()) { return false; } // round size to a standard size. hardcode as we can't use KIconLoader int stdSize; if (img.width() <= 16) { stdSize = 16; } else if (img.width() <= 22) { stdSize = 22; } else if (img.width() <= 24) { stdSize = 24; } else if (img.width() <= 32) { stdSize = 32; } else if (img.width() <= 48) { stdSize = 48; } else if (img.width() <= 64) { stdSize = 64; } else { stdSize = 128; } auto findIt = m_monochromeHeuristics.constFind(stdSize); if (findIt != m_monochromeHeuristics.constEnd()) { return findIt.value(); } QHash dist; int transparentPixels = 0; int saturatedPixels = 0; for(int x=0; x < img.width(); x++) { for(int y=0; y < img.height(); y++) { QColor color = QColor::fromRgba(qUnpremultiply(img.pixel(x, y))); if (color.alpha() < 100) { ++transparentPixels; continue; } else if (color.saturation() > 84) { ++saturatedPixels; } dist[qGray(color.rgb())]++; } } QMultiMap reverseDist; auto it = dist.constBegin(); std::vector probabilities(dist.size()); qreal entropy = 0; while (it != dist.constEnd()) { reverseDist.insertMulti(it.value(), it.key()); qreal probability = qreal(it.value()) / qreal(img.size().width() * img.size().height() - transparentPixels); entropy -= probability * log(probability) / log(255); ++it; } // Arbitrarly low values of entropy and colored pixels m_monochromeHeuristics[stdSize] = saturatedPixels <= (img.size().width()*img.size().height() - transparentPixels) * 0.3 && entropy <= 0.3; return m_monochromeHeuristics[stdSize]; } QString Icon::fallback() const { return m_fallback; } void Icon::setFallback(const QString& fallback) { if (m_fallback != fallback) { m_fallback = fallback; Q_EMIT fallbackChanged(fallback); } } #include "moc_icon.cpp"