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;
}
function onHintsChanged(): void {
notif.hints = notif.notification.hints;
}
function onImageChanged(): void {
notif.imageSource = notif.notification.image || "";
notif.image = notif.imageSource;
@@ -237,6 +241,7 @@ Singleton {
}
property real expireTimeout: 5
property bool hasActionIcons
property var hints
property string image
property string imageSource
property var locks: new Set()
@@ -333,6 +338,7 @@ Singleton {
appName = notification.appName;
imageSource = notification.image || "";
image = imageSource;
hints = notification.hints;
expireTimeout = notification.expireTimeout;
urgency = notification.urgency;
resident = notification.resident;
+4 -1
View File
@@ -1,3 +1,5 @@
pragma ComponentBehavior: Bound
import qs.Components
import qs.Config
import qs.Daemons
@@ -149,11 +151,12 @@ Item {
color: "transparent"
implicitHeight: notif.implicitHeight
implicitWidth: notif.implicitWidth
radius: Appearance.rounding.smallest / 2
radius: Appearance.rounding.small
Notification {
id: notif
implicitWidth: root.implicitWidth - root.padding * 2
modelData: wrapper.modelData
}
}
+155 -119
View File
@@ -1,35 +1,33 @@
pragma ComponentBehavior: Bound
import qs.Components
import qs.Config
import qs.Modules
import QtQuick
import QtQuick.Shapes
import Quickshell
import Quickshell.Services.Notifications
import ZShell.Components
import qs.Daemons
import qs.Helpers
import Quickshell
import Quickshell.Widgets
import Quickshell.Services.Notifications
import QtQuick
import QtQuick.Layouts
import qs.Config
import qs.Components
CustomRect {
id: root
readonly property int bodyTextFormat: /[<*_`#\[\]]/.test(modelData.body) ? Text.MarkdownText : Text.PlainText
property bool expanded: Config.notifs.openExpanded
readonly property bool hasAppIcon: modelData.appIcon.length > 0
readonly property bool hasImage: modelData.image.length > 0
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
implicitHeight: inner.implicitHeight
implicitWidth: Config.notifs.sizes.width
radius: 6
x: Config.notifs.sizes.width
radius: Appearance.rounding.small
x: implicitWidth
Behavior on x {
Anim {
easing.bezierCurve: MaterialEasing.expressiveEffects
easing.bezierCurve: Appearance.anim.curves.emphasizedDecel
}
}
@@ -55,7 +53,7 @@ CustomRect {
return;
const actions = root.modelData.actions;
if (actions?.length === 1)
if (actions.length === 1)
actions[0].invoke();
}
onEntered: root.modelData.timer.stop()
@@ -80,7 +78,7 @@ CustomRect {
if (!containsMouse)
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;
else
root.modelData.popup = false;
@@ -90,7 +88,7 @@ CustomRect {
id: inner
anchors.left: parent.left
anchors.margins: 8
anchors.margins: Appearance.padding.normal
anchors.right: parent.right
anchors.top: parent.top
implicitHeight: root.nonAnimHeight
@@ -111,7 +109,8 @@ CustomRect {
visible: root.hasImage || root.hasAppIcon
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
implicitWidth: Config.notifs.sizes.image
radius: Appearance.rounding.full
@@ -121,8 +120,11 @@ CustomRect {
asynchronous: true
cache: false
fillMode: Image.PreserveAspectCrop
mipmap: true
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)
width: Math.round(parent.width * 0.6)
sourceComponent: CustomIcon {
sourceComponent: ColoredIcon {
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")
source: Quickshell.iconPath(root.modelData.appIcon)
}
@@ -162,34 +165,67 @@ CustomRect {
Loader {
active: !root.hasAppIcon
anchors.centerIn: parent
anchors.horizontalCenterOffset: -18 * 0.02
anchors.verticalCenterOffset: 18 * 0.02
anchors.verticalCenterOffset: 1
asynchronous: true
sourceComponent: MaterialIcon {
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)
}
}
}
}
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 {
id: appName
anchors.left: image.right
anchors.leftMargin: 10
anchors.leftMargin: Appearance.spacing.small
anchors.top: parent.top
animate: true
color: DynamicColors.palette.m3onSurfaceVariant
font.pointSize: 10
maximumLineCount: 1
opacity: root.expanded ? 1 : 0
text: appNameMetrics.elidedText
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
}
@@ -198,9 +234,8 @@ CustomRect {
id: appNameMetrics
elide: Text.ElideRight
elideWidth: expandBtn.x - time.width - timeSep.width - summary.x - 7 * 3
font.family: appName.font.family
font.pointSize: appName.font.pointSize
elideWidth: expandBtn.x - time.width - timeSep.width - summary.x - Appearance.spacing.small * 3
font: appName.font
text: root.modelData.appName
}
@@ -208,7 +243,7 @@ CustomRect {
id: summary
anchors.left: image.right
anchors.leftMargin: 10
anchors.leftMargin: Appearance.spacing.small
anchors.top: parent.top
animate: true
height: implicitHeight
@@ -224,6 +259,9 @@ CustomRect {
when: root.expanded
PropertyChanges {
body.anchors.topMargin: Appearance.spacing.extraSmall
bodyPreview.anchors.topMargin: Appearance.spacing.extraSmall
summary.anchors.topMargin: Appearance.spacing.extraSmall
summary.maximumLineCount: undefined
}
@@ -238,10 +276,11 @@ CustomRect {
target: summary
}
AnchorAnimation {
duration: MaterialEasing.expressiveEffectsTime
easing.bezierCurve: MaterialEasing.expressiveEffects
easing.type: Easing.BezierSpline
Anim {
property: "topMargin"
}
AnchorAnim {
}
}
}
@@ -250,9 +289,8 @@ CustomRect {
id: summaryMetrics
elide: Text.ElideRight
elideWidth: expandBtn.x - time.width - timeSep.width - summary.x - 7 * 3
font.family: summary.font.family
font.pointSize: summary.font.pointSize
elideWidth: expandBtn.x - time.width - timeSep.width - summary.x - Appearance.spacing.small * 3
font: summary.font
text: root.modelData.summary
}
@@ -260,10 +298,9 @@ CustomRect {
id: timeSep
anchors.left: summary.right
anchors.leftMargin: 7
anchors.leftMargin: Appearance.spacing.small
anchors.top: parent.top
color: DynamicColors.palette.m3onSurfaceVariant
font.pointSize: 10
text: "•"
states: State {
@@ -276,10 +313,7 @@ CustomRect {
}
}
transitions: Transition {
AnchorAnimation {
duration: MaterialEasing.expressiveEffectsTime
easing.bezierCurve: MaterialEasing.expressiveEffects
easing.type: Easing.BezierSpline
AnchorAnim {
}
}
}
@@ -288,11 +322,10 @@ CustomRect {
id: time
anchors.left: timeSep.right
anchors.leftMargin: 7
anchors.leftMargin: Appearance.spacing.small
anchors.top: parent.top
animate: true
color: DynamicColors.palette.m3onSurfaceVariant
font.pointSize: 10
horizontalAlignment: Text.AlignLeft
text: root.modelData.timeStr
}
@@ -302,25 +335,33 @@ CustomRect {
anchors.right: parent.right
anchors.top: parent.top
implicitHeight: expandIcon.height
implicitWidth: expandIcon.height
anchors.topMargin: -Appearance.padding.extraSmall
implicitHeight: expandIcon.implicitHeight
implicitWidth: expandIcon.implicitHeight
StateLayer {
color: root.modelData.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3onSecondaryContainer : DynamicColors.palette.m3onSurface
radius: Appearance.rounding.full
onClicked: {
root.expanded = !root.expanded;
}
onClicked: root.expanded = !root.expanded
}
MaterialIcon {
id: expandIcon
anchors.centerIn: parent
animate: true
font.pointSize: 13
text: root.expanded ? "expand_less" : "expand_more"
anchors.verticalCenterOffset: root.expanded ? -1 : 1
rotation: root.expanded ? 180 : 0
text: "expand_more"
Behavior on anchors.verticalCenterOffset {
Anim {
}
}
Behavior on rotation {
Anim {
}
}
}
}
@@ -329,17 +370,17 @@ CustomRect {
anchors.left: summary.left
anchors.right: expandBtn.left
anchors.rightMargin: 7
anchors.rightMargin: Appearance.spacing.small
anchors.top: summary.bottom
animate: true
color: DynamicColors.palette.m3onSurfaceVariant
font.pointSize: 10
opacity: root.expanded ? 0 : 1
text: bodyPreviewMetrics.elidedText
textFormat: Text.MarkdownText
textFormat: root.bodyTextFormat
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
}
@@ -349,8 +390,7 @@ CustomRect {
elide: Text.ElideRight
elideWidth: bodyPreview.width
font.family: bodyPreview.font.family
font.pointSize: bodyPreview.font.pointSize
font: bodyPreview.font
text: root.modelData.body
}
@@ -359,19 +399,19 @@ CustomRect {
anchors.left: summary.left
anchors.right: expandBtn.left
anchors.rightMargin: 7
anchors.rightMargin: Appearance.spacing.small
anchors.top: summary.bottom
animate: true
color: DynamicColors.palette.m3onSurfaceVariant
font.pointSize: 10
height: text ? implicitHeight : 0
opacity: root.expanded ? 1 : 0
text: root.modelData.body
textFormat: Text.MarkdownText
textFormat: root.bodyTextFormat
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
@@ -384,84 +424,80 @@ CustomRect {
}
}
RowLayout {
ButtonRow {
id: actions
anchors.horizontalCenter: parent.horizontalCenter
anchors.left: body.left
anchors.right: body.right
anchors.top: body.bottom
anchors.topMargin: 7
anchors.topMargin: Appearance.spacing.small
opacity: root.expanded ? 1 : 0
spacing: 10
spacing: Appearance.spacing.extraSmall
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
Action {
modelData: QtObject {
readonly property string text: qsTr("Close")
IconButton {
fillWidth: root.modelData.actions.length === 0
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 {
root.modelData.close();
}
}
onClicked: root.modelData.close()
}
Repeater {
model: root.modelData.actions
delegate: Component {
Action {
}
TextButton {
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
lazylistview.hpp lazylistview.cpp
wavyline.hpp wavyline.cpp
buttonrow.hpp buttonrow.cpp
LIBRARIES
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