diff --git a/quickshell/components/NotifCard.qml b/quickshell/components/NotifCard.qml index dc26c48..e45b410 100644 --- a/quickshell/components/NotifCard.qml +++ b/quickshell/components/NotifCard.qml @@ -5,179 +5,252 @@ import "root:/config" import "root:/components" import "root:/services" -// One notification, used both in the history list and as a floating toast. +// One notification, used both in the history list and in the toast drawer. +// +// Laid out like an entry in NieR's message log rather than as a card: a square +// marker and the sender's name running into a hairline, the summary, the body, +// and hairline buttons for any actions. There is no panel of its own — both +// places it appears are already hull surfaces — so rest is bare, hover lifts +// the row onto the surface tone, and entries are divided by the hairline in +// their own header. +// +// Urgency is the marker: the light for normal, grey for low, red for critical. +// A critical entry also takes the red into its sender line, and nothing else. Item { id: root required property var notif property bool showActions: true + readonly property bool critical: root.notif?.urgency === NotificationUrgency.Critical readonly property color tone: root.notif ? Notifs.urgencyColor(root.notif.urgency) : Theme.primary + // Kept for callers that size things to the card's edges; there is no lean + // or chop under the YoRHa theme. + readonly property real lean: 0 + readonly property real chop: 0 + + readonly property bool hovered: hover.hovered + signal dismissed() - // The card's own geometry, published so anything drawn along its edges — - // the toast countdown rule — can stay inside the chopped corners instead of - // running out past them. - readonly property real lean: Theme.skew - readonly property real chop: Theme.cut + implicitHeight: layout.implicitHeight + Theme.padM * 2 - implicitHeight: panel.height + Rectangle { + anchors.fill: parent + color: root.hovered ? Theme.treatmentHoverFill : "transparent" - P5Panel { - id: panel - width: parent.width - height: layout.implicitHeight + Theme.padM * 2 - lean: root.lean - chop: root.chop - fill: Theme.surface - fillOpacity: 0.97 - // Urgency lives in the slash, and in the frame only when critical. A - // tone-tinted frame on every card drew a pale outline round ordinary - // notifications, and the critical halftone was painted in the accent, - // so the most urgent card wore the colour that means "live". - border: root.notif?.urgency === NotificationUrgency.Critical - ? root.tone - : (hover.hovered ? Theme.treatmentHoverMark : Theme.surfaceAlt) - slash: true - slashColor: root.tone - slashWidth: 4 - padding: Theme.padM + Behavior on color { + ColorAnimation { duration: Theme.durFast } + } + } - Column { - id: layout - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - spacing: 3 + Column { + id: layout + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Theme.padM + spacing: 4 - // App line - Row { - spacing: Theme.padS + // Sender: marker, glyph, name, then the hairline to the edge. + Item { + width: parent.width + height: 16 - Icon { - anchors.verticalCenter: parent.verticalCenter - text: Notifs.appIconFor(root.notif) - color: root.tone - font.pixelSize: Theme.fsBody - } - - Text { - anchors.verticalCenter: parent.verticalCenter - text: (root.notif?.appName || "System").toUpperCase() - color: root.tone - font.family: Theme.fontMono - font.pixelSize: Theme.fsMicro - font.letterSpacing: 2 - renderType: Text.NativeRendering - } + Rectangle { + id: marker + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: Theme.marker + height: Theme.marker + color: root.tone } - Text { - width: parent.width - text: root.notif?.summary || "" - color: Theme.text - elide: Text.ElideRight - maximumLineCount: 2 - wrapMode: Text.WordWrap - font.family: Theme.fontDisplay - font.pixelSize: Theme.fsBody - font.weight: Font.DemiBold - renderType: Text.NativeRendering - } - - Text { - width: parent.width + Icon { + id: glyph visible: text !== "" - text: root.notif?.body || "" - color: Theme.subtext - elide: Text.ElideRight - maximumLineCount: 4 - wrapMode: Text.WordWrap - textFormat: Text.StyledText - font.family: Theme.fontDisplay - font.pixelSize: Theme.fsSmall + anchors.left: marker.right + anchors.leftMargin: Theme.padS + anchors.verticalCenter: parent.verticalCenter + text: Notifs.appIconFor(root.notif) + color: root.critical ? Theme.alarm : Theme.muted + font.pixelSize: Theme.fsBody + } + + Text { + id: sender + anchors.left: glyph.visible ? glyph.right : marker.right + anchors.leftMargin: Theme.padS + anchors.verticalCenter: parent.verticalCenter + text: (root.notif?.appName || "System").toUpperCase() + color: root.critical ? Theme.alarm : Theme.muted + font.family: Theme.fontMono + font.pixelSize: Theme.fsMicro + font.letterSpacing: 2 renderType: Text.NativeRendering } - // Inline image, when the sender supplied one. + Rectangle { + anchors.left: sender.right + anchors.leftMargin: Theme.padS + anchors.right: close.left + anchors.rightMargin: Theme.padS + anchors.verticalCenter: parent.verticalCenter + height: 1 + color: Theme.alpha(Theme.outline, 0.5) + } + + // Close affordance: always there, quiet until the row is hovered. + Icon { + id: close + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: "󰅖" + opacity: root.hovered ? 1 : 0.0 + color: closeHover.hovered ? Theme.accent : Theme.muted + font.pixelSize: Theme.fsBody + + Behavior on opacity { + NumberAnimation { duration: Theme.durFast } + } + + HoverHandler { + id: closeHover + cursorShape: Qt.PointingHandCursor + } + + TapHandler { + onTapped: root.dismissed() + } + } + } + + Text { + width: parent.width + leftPadding: Theme.marker + Theme.padS + text: root.notif?.summary || "" + color: root.hovered ? Theme.accent : Theme.text + elide: Text.ElideRight + maximumLineCount: 2 + wrapMode: Text.WordWrap + font.family: Theme.fontDisplay + font.pixelSize: Theme.fsBody + font.weight: Theme.weightDisplay + renderType: Text.NativeRendering + + Behavior on color { + ColorAnimation { duration: Theme.durFast } + } + } + + Text { + width: parent.width + leftPadding: Theme.marker + Theme.padS + visible: text !== "" + text: root.notif?.body || "" + color: Theme.subtext + elide: Text.ElideRight + maximumLineCount: 4 + wrapMode: Text.WordWrap + textFormat: Text.StyledText + font.family: Theme.fontDisplay + font.pixelSize: Theme.fsSmall + lineHeight: 1.1 + renderType: Text.NativeRendering + } + + // Inline image, when the sender supplied one, in a hairline frame. + Rectangle { + x: Theme.marker + Theme.padS + visible: inline.source != "" && inline.status === Image.Ready + width: inline.width + 2 + height: inline.height + 2 + color: "transparent" + border.width: 1 + border.color: Theme.overlay + Image { - visible: source != "" && status === Image.Ready + id: inline + x: 1 + y: 1 source: root.notif?.image || "" sourceSize.height: 90 fillMode: Image.PreserveAspectFit asynchronous: true } + } - Row { - visible: root.showActions && (root.notif?.actions?.length ?? 0) > 0 - spacing: Theme.padS - topPadding: Theme.padS + Row { + x: Theme.marker + Theme.padS + visible: root.showActions && (root.notif?.actions?.length ?? 0) > 0 + spacing: Theme.padS + topPadding: 4 - Repeater { - model: root.notif?.actions ?? [] + Repeater { + model: root.notif?.actions ?? [] - Item { - id: actionChip - required property var modelData + // A hairline button with a marker, the P5Button pattern at a + // smaller size. Hover lifts and lights it; it never inverts. + Item { + id: actionChip + required property var modelData - implicitWidth: actionLabel.implicitWidth + Theme.padM - implicitHeight: 22 + implicitWidth: chipRow.implicitWidth + Theme.padM * 2 + implicitHeight: 24 - Skew { - anchors.fill: parent - color: actionHover.hovered ? Theme.accent : Theme.surfaceAlt - borderWidth: 1 - borderColor: actionHover.hovered ? Theme.accent : Theme.overlay + Rectangle { + anchors.fill: parent + color: actionHover.hovered ? Theme.treatmentHoverFill : "transparent" + border.width: 1 + border.color: actionHover.hovered ? Theme.treatmentHoverMark : Theme.overlay + + Behavior on color { + ColorAnimation { duration: Theme.durFast } + } + } + + Row { + id: chipRow + anchors.centerIn: parent + spacing: Theme.padS + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: 4 + height: 4 + color: actionLabel.color + opacity: actionHover.hovered ? 1 : 0.4 } Text { id: actionLabel - anchors.centerIn: parent - text: actionChip.modelData.text - color: actionHover.hovered ? Theme.ink : Theme.text + anchors.verticalCenter: parent.verticalCenter + text: actionChip.modelData.text.toUpperCase() + color: actionHover.hovered ? Theme.accent : Theme.text font.family: Theme.fontDisplay font.pixelSize: Theme.fsMicro - font.weight: Font.DemiBold + font.letterSpacing: Theme.tracking renderType: Text.NativeRendering } + } - HoverHandler { - id: actionHover - cursorShape: Qt.PointingHandCursor - } + HoverHandler { + id: actionHover + cursorShape: Qt.PointingHandCursor + } - TapHandler { - onTapped: { - actionChip.modelData.invoke(); - root.dismissed(); - } + TapHandler { + onTapped: { + actionChip.modelData.invoke(); + root.dismissed(); } } } } } - - // Close affordance. - Icon { - anchors.right: parent.right - anchors.top: parent.top - visible: hover.hovered - text: "󰅖" - color: closeHover.hovered ? Theme.accent : Theme.muted - font.pixelSize: Theme.fsBody - - HoverHandler { - id: closeHover - cursorShape: Qt.PointingHandCursor - } - - TapHandler { - onTapped: root.dismissed() - } - } } HoverHandler { diff --git a/quickshell/overlays/Toasts.qml b/quickshell/overlays/Toasts.qml index 245bb36..e30831b 100644 --- a/quickshell/overlays/Toasts.qml +++ b/quickshell/overlays/Toasts.qml @@ -1,4 +1,5 @@ import QtQuick +import QtQuick.Shapes import Quickshell import Quickshell.Wayland import Quickshell.Services.Notifications @@ -6,7 +7,14 @@ import "root:/config" import "root:/components" import "root:/services" -// Floating notification stack under the bar, top right. Replaces dunst. +// Notification drawer, docked in the top-right corner of the opening. +// Replaces dunst. +// +// Not a stack of floating cards: one hull surface lowered out of the bar and +// out of the rail at once, the same construction as a corner-docked popout, +// with each notification an entry in it divided by its own header hairline. +// The drawer grows and shrinks with the entries and slides back behind the +// frame when the last one goes. PanelWindow { id: root @@ -15,23 +23,47 @@ PanelWindow { // A toast window exists on every screen, but each notification lands only // on the monitor that had focus when it arrived. Latched per toast rather - // than live, so a card does not hop screens while it is being read. + // than live, so an entry does not hop screens while it is being read. WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.namespace: "takemi-toasts" WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + exclusionMode: ExclusionMode.Ignore + // One pixel over the bar and the rail, so the drawer covers the rim the + // hull draws along both edges instead of sitting inside it. anchors { top: true; right: true } margins { - top: Theme.barHeight + Theme.frame - right: Theme.railWidth + Theme.frame + top: Theme.barHeight - 1 + right: Theme.railWidth - 1 } - implicitWidth: 400 - implicitHeight: Math.max(1, stack.implicitHeight) + readonly property real fillet: Theme.frameRadius + readonly property real corner: Theme.frameRadius + readonly property real seam: 1 + readonly property real drawerWidth: 400 + + // Height of the entries, eased, so the drawer grows rather than jumps. + property real bodyHeight: stack.implicitHeight + Behavior on bodyHeight { + NumberAnimation { duration: Theme.durBase; easing.type: Easing.OutCubic } + } + + // 0 = behind the frame, 1 = out. Follows whether anything is live. + property real reveal: root.live.length > 0 ? 1 : 0 + Behavior on reveal { + NumberAnimation { + duration: Theme.durSlow + easing.type: root.live.length > 0 ? Easing.OutCubic : Easing.InCubic + } + } + + implicitWidth: root.drawerWidth + root.fillet + root.seam + implicitHeight: Math.max(1, root.bodyHeight + root.fillet + root.seam) color: "transparent" - exclusionMode: ExclusionMode.Ignore - visible: root.live.length > 0 + visible: root.live.length > 0 || root.reveal > 0 + + mask: Region { item: stack } // Toasts currently on screen, newest first. property list live: [] @@ -53,101 +85,130 @@ PanelWindow { root.live = next; } - Column { - id: stack - anchors.right: parent.right - anchors.top: parent.top - width: parent.width - spacing: Theme.padS + Item { + anchors.fill: parent + clip: true - Repeater { - model: root.live + Item { + id: sheet + width: parent.width + height: parent.height + y: -height * (1 - root.reveal) - Item { - id: toast - required property var modelData + // Off the bar through the left fillet, round the one free corner, + // onto the rail through the bottom fillet — Popout's "topRight". + Shape { + id: silhouette + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer - width: stack.width - height: card.implicitHeight + readonly property real w: width + readonly property real h: height + readonly property real f: root.fillet + readonly property real r: Math.min(root.corner, Math.max(0, (h - f) / 2)) + readonly property string edge: + `M 0 0 A ${f} ${f} 0 0 1 ${f} ${f}` + + ` V ${h - f - r} A ${r} ${r} 0 0 0 ${f + r} ${h - f}` + + ` H ${w - f} A ${f} ${f} 0 0 1 ${w} ${h}` - NotifCard { - id: card - width: parent.width - notif: toast.modelData - onDismissed: { - Notifs.dismiss(toast.modelData); - root.retire(toast.modelData); + ShapePath { + fillColor: Theme.hull + strokeColor: "transparent" + PathSvg { path: silhouette.edge + ` L ${silhouette.w} 0 Z` } + } + + ShapePath { + fillColor: "transparent" + strokeColor: Theme.rim + strokeWidth: 1 + PathSvg { path: silhouette.edge } + } + } + + Column { + id: stack + x: root.fillet + y: root.seam + width: root.drawerWidth + bottomPadding: Theme.padS + + Repeater { + model: root.live + + Item { + id: toast + required property var modelData + + width: stack.width + height: card.implicitHeight + + NotifCard { + id: card + width: parent.width + notif: toast.modelData + onDismissed: { + Notifs.dismiss(toast.modelData); + root.retire(toast.modelData); + } + } + + // Countdown: a hairline draining along the entry's + // foot, inset to the text column. + Rectangle { + id: fuse + anchors.bottom: parent.bottom + x: Theme.padM + Theme.marker + Theme.padS + height: 1 + width: 0 + color: Theme.alpha(card.tone, 0.5) + visible: life.running + } + + readonly property real fuseSpan: + width - Theme.padM * 2 - Theme.marker - Theme.padS + + NumberAnimation { + id: life + target: fuse + property: "width" + from: toast.fuseSpan + to: 0 + running: false + duration: Notifs.timeoutFor(toast.modelData) + easing.type: Easing.Linear + onFinished: root.retire(toast.modelData) + } + + // A new entry resolves in place: a fade, no shove. + opacity: 0 + + Component.onCompleted: { + enter.start(); + const ms = Notifs.timeoutFor(toast.modelData); + if (ms > 0) life.start(); + } + + NumberAnimation { + id: enter + target: toast; property: "opacity" + to: 1; duration: Theme.durSlow; easing.type: Easing.OutCubic + } + + // Hovering an entry pauses its fuse. + HoverHandler { + onHoveredChanged: { + if (!life.duration) return; + if (hovered) life.pause(); + else if (life.paused) life.resume(); + } + } + + TapHandler { + acceptedButtons: Qt.LeftButton + onTapped: root.retire(toast.modelData) + } } } - - // Countdown hairline that drains along the bottom edge. The - // chop and lean terms are zero under the YoRHa theme; they are - // kept so the fuse still fits if a shape is brought back. - readonly property real fuseSpan: Math.max(0, - width - card.chop - card.implicitHeight * card.lean) - - Skew { - id: fuse - anchors.bottom: parent.bottom - anchors.bottomMargin: 1 - x: card.chop - height: 1 - width: toast.fuseSpan - lean: card.lean - color: Theme.alpha(card.tone, 0.7) - visible: life.running - } - - NumberAnimation { - id: life - target: fuse - property: "width" - from: toast.fuseSpan - to: 0 - running: false - duration: Notifs.timeoutFor(toast.modelData) - easing.type: Easing.Linear - onFinished: root.retire(toast.modelData) - } - - // Slide in from the right. - opacity: 0 - transform: Translate { id: shove; x: 60 } - - Component.onCompleted: { - enter.start(); - const ms = Notifs.timeoutFor(toast.modelData); - if (ms > 0) life.start(); - } - - ParallelAnimation { - id: enter - NumberAnimation { - target: toast; property: "opacity" - to: 1; duration: Theme.durBase - } - NumberAnimation { - target: shove; property: "x" - from: 60; to: 0 - duration: Theme.durSlow - easing.type: Easing.OutCubic - } - } - - // Hovering a toast pauses its fuse. - HoverHandler { - id: toastHover - onHoveredChanged: { - if (!life.duration) return; - if (hovered) life.pause(); - else if (life.paused) life.resume(); - } - } - - TapHandler { - acceptedButtons: Qt.LeftButton - onTapped: root.retire(toast.modelData) - } } } } diff --git a/quickshell/popouts/NotifPopout.qml b/quickshell/popouts/NotifPopout.qml index bc2e59c..f9d3fdf 100644 --- a/quickshell/popouts/NotifPopout.qml +++ b/quickshell/popouts/NotifPopout.qml @@ -68,7 +68,8 @@ Popout { anchors.right: parent.right anchors.bottom: parent.bottom clip: true - spacing: Theme.padS + // Entries carry their own header hairline, so they sit flush. + spacing: 0 model: Notifs.history delegate: NotifCard { @@ -79,12 +80,6 @@ Popout { } add: Transition { - NumberAnimation { - properties: "x" - from: 40 - duration: Theme.durSlow - easing.type: Easing.OutCubic - } NumberAnimation { property: "opacity" from: 0; to: 1