/* SPDX-FileCopyrightText: 2021 Devin Lin SPDX-FileCopyrightText: 2021 Noah Davis SPDX-FileCopyrightText: 2022 ivan tkachenko SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL */ pragma ComponentBehavior: Bound import QtQuick import QtQml import QtQuick.Layouts import QtQuick.Templates as T import QtQuick.Controls as QQC2 import org.kde.kirigami as Kirigami /** * @brief Popup dialog that is used for short tasks and user interaction. * * Dialog consists of three components: the header, the content, * and the footer. * * By default, the header is a heading with text specified by the * `title` property. * * By default, the footer consists of a row of buttons specified by * the `standardButtons` and `customFooterActions` properties. * * The `implicitHeight` and `implicitWidth` of the dialog contentItem is * the primary hint used for the dialog size. The dialog will be the * minimum size required for the header, footer and content unless * it is larger than `maximumHeight` and `maximumWidth`. Use * `preferredHeight` and `preferredWidth` in order to manually specify * a size for the dialog. * * If the content height exceeds the maximum height of the dialog, the * dialog's contents will become scrollable. * * If the contentItem is a ListView, the dialog will take care of the * necessary scrollbars and scrolling behaviour. Do not attempt * to nest ListViews (it must be the top level item), as the scrolling * behaviour will not be handled. Use ListView's `header` and `footer` instead. * * Example for a selection dialog: * * @code{.qml} * import QtQuick * import QtQuick.Layouts * import QtQuick.Controls as QQC2 * import org.kde.kirigami as Kirigami * * Kirigami.Dialog { * title: i18n("Dialog") * padding: 0 * preferredWidth: Kirigami.Units.gridUnit * 16 * * standardButtons: Kirigami.Dialog.Ok | Kirigami.Dialog.Cancel * * onAccepted: console.log("OK button pressed") * onRejected: console.log("Rejected") * * ColumnLayout { * spacing: 0 * Repeater { * model: 5 * delegate: QQC2.CheckDelegate { * topPadding: Kirigami.Units.smallSpacing * 2 * bottomPadding: Kirigami.Units.smallSpacing * 2 * Layout.fillWidth: true * text: modelData * } * } * } * } * @endcode * * Example with scrolling (ListView scrolling behaviour is handled by the Dialog): * * @code{.qml} * import QtQuick * import QtQuick.Layouts * import QtQuick.Controls as QQC2 * import org.kde.kirigami as Kirigami * * Kirigami.Dialog { * id: scrollableDialog * title: i18n("Select Number") * * ListView { * id: listView * // hints for the dialog dimensions * implicitWidth: Kirigami.Units.gridUnit * 16 * implicitHeight: Kirigami.Units.gridUnit * 16 * * model: 100 * delegate: QQC2.RadioDelegate { * topPadding: Kirigami.Units.smallSpacing * 2 * bottomPadding: Kirigami.Units.smallSpacing * 2 * implicitWidth: listView.width * text: modelData * } * } * } * @endcode * * There are also sub-components of the Dialog that target specific usecases, * and can reduce boilerplate code if used: * * @see PromptDialog * @see MenuDialog * * @inherit QtQuick.QtObject */ T.Dialog { id: root /** * @brief This property holds the dialog's contents; includes Items and QtObjects. * @property list dialogData */ default property alias dialogData: contentControl.contentData /** * @brief This property holds the content items of the dialog. * * The initial height and width of the dialog is calculated from the * `implicitWidth` and `implicitHeight` of the content. * * @property list dialogChildren */ property alias dialogChildren: contentControl.contentChildren /** * @brief This property sets the absolute maximum height the dialog can have. * * The height restriction is solely applied on the content, so if the * maximum height given is not larger than the height of the header and * footer, it will be ignored. * * This is the window height, subtracted by largeSpacing on both the top * and bottom. */ readonly property real absoluteMaximumHeight: parent ? (parent.height - Kirigami.Units.largeSpacing * 2) : Infinity /** * @brief This property holds the absolute maximum width the dialog can have. * * By default, it is the window width, subtracted by largeSpacing on both * the top and bottom. */ readonly property real absoluteMaximumWidth: parent ? (parent.width - Kirigami.Units.largeSpacing * 2) : Infinity readonly property real __borderWidth: 1 /** * @brief This property holds the maximum height the dialog can have * (including the header and footer). * * The height restriction is solely enforced on the content, so if the * maximum height given is not larger than the height of the header and * footer, it will be ignored. * * By default, this is `absoluteMaximumHeight`. */ property real maximumHeight: absoluteMaximumHeight /** * @brief This property holds the maximum width the dialog can have. * * By default, this is `absoluteMaximumWidth`. */ property real maximumWidth: absoluteMaximumWidth /** * @brief This property holds the preferred height of the dialog. * * The content will receive a hint for how tall it should be to have * the dialog to be this height. * * If the content, header or footer require more space, then the height * of the dialog will expand to the necessary amount of space. */ property real preferredHeight: -1 /** * @brief This property holds the preferred width of the dialog. * * The content will receive a hint for how wide it should be to have * the dialog be this wide. * * If the content, header or footer require more space, then the width * of the dialog will expand to the necessary amount of space. */ property real preferredWidth: -1 /** * @brief This property holds the component to the left of the footer buttons. */ property Component footerLeadingComponent /** * @brief his property holds the component to the right of the footer buttons. */ property Component footerTrailingComponent /** * @brief This property sets whether to show the close button in the header. */ property bool showCloseButton: true /** * @brief This property sets whether the footer button style should be flat. */ property bool flatFooterButtons: false /** * @brief This property holds the custom actions displayed in the footer. * * Example usage: * @code{.qml} * import QtQuick * import org.kde.kirigami as Kirigami * * Kirigami.PromptDialog { * id: dialog * title: i18n("Confirm Playback") * subtitle: i18n("Are you sure you want to play this song? It's really loud!") * * standardButtons: Kirigami.Dialog.Cancel * customFooterActions: [ * Kirigami.Action { * text: i18n("Play") * icon.name: "media-playback-start" * onTriggered: { * //... * dialog.close(); * } * } * ] * } * @endcode * * @see org::kde::kirigami::Action */ property list customFooterActions // DialogButtonBox should NOT contain invisible buttons, because in Qt 6 // ListView preserves space even for invisible items. readonly property list __visibleCustomFooterActions: customFooterActions .filter(action => !(action instanceof Kirigami.Action) || action?.visible) function standardButton(button): T.AbstractButton { // in case a footer is redefined if (footer instanceof T.DialogButtonBox) { return footer.standardButton(button); } else if (footer === footerToolBar) { return dialogButtonBox.standardButton(button); } else { return null; } } function customFooterButton(action: T.Action): T.AbstractButton { if (!action) { // Even if there's a null object in the list of actions, we should // not return a button for it. return null; } const index = __visibleCustomFooterActions.indexOf(action); if (index < 0) { return null; } return customFooterButtons.itemAt(index) as T.AbstractButton; } z: Kirigami.OverlayZStacking.z // calculate dimensions and in case footer is wider than content, use that implicitWidth: Math.max(contentItem.implicitWidth, footerToolBar.implicitWidth, heading.implicitWidth) + leftPadding + rightPadding // maximum width enforced from our content (one source of truth) to avoid binding loops implicitHeight: contentItem.implicitHeight + topPadding + bottomPadding + (implicitHeaderHeight > 0 ? implicitHeaderHeight + spacing : 0) + (implicitFooterHeight > 0 ? implicitFooterHeight + spacing : 0); // misc. dialog settings closePolicy: QQC2.Popup.CloseOnEscape | QQC2.Popup.CloseOnReleaseOutside modal: true clip: false padding: 0 horizontalPadding: __borderWidth + padding // determine parent so that popup knows which window to popup in // we want to open the dialog in the center of the window, if possible Component.onCompleted: { if (typeof applicationWindow !== "undefined") { parent = applicationWindow().overlay; } } // center dialog x: parent ? Math.round((parent.width - width) / 2) : 0 y: parent ? Math.round((parent.height - height) / 2) + Kirigami.Units.gridUnit * 2 * (1 - opacity) : 0 // move animation // dialog enter and exit transitions enter: Transition { NumberAnimation { property: "opacity"; from: 0; to: 1; easing.type: Easing.InOutQuad; duration: Kirigami.Units.longDuration } } exit: Transition { NumberAnimation { property: "opacity"; from: 1; to: 0; easing.type: Easing.InOutQuad; duration: Kirigami.Units.longDuration } } // black background, fades in and out QQC2.Overlay.modal: Rectangle { color: Qt.rgba(0, 0, 0, 0.3) // the opacity of the item is changed internally by QQuickPopup on open/close Behavior on opacity { OpacityAnimator { duration: Kirigami.Units.longDuration easing.type: Easing.InOutQuad } } } // dialog view background background: Kirigami.ShadowedRectangle { id: rect Kirigami.Theme.colorSet: Kirigami.Theme.View Kirigami.Theme.inherit: false color: Kirigami.Theme.backgroundColor radius: Kirigami.Units.cornerRadius shadow { size: radius * 2 color: Qt.rgba(0, 0, 0, 0.3) yOffset: 1 } border { width: root.__borderWidth color: Kirigami.ColorUtils.linearInterpolation(Kirigami.Theme.backgroundColor, Kirigami.Theme.textColor, Kirigami.Theme.frameContrast); } } // dialog content contentItem: QQC2.ScrollView { id: contentControl // ensure view colour scheme, and background color Kirigami.Theme.inherit: false Kirigami.Theme.colorSet: Kirigami.Theme.View QQC2.ScrollBar.horizontal.policy: QQC2.ScrollBar.AlwaysOff // height of everything else in the dialog other than the content property real otherHeights: (root.header?.height ?? 0) + (root.footer?.height ?? 0) + root.topPadding + root.bottomPadding; property real calculatedMaximumWidth: Math.min(root.absoluteMaximumWidth, root.maximumWidth) - root.leftPadding - root.rightPadding property real calculatedMaximumHeight: Math.min(root.absoluteMaximumHeight, root.maximumHeight) - root.topPadding - root.bottomPadding property real calculatedImplicitWidth: (contentChildren.length === 1 && contentChildren[0].implicitWidth > 0 ? contentChildren[0].implicitWidth : contentItem.implicitWidth) + leftPadding + rightPadding property real calculatedImplicitHeight: (contentChildren.length === 1 && contentChildren[0].implicitHeight > 0? contentChildren[0].implicitHeight: contentItem.implicitHeight) + topPadding + bottomPadding onContentItemChanged: { const contentFlickable = contentItem as Flickable; if (contentFlickable) { /* Why this is necessary? A Flickable mainItem syncs its size with the contents only on startup, and if the contents can change their size dinamically afterwards (wrapping text does that), the contentsize will be wrong see BUG 477257. We also don't do this declaratively but only we are sure a contentItem is declared/created as just accessing the property would create an internal Flickable, making it impossible to assign custom flickables/listviews to the Dialog. */ contentFlickable.contentHeight = Qt.binding(() => calculatedImplicitHeight); contentFlickable.clip = true; } } // how do we deal with the scrollbar width? // - case 1: the dialog itself has the preferredWidth set // -> we hint a width to the content so it shrinks to give space to the scrollbar // - case 2: preferredWidth not set, so we are using the content's implicit width // -> we expand the dialog's width to accommodate the scrollbar width (to respect the content's desired width) // don't enforce preferred width and height if not set (-1), and expand to a larger implicit size property real preferredWidth: Math.max(root.preferredWidth, calculatedImplicitWidth) property real preferredHeight: Math.max(root.preferredHeight - otherHeights, calculatedImplicitHeight) property real maximumWidth: calculatedMaximumWidth property real maximumHeight: calculatedMaximumHeight - otherHeights // we enforce maximum height solely from the content implicitWidth: Math.min(preferredWidth, maximumWidth) implicitHeight: Math.min(preferredHeight, maximumHeight) // give an implied width and height to the contentItem so that features like word wrapping/eliding work // cannot placed directly in contentControl as a child, so we must use a property property var widthHint: Binding { target: contentControl.contentChildren[0] || null property: "width" value: contentControl.width + contentControl.leftPadding + contentControl.rightPadding restoreMode: Binding.RestoreBinding } } header: T.Control { implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, implicitContentWidth + leftPadding + rightPadding) implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, implicitContentHeight + topPadding + bottomPadding) padding: Kirigami.Units.largeSpacing bottomPadding: verticalPadding + headerSeparator.implicitHeight // add space for bottom separator contentItem: RowLayout { spacing: Kirigami.Units.smallSpacing Kirigami.Heading { id: heading Layout.fillWidth: true Layout.alignment: Qt.AlignVCenter text: root.title.length === 0 ? " " : root.title // always have text to ensure header height elide: Text.ElideRight // use tooltip for long text that is elided QQC2.ToolTip.visible: truncated && titleHoverHandler.hovered QQC2.ToolTip.text: root.title HoverHandler { id: titleHoverHandler } } QQC2.ToolButton { id: closeIcon // We want to position the close button in the top-right // corner if the header is very tall, but we want to // vertically center it in a short header readonly property bool tallHeader: parent.height > (Kirigami.Units.iconSizes.smallMedium + Kirigami.Units.largeSpacing * 2) Layout.alignment: tallHeader ? Qt.AlignRight | Qt.AlignTop : Qt.AlignRight | Qt.AlignVCenter Layout.topMargin: tallHeader ? Kirigami.Units.largeSpacing : 0 visible: root.showCloseButton icon.name: closeIcon.hovered ? "window-close" : "window-close-symbolic" text: qsTr("Close", "@action:button close dialog") onClicked: root.reject() display: QQC2.AbstractButton.IconOnly } } // header background background: Item { Kirigami.Separator { id: headerSeparator width: parent.width anchors.bottom: parent.bottom visible: contentControl.contentHeight > contentControl.implicitHeight } } } // use top level control rather than toolbar, since toolbar causes button rendering glitches footer: T.Control { id: footerToolBar // if there is nothing in the footer, still maintain a height so that we can create a rounded bottom buffer for the dialog property bool bufferMode: !root.footerLeadingComponent && !dialogButtonBox.visible implicitHeight: bufferMode ? Math.round(Kirigami.Units.smallSpacing / 2) : contentItem.implicitHeight + topPadding + bottomPadding implicitWidth: dialogButtonBox.implicitWidth + leftPadding + rightPadding padding: !bufferMode ? Kirigami.Units.largeSpacing : 0 contentItem: RowLayout { spacing: footerToolBar.spacing // Don't let user interact with footer during transitions enabled: root.opened Loader { id: leadingLoader sourceComponent: root.footerLeadingComponent } // footer buttons QQC2.DialogButtonBox { // we don't explicitly set padding, to let the style choose the padding id: dialogButtonBox standardButtons: root.standardButtons visible: count > 0 padding: 0 Layout.fillWidth: true Layout.alignment: dialogButtonBox.alignment position: QQC2.DialogButtonBox.Footer // ensure themes don't add a background, since it can lead to visual inconsistencies // with the rest of the dialog background: null // we need to hook all of the buttonbox events to the dialog events onAccepted: root.accept() onRejected: root.reject() onApplied: root.applied() onDiscarded: root.discarded() onHelpRequested: root.helpRequested() onReset: root.reset() // add custom footer buttons Repeater { id: customFooterButtons model: root.__visibleCustomFooterActions // we have to use Button instead of ToolButton, because ToolButton has no visual distinction when disabled delegate: QQC2.Button { required property T.Action modelData flat: root.flatFooterButtons action: modelData } } } Loader { id: trailingLoader sourceComponent: root.footerTrailingComponent } } background: Item { Kirigami.Separator { id: footerSeparator visible: contentControl.contentHeight > contentControl.implicitHeight && footerToolBar.padding !== 0 width: parent.width anchors.top: parent.top } } } }