notif actions rework
Lint & Format (JS/TS) / lint-format (pull_request) Successful in 9s
Python / lint-format (pull_request) Successful in 17s
Python / test (pull_request) Successful in 29s
Lint & Format (Rust) / lint-format (pull_request) Successful in 1m25s

This commit is contained in:
2026-06-19 22:50:52 +02:00
parent d516dd8be4
commit 697058146e
7 changed files with 374 additions and 120 deletions
+39
View File
@@ -0,0 +1,39 @@
import QtQuick
import qs.Config
ButtonBase {
id: root
readonly property alias label: label
property alias text: label.text
activeColor: type === TextButton.Filled ? DynamicColors.palette.m3primary : DynamicColors.palette.m3secondary
activeOnColor: {
if (type === TextButton.Text)
return DynamicColors.palette.m3primary;
return type === TextButton.Filled ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSecondary;
}
horizontalPadding: Appearance.padding.normal
implicitHeight: label.implicitHeight + verticalPadding * 2
implicitWidth: label.implicitWidth + horizontalPadding * 2
inactiveColor: {
if (!isToggle && type === TextButton.Filled)
return DynamicColors.palette.m3primary;
return type === TextButton.Filled ? DynamicColors.tPalette.m3surfaceContainer : DynamicColors.palette.m3secondaryContainer;
}
inactiveOnColor: {
if (!isToggle && type === TextButton.Filled)
return DynamicColors.palette.m3onPrimary;
if (type === TextButton.Text)
return DynamicColors.palette.m3primary;
return type === TextButton.Filled ? DynamicColors.palette.m3onSurface : DynamicColors.palette.m3onSecondaryContainer;
}
verticalPadding: Appearance.padding.small
CustomText {
id: label
anchors.centerIn: parent
color: root.onColor
}
}
+6
View File
@@ -215,6 +215,10 @@ Singleton {
notif.hasActionIcons = notif.notification.hasActionIcons; notif.hasActionIcons = notif.notification.hasActionIcons;
} }
function onHintsChanged(): void {
notif.hints = notif.notification.hints;
}
function onImageChanged(): void { function onImageChanged(): void {
notif.imageSource = notif.notification.image || ""; notif.imageSource = notif.notification.image || "";
notif.image = notif.imageSource; notif.image = notif.imageSource;
@@ -237,6 +241,7 @@ Singleton {
} }
property real expireTimeout: 5 property real expireTimeout: 5
property bool hasActionIcons property bool hasActionIcons
property var hints
property string image property string image
property string imageSource property string imageSource
property var locks: new Set() property var locks: new Set()
@@ -333,6 +338,7 @@ Singleton {
appName = notification.appName; appName = notification.appName;
imageSource = notification.image || ""; imageSource = notification.image || "";
image = imageSource; image = imageSource;
hints = notification.hints;
expireTimeout = notification.expireTimeout; expireTimeout = notification.expireTimeout;
urgency = notification.urgency; urgency = notification.urgency;
resident = notification.resident; resident = notification.resident;
+4 -1
View File
@@ -1,3 +1,5 @@
pragma ComponentBehavior: Bound
import qs.Components import qs.Components
import qs.Config import qs.Config
import qs.Daemons import qs.Daemons
@@ -149,11 +151,12 @@ Item {
color: "transparent" color: "transparent"
implicitHeight: notif.implicitHeight implicitHeight: notif.implicitHeight
implicitWidth: notif.implicitWidth implicitWidth: notif.implicitWidth
radius: Appearance.rounding.smallest / 2 radius: Appearance.rounding.small
Notification { Notification {
id: notif id: notif
implicitWidth: root.implicitWidth - root.padding * 2
modelData: wrapper.modelData modelData: wrapper.modelData
} }
} }
+155 -119
View File
@@ -1,35 +1,33 @@
pragma ComponentBehavior: Bound pragma ComponentBehavior: Bound
import qs.Components import QtQuick
import qs.Config import QtQuick.Shapes
import qs.Modules import Quickshell
import Quickshell.Services.Notifications
import ZShell.Components
import qs.Daemons import qs.Daemons
import qs.Helpers import qs.Helpers
import Quickshell import qs.Config
import Quickshell.Widgets import qs.Components
import Quickshell.Services.Notifications
import QtQuick
import QtQuick.Layouts
CustomRect { CustomRect {
id: root id: root
readonly property int bodyTextFormat: /[<*_`#\[\]]/.test(modelData.body) ? Text.MarkdownText : Text.PlainText
property bool expanded: Config.notifs.openExpanded property bool expanded: Config.notifs.openExpanded
readonly property bool hasAppIcon: modelData.appIcon.length > 0 readonly property bool hasAppIcon: modelData.appIcon.length > 0
readonly property bool hasImage: modelData.image.length > 0 readonly property bool hasImage: modelData.image.length > 0
required property NotifServer.Notif modelData required property NotifServer.Notif modelData
readonly property int nonAnimHeight: summary.implicitHeight + (root.expanded ? appName.height + body.height + actions.height + actions.anchors.topMargin : bodyPreview.height) + inner.anchors.margins * 2 readonly property int nonAnimHeight: summary.implicitHeight + (root.expanded ? Appearance.spacing.extraSmall * 2 + appName.height + body.height + actions.height + actions.anchors.topMargin : bodyPreview.height) + inner.anchors.margins * 2
clip: true
color: root.modelData.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3secondaryContainer : DynamicColors.tPalette.m3surfaceContainer color: root.modelData.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3secondaryContainer : DynamicColors.tPalette.m3surfaceContainer
implicitHeight: inner.implicitHeight implicitHeight: inner.implicitHeight
implicitWidth: Config.notifs.sizes.width radius: Appearance.rounding.small
radius: 6 x: implicitWidth
x: Config.notifs.sizes.width
Behavior on x { Behavior on x {
Anim { Anim {
easing.bezierCurve: MaterialEasing.expressiveEffects easing.bezierCurve: Appearance.anim.curves.emphasizedDecel
} }
} }
@@ -55,7 +53,7 @@ CustomRect {
return; return;
const actions = root.modelData.actions; const actions = root.modelData.actions;
if (actions?.length === 1) if (actions.length === 1)
actions[0].invoke(); actions[0].invoke();
} }
onEntered: root.modelData.timer.stop() onEntered: root.modelData.timer.stop()
@@ -80,7 +78,7 @@ CustomRect {
if (!containsMouse) if (!containsMouse)
root.modelData.timer.start(); root.modelData.timer.start();
if (Math.abs(root.x) < Config.notifs.sizes.width * Config.notifs.clearThreshold) if (Math.abs(root.x) < root.implicitWidth * Config.notifs.clearThreshold)
root.x = 0; root.x = 0;
else else
root.modelData.popup = false; root.modelData.popup = false;
@@ -90,7 +88,7 @@ CustomRect {
id: inner id: inner
anchors.left: parent.left anchors.left: parent.left
anchors.margins: 8 anchors.margins: Appearance.padding.normal
anchors.right: parent.right anchors.right: parent.right
anchors.top: parent.top anchors.top: parent.top
implicitHeight: root.nonAnimHeight implicitHeight: root.nonAnimHeight
@@ -111,7 +109,8 @@ CustomRect {
visible: root.hasImage || root.hasAppIcon visible: root.hasImage || root.hasAppIcon
width: Config.notifs.sizes.image width: Config.notifs.sizes.image
sourceComponent: ClippingRectangle { sourceComponent: CustomClippingRect {
color: root.modelData.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3error : root.modelData.urgency === NotificationUrgency.Low ? DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHighest, 2) : DynamicColors.palette.m3secondaryContainer
implicitHeight: Config.notifs.sizes.image implicitHeight: Config.notifs.sizes.image
implicitWidth: Config.notifs.sizes.image implicitWidth: Config.notifs.sizes.image
radius: Appearance.rounding.full radius: Appearance.rounding.full
@@ -121,8 +120,11 @@ CustomRect {
asynchronous: true asynchronous: true
cache: false cache: false
fillMode: Image.PreserveAspectCrop fillMode: Image.PreserveAspectCrop
mipmap: true
source: Qt.resolvedUrl(root.modelData.image) source: Qt.resolvedUrl(root.modelData.image)
sourceSize: {
const size = Config.notifs.sizes.image * ((QsWindow.window as QsWindow)?.devicePixelRatio ?? 1);
return Qt.size(size, size);
}
} }
} }
} }
@@ -152,8 +154,9 @@ CustomRect {
height: Math.round(parent.width * 0.6) height: Math.round(parent.width * 0.6)
width: Math.round(parent.width * 0.6) width: Math.round(parent.width * 0.6)
sourceComponent: CustomIcon { sourceComponent: ColoredIcon {
anchors.fill: parent anchors.fill: parent
color: root.modelData.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3onError : root.modelData.urgency === NotificationUrgency.Low ? DynamicColors.palette.m3onSurface : DynamicColors.palette.m3onSecondaryContainer
layer.enabled: root.modelData.appIcon.endsWith("symbolic") layer.enabled: root.modelData.appIcon.endsWith("symbolic")
source: Quickshell.iconPath(root.modelData.appIcon) source: Quickshell.iconPath(root.modelData.appIcon)
} }
@@ -162,34 +165,67 @@ CustomRect {
Loader { Loader {
active: !root.hasAppIcon active: !root.hasAppIcon
anchors.centerIn: parent anchors.centerIn: parent
anchors.horizontalCenterOffset: -18 * 0.02 anchors.verticalCenterOffset: 1
anchors.verticalCenterOffset: 18 * 0.02
asynchronous: true asynchronous: true
sourceComponent: MaterialIcon { sourceComponent: MaterialIcon {
color: root.modelData.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3onError : root.modelData.urgency === NotificationUrgency.Low ? DynamicColors.palette.m3onSurface : DynamicColors.palette.m3onSecondaryContainer color: root.modelData.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3onError : root.modelData.urgency === NotificationUrgency.Low ? DynamicColors.palette.m3onSurface : DynamicColors.palette.m3onSecondaryContainer
font.pointSize: 18
text: Icons.getNotifIcon(root.modelData.summary, root.modelData.urgency) text: Icons.getNotifIcon(root.modelData.summary, root.modelData.urgency)
} }
} }
} }
} }
Shape {
id: progressIndicator
anchors.centerIn: appIcon
height: appIcon.implicitHeight + progressShape.strokeWidth * 2
preferredRendererType: Shape.CurveRenderer
width: appIcon.implicitWidth + progressShape.strokeWidth * 2
ShapePath {
id: progressShape
capStyle: ShapePath.RoundCap
fillColor: "transparent"
strokeColor: DynamicColors.palette.m3primary
strokeWidth: 2
PathAngleArc {
id: progressArc
centerX: progressIndicator.width / 2
centerY: progressIndicator.height / 2
radiusX: progressIndicator.width / 2 - Appearance.padding.extraSmall / 2
radiusY: progressIndicator.height / 2 - Appearance.padding.extraSmall / 2
startAngle: -90
sweepAngle: ((root.modelData.hints.value ?? 0) / 100) * 360
Behavior on sweepAngle {
Anim {
easing.bezierCurve: Appearance.anim.curves.emphasizedDecel
}
}
}
}
}
CustomText { CustomText {
id: appName id: appName
anchors.left: image.right anchors.left: image.right
anchors.leftMargin: 10 anchors.leftMargin: Appearance.spacing.small
anchors.top: parent.top anchors.top: parent.top
animate: true animate: true
color: DynamicColors.palette.m3onSurfaceVariant color: DynamicColors.palette.m3onSurfaceVariant
font.pointSize: 10
maximumLineCount: 1 maximumLineCount: 1
opacity: root.expanded ? 1 : 0 opacity: root.expanded ? 1 : 0
text: appNameMetrics.elidedText text: appNameMetrics.elidedText
Behavior on opacity { Behavior on opacity {
Anim { Anim {
type: Anim.DefaultEffects
} }
} }
} }
@@ -198,9 +234,8 @@ CustomRect {
id: appNameMetrics id: appNameMetrics
elide: Text.ElideRight elide: Text.ElideRight
elideWidth: expandBtn.x - time.width - timeSep.width - summary.x - 7 * 3 elideWidth: expandBtn.x - time.width - timeSep.width - summary.x - Appearance.spacing.small * 3
font.family: appName.font.family font: appName.font
font.pointSize: appName.font.pointSize
text: root.modelData.appName text: root.modelData.appName
} }
@@ -208,7 +243,7 @@ CustomRect {
id: summary id: summary
anchors.left: image.right anchors.left: image.right
anchors.leftMargin: 10 anchors.leftMargin: Appearance.spacing.small
anchors.top: parent.top anchors.top: parent.top
animate: true animate: true
height: implicitHeight height: implicitHeight
@@ -224,6 +259,9 @@ CustomRect {
when: root.expanded when: root.expanded
PropertyChanges { PropertyChanges {
body.anchors.topMargin: Appearance.spacing.extraSmall
bodyPreview.anchors.topMargin: Appearance.spacing.extraSmall
summary.anchors.topMargin: Appearance.spacing.extraSmall
summary.maximumLineCount: undefined summary.maximumLineCount: undefined
} }
@@ -238,10 +276,11 @@ CustomRect {
target: summary target: summary
} }
AnchorAnimation { Anim {
duration: MaterialEasing.expressiveEffectsTime property: "topMargin"
easing.bezierCurve: MaterialEasing.expressiveEffects }
easing.type: Easing.BezierSpline
AnchorAnim {
} }
} }
} }
@@ -250,9 +289,8 @@ CustomRect {
id: summaryMetrics id: summaryMetrics
elide: Text.ElideRight elide: Text.ElideRight
elideWidth: expandBtn.x - time.width - timeSep.width - summary.x - 7 * 3 elideWidth: expandBtn.x - time.width - timeSep.width - summary.x - Appearance.spacing.small * 3
font.family: summary.font.family font: summary.font
font.pointSize: summary.font.pointSize
text: root.modelData.summary text: root.modelData.summary
} }
@@ -260,10 +298,9 @@ CustomRect {
id: timeSep id: timeSep
anchors.left: summary.right anchors.left: summary.right
anchors.leftMargin: 7 anchors.leftMargin: Appearance.spacing.small
anchors.top: parent.top anchors.top: parent.top
color: DynamicColors.palette.m3onSurfaceVariant color: DynamicColors.palette.m3onSurfaceVariant
font.pointSize: 10
text: "•" text: "•"
states: State { states: State {
@@ -276,10 +313,7 @@ CustomRect {
} }
} }
transitions: Transition { transitions: Transition {
AnchorAnimation { AnchorAnim {
duration: MaterialEasing.expressiveEffectsTime
easing.bezierCurve: MaterialEasing.expressiveEffects
easing.type: Easing.BezierSpline
} }
} }
} }
@@ -288,11 +322,10 @@ CustomRect {
id: time id: time
anchors.left: timeSep.right anchors.left: timeSep.right
anchors.leftMargin: 7 anchors.leftMargin: Appearance.spacing.small
anchors.top: parent.top anchors.top: parent.top
animate: true animate: true
color: DynamicColors.palette.m3onSurfaceVariant color: DynamicColors.palette.m3onSurfaceVariant
font.pointSize: 10
horizontalAlignment: Text.AlignLeft horizontalAlignment: Text.AlignLeft
text: root.modelData.timeStr text: root.modelData.timeStr
} }
@@ -302,25 +335,33 @@ CustomRect {
anchors.right: parent.right anchors.right: parent.right
anchors.top: parent.top anchors.top: parent.top
implicitHeight: expandIcon.height anchors.topMargin: -Appearance.padding.extraSmall
implicitWidth: expandIcon.height implicitHeight: expandIcon.implicitHeight
implicitWidth: expandIcon.implicitHeight
StateLayer { StateLayer {
color: root.modelData.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3onSecondaryContainer : DynamicColors.palette.m3onSurface color: root.modelData.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3onSecondaryContainer : DynamicColors.palette.m3onSurface
radius: Appearance.rounding.full radius: Appearance.rounding.full
onClicked: { onClicked: root.expanded = !root.expanded
root.expanded = !root.expanded;
}
} }
MaterialIcon { MaterialIcon {
id: expandIcon id: expandIcon
anchors.centerIn: parent anchors.centerIn: parent
animate: true anchors.verticalCenterOffset: root.expanded ? -1 : 1
font.pointSize: 13 rotation: root.expanded ? 180 : 0
text: root.expanded ? "expand_less" : "expand_more" text: "expand_more"
Behavior on anchors.verticalCenterOffset {
Anim {
}
}
Behavior on rotation {
Anim {
}
}
} }
} }
@@ -329,17 +370,17 @@ CustomRect {
anchors.left: summary.left anchors.left: summary.left
anchors.right: expandBtn.left anchors.right: expandBtn.left
anchors.rightMargin: 7 anchors.rightMargin: Appearance.spacing.small
anchors.top: summary.bottom anchors.top: summary.bottom
animate: true animate: true
color: DynamicColors.palette.m3onSurfaceVariant color: DynamicColors.palette.m3onSurfaceVariant
font.pointSize: 10
opacity: root.expanded ? 0 : 1 opacity: root.expanded ? 0 : 1
text: bodyPreviewMetrics.elidedText text: bodyPreviewMetrics.elidedText
textFormat: Text.MarkdownText textFormat: root.bodyTextFormat
Behavior on opacity { Behavior on opacity {
Anim { Anim {
type: Anim.DefaultEffects
} }
} }
} }
@@ -349,8 +390,7 @@ CustomRect {
elide: Text.ElideRight elide: Text.ElideRight
elideWidth: bodyPreview.width elideWidth: bodyPreview.width
font.family: bodyPreview.font.family font: bodyPreview.font
font.pointSize: bodyPreview.font.pointSize
text: root.modelData.body text: root.modelData.body
} }
@@ -359,19 +399,19 @@ CustomRect {
anchors.left: summary.left anchors.left: summary.left
anchors.right: expandBtn.left anchors.right: expandBtn.left
anchors.rightMargin: 7 anchors.rightMargin: Appearance.spacing.small
anchors.top: summary.bottom anchors.top: summary.bottom
animate: true animate: true
color: DynamicColors.palette.m3onSurfaceVariant color: DynamicColors.palette.m3onSurfaceVariant
font.pointSize: 10
height: text ? implicitHeight : 0 height: text ? implicitHeight : 0
opacity: root.expanded ? 1 : 0 opacity: root.expanded ? 1 : 0
text: root.modelData.body text: root.modelData.body
textFormat: Text.MarkdownText textFormat: root.bodyTextFormat
wrapMode: Text.WrapAtWordBoundaryOrAnywhere wrapMode: Text.WrapAtWordBoundaryOrAnywhere
Behavior on opacity { Behavior on opacity {
Anim { Anim {
type: Anim.DefaultEffects
} }
} }
@@ -384,84 +424,80 @@ CustomRect {
} }
} }
RowLayout { ButtonRow {
id: actions id: actions
anchors.horizontalCenter: parent.horizontalCenter anchors.left: body.left
anchors.right: body.right
anchors.top: body.bottom anchors.top: body.bottom
anchors.topMargin: 7 anchors.topMargin: Appearance.spacing.small
opacity: root.expanded ? 1 : 0 opacity: root.expanded ? 1 : 0
spacing: 10 spacing: Appearance.spacing.extraSmall
Behavior on opacity { Behavior on opacity {
Anim { Anim {
type: Anim.DefaultEffects
} }
} }
Action { IconButton {
modelData: QtObject { fillWidth: root.modelData.actions.length === 0
readonly property string text: qsTr("Close") icon: "close"
inactiveColor: root.modelData.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3secondary : DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHighest, 2)
inactiveOnColor: root.modelData.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3onSecondary : DynamicColors.palette.m3onSurfaceVariant
isRound: true
padding: Appearance.padding.extraSmall
shapeMorph: true
function invoke(): void { onClicked: root.modelData.close()
root.modelData.close();
}
}
} }
Repeater { Repeater {
model: root.modelData.actions model: root.modelData.actions
delegate: Component { TextButton {
Action { required property var modelData
}
fillWidth: true
inactiveColor: root.modelData.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3secondary : DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHighest, 2)
inactiveOnColor: root.modelData.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3onSecondary : DynamicColors.palette.m3onSurfaceVariant
isRound: true
label.anchors.centerIn: undefined
label.anchors.left: left
label.anchors.margins: Appearance.padding.normal
label.anchors.right: right
label.anchors.verticalCenter: verticalCenter
label.elide: Text.ElideRight
label.horizontalAlignment: Text.AlignHCenter
shapeMorph: true
text: modelData.text
onClicked: modelData.invoke()
}
}
IconButton {
fillWidth: root.modelData.actions.length === 0
icon: copyTimer.running ? "inventory" : "content_copy"
inactiveColor: root.modelData.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3secondary : DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHighest, 2)
inactiveOnColor: root.modelData.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3onSecondary : DynamicColors.palette.m3onSurfaceVariant
isRound: true
label.animate: true
padding: Appearance.padding.extraSmall
shapeMorph: true
onClicked: {
Quickshell.clipboardText = root.modelData.body;
copyTimer.restart();
}
Timer {
id: copyTimer
interval: 3000
} }
} }
} }
} }
} }
component Action: CustomRect {
id: action
required property var modelData
Layout.preferredHeight: actionText.height + 4 * 2
Layout.preferredWidth: actionText.width + 8 * 2
color: root.modelData.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3secondary : DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2)
implicitHeight: actionText.height + 4 * 2
implicitWidth: actionText.width + 8 * 2
radius: Appearance.rounding.full
StateLayer {
color: root.modelData.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3onSecondary : DynamicColors.palette.m3onSurface
enabled: root.expanded
radius: Appearance.rounding.full
onClicked: {
action.modelData.invoke();
}
}
CustomText {
id: actionText
anchors.centerIn: parent
color: root.modelData.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3onSecondary : DynamicColors.palette.m3onSurfaceVariant
font.pointSize: 10
text: actionTextMetrics.elidedText
}
TextMetrics {
id: actionTextMetrics
elide: Text.ElideRight
elideWidth: {
const numActions = root.modelData.actions.length + 1;
return (inner.width - actions.spacing * (numActions - 1)) / numActions - 8 * 2;
}
font.family: actionText.font.family
font.pointSize: actionText.font.pointSize
text: action.modelData.text
}
}
} }
+1
View File
@@ -3,6 +3,7 @@ qml_module(ZShell-components
SOURCES SOURCES
lazylistview.hpp lazylistview.cpp lazylistview.hpp lazylistview.cpp
wavyline.hpp wavyline.cpp wavyline.hpp wavyline.cpp
buttonrow.hpp buttonrow.cpp
LIBRARIES LIBRARIES
Qt::Quick Qt::Quick
) )
+132
View File
@@ -0,0 +1,132 @@
#include "buttonrow.hpp"
namespace ZShell::components {
ButtonRow::ButtonRow(QQuickItem* parent)
: QQuickItem(parent)
, m_dirty(false)
, m_spacing(0.0) {
setFlag(QQuickItem::ItemHasContents, true);
QObject::connect(this, &ButtonRow::widthChanged, this, &ButtonRow::invalidate);
}
qreal ButtonRow::spacing() const {
return m_spacing;
}
void ButtonRow::setSpacing(qreal spacing) {
if (qFuzzyCompare(m_spacing + 1.0, spacing + 1.0))
return;
m_spacing = spacing;
emit spacingChanged();
invalidate();
}
void ButtonRow::itemChange(QQuickItem::ItemChange change, const QQuickItem::ItemChangeData& data) {
if (change == QQuickItem::ItemChildAddedChange) {
auto* const child = data.item;
QObject::connect(child, &QQuickItem::implicitWidthChanged, this, &ButtonRow::invalidate);
QObject::connect(child, &QQuickItem::implicitHeightChanged, this, &ButtonRow::invalidate);
QObject::connect(child, &QQuickItem::visibleChanged, this, &ButtonRow::invalidate);
const auto* childMeta = child->metaObject();
const auto morphSignalIdx = childMeta->indexOfSignal("shapeMorphExpansionChanged()");
if (morphSignalIdx != -1)
QObject::connect(child, childMeta->method(morphSignalIdx), this,
metaObject()->method(metaObject()->indexOfSlot("invalidate()")));
invalidate();
} else if (change == QQuickItem::ItemChildRemovedChange) {
QObject::disconnect(data.item, nullptr, this, nullptr);
invalidate();
}
QQuickItem::itemChange(change, data);
}
void ButtonRow::updatePolish() {
if (m_dirty) {
relayout();
m_dirty = false;
}
}
void ButtonRow::invalidate() {
m_dirty = true;
polish();
}
void ButtonRow::relayout() {
const auto allChildren = childItems();
QList<QQuickItem*> validChildren;
for (auto* const child : allChildren) {
if (child->isVisible() && !child->inherits("QQuickRepeater"))
validChildren.append(child);
}
const auto nChildren = validChildren.size();
const auto totalSpacing = static_cast<qreal>(nChildren - 1) * m_spacing;
qreal reservedWidth = 0;
qreal unreservedWidth = 0;
int fillWidthCount = 0;
qreal maxHeight = 0;
for (auto* const child : validChildren) {
maxHeight = qMax(maxHeight, child->implicitHeight());
const auto prop = child->property("fillWidth");
if (!prop.isValid())
continue;
if (prop.toBool()) {
fillWidthCount++;
unreservedWidth += child->implicitWidth();
} else {
reservedWidth += child->implicitWidth();
}
}
if (fillWidthCount == 0)
fillWidthCount = 1; // Avoid divide by 0
const auto widthPerItem = (width() - totalSpacing - reservedWidth) / static_cast<qreal>(fillWidthCount);
QList<qreal> baseWidths;
baseWidths.reserve(nChildren);
for (auto* const child : validChildren)
baseWidths.append(child->property("fillWidth").toBool() ? widthPerItem : child->implicitWidth());
qreal accX = 0;
for (int i = 0; i < nChildren; ++i) {
auto* const child = validChildren[i];
// clang-format off
auto prevExtraWidth = i > 0 ? getMorphExpansion(validChildren[i - 1]) : 0.0;
auto nextExtraWidth = i < nChildren - 1 ? getMorphExpansion(validChildren[i + 1]) : 0.0;
// clang-format on
// Items at edges push by full amount, items in middle push by half
if (i > 1)
prevExtraWidth /= 2;
if (i < nChildren - 2)
nextExtraWidth /= 2;
child->setWidth(baseWidths[i] + getMorphExpansion(child) - prevExtraWidth - nextExtraWidth);
child->setHeight(maxHeight);
child->setX(accX);
child->setY(0);
accX += child->width() + m_spacing;
}
setImplicitWidth(reservedWidth + unreservedWidth + totalSpacing);
setImplicitHeight(maxHeight);
}
qreal ButtonRow::getMorphExpansion(const QQuickItem* item) {
return item->property("shapeMorphExpansion").toReal();
}
} // namespace ZShell::components
+37
View File
@@ -0,0 +1,37 @@
#pragma once
#include <qquickitem.h>
namespace ZShell::components {
class ButtonRow : public QQuickItem {
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(qreal spacing READ spacing WRITE setSpacing NOTIFY spacingChanged)
public:
explicit ButtonRow(QQuickItem* parent = nullptr);
[[nodiscard]] qreal spacing() const;
void setSpacing(qreal spacing);
signals:
void spacingChanged();
protected:
void itemChange(QQuickItem::ItemChange change, const QQuickItem::ItemChangeData& data) override;
void updatePolish() override;
private slots:
void invalidate();
private:
void relayout();
static qreal getMorphExpansion(const QQuickItem* item);
bool m_dirty;
qreal m_spacing;
};
} // namespace ZShell::components