feat(quickshell): notifications as a NieR message log in a corner drawer

Notifications were still Persona: separate floating cards with an urgency
slash down the leading edge, action chips that filled with the accent on
hover, and a 60px shove in from the right.

NotifCard is now laid out like an entry in NieR's message log: a square
urgency marker and the sender's name running into a hairline, the summary
and body set in from the marker, and hairline action buttons with their
own small marker that lift on hover instead of inverting. It draws no
panel; rest is bare and hover lifts the row onto the surface tone.
Critical entries take the red into the marker and the sender line only.

The toast stack is one hull drawer docked into the opening's top-right
corner — off the bar through one fillet, onto the rail through the other,
the same construction as a corner-docked popout. It grows with its
entries and slides back behind the frame when the last one goes. Each
entry's countdown is a hairline along its foot, and new entries fade in
place. The history popout lists the same entries flush.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nvrlandClaude Opus 5.5 committed 2026-10-04 20:48:30 +02:00
1 parent ae6539218e
commit d6de04eaaf
3 files changed
+357 -228

No files matched your search

+197 -124
View File
@@ -5,179 +5,252 @@ import "root:/config"
import "root:/components" import "root:/components"
import "root:/services" 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 { Item {
id: root id: root
required property var notif required property var notif
property bool showActions: true property bool showActions: true
readonly property bool critical: root.notif?.urgency === NotificationUrgency.Critical
readonly property color tone: root.notif readonly property color tone: root.notif
? Notifs.urgencyColor(root.notif.urgency) ? Notifs.urgencyColor(root.notif.urgency)
: Theme.primary : 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() signal dismissed()
// The card's own geometry, published so anything drawn along its edges — implicitHeight: layout.implicitHeight + Theme.padM * 2
// 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: panel.height Rectangle {
anchors.fill: parent
color: root.hovered ? Theme.treatmentHoverFill : "transparent"
P5Panel { Behavior on color {
id: panel ColorAnimation { duration: Theme.durFast }
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
Column { Column {
id: layout id: layout
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter anchors.top: parent.top
spacing: 3 anchors.margins: Theme.padM
spacing: 4
// App line // Sender: marker, glyph, name, then the hairline to the edge.
Row { Item {
spacing: Theme.padS width: parent.width
height: 16
Icon { Rectangle {
anchors.verticalCenter: parent.verticalCenter id: marker
text: Notifs.appIconFor(root.notif) anchors.left: parent.left
color: root.tone anchors.verticalCenter: parent.verticalCenter
font.pixelSize: Theme.fsBody width: Theme.marker
} height: Theme.marker
color: root.tone
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
}
} }
Text { Icon {
width: parent.width id: glyph
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
visible: text !== "" visible: text !== ""
text: root.notif?.body || "" anchors.left: marker.right
color: Theme.subtext anchors.leftMargin: Theme.padS
elide: Text.ElideRight anchors.verticalCenter: parent.verticalCenter
maximumLineCount: 4 text: Notifs.appIconFor(root.notif)
wrapMode: Text.WordWrap color: root.critical ? Theme.alarm : Theme.muted
textFormat: Text.StyledText font.pixelSize: Theme.fsBody
font.family: Theme.fontDisplay }
font.pixelSize: Theme.fsSmall
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 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 { Image {
visible: source != "" && status === Image.Ready id: inline
x: 1
y: 1
source: root.notif?.image || "" source: root.notif?.image || ""
sourceSize.height: 90 sourceSize.height: 90
fillMode: Image.PreserveAspectFit fillMode: Image.PreserveAspectFit
asynchronous: true asynchronous: true
} }
}
Row { Row {
visible: root.showActions && (root.notif?.actions?.length ?? 0) > 0 x: Theme.marker + Theme.padS
spacing: Theme.padS visible: root.showActions && (root.notif?.actions?.length ?? 0) > 0
topPadding: Theme.padS spacing: Theme.padS
topPadding: 4
Repeater { Repeater {
model: root.notif?.actions ?? [] model: root.notif?.actions ?? []
Item { // A hairline button with a marker, the P5Button pattern at a
id: actionChip // smaller size. Hover lifts and lights it; it never inverts.
required property var modelData Item {
id: actionChip
required property var modelData
implicitWidth: actionLabel.implicitWidth + Theme.padM implicitWidth: chipRow.implicitWidth + Theme.padM * 2
implicitHeight: 22 implicitHeight: 24
Skew { Rectangle {
anchors.fill: parent anchors.fill: parent
color: actionHover.hovered ? Theme.accent : Theme.surfaceAlt color: actionHover.hovered ? Theme.treatmentHoverFill : "transparent"
borderWidth: 1 border.width: 1
borderColor: actionHover.hovered ? Theme.accent : Theme.overlay 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 { Text {
id: actionLabel id: actionLabel
anchors.centerIn: parent anchors.verticalCenter: parent.verticalCenter
text: actionChip.modelData.text text: actionChip.modelData.text.toUpperCase()
color: actionHover.hovered ? Theme.ink : Theme.text color: actionHover.hovered ? Theme.accent : Theme.text
font.family: Theme.fontDisplay font.family: Theme.fontDisplay
font.pixelSize: Theme.fsMicro font.pixelSize: Theme.fsMicro
font.weight: Font.DemiBold font.letterSpacing: Theme.tracking
renderType: Text.NativeRendering renderType: Text.NativeRendering
} }
}
HoverHandler { HoverHandler {
id: actionHover id: actionHover
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
} }
TapHandler { TapHandler {
onTapped: { onTapped: {
actionChip.modelData.invoke(); actionChip.modelData.invoke();
root.dismissed(); 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 { HoverHandler {
+158 -97
View File
@@ -1,4 +1,5 @@
import QtQuick import QtQuick
import QtQuick.Shapes
import Quickshell import Quickshell
import Quickshell.Wayland import Quickshell.Wayland
import Quickshell.Services.Notifications import Quickshell.Services.Notifications
@@ -6,7 +7,14 @@ import "root:/config"
import "root:/components" import "root:/components"
import "root:/services" 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 { PanelWindow {
id: root id: root
@@ -15,23 +23,47 @@ PanelWindow {
// A toast window exists on every screen, but each notification lands only // 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 // 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.layer: WlrLayer.Overlay
WlrLayershell.namespace: "takemi-toasts" WlrLayershell.namespace: "takemi-toasts"
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None 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 } anchors { top: true; right: true }
margins { margins {
top: Theme.barHeight + Theme.frame top: Theme.barHeight - 1
right: Theme.railWidth + Theme.frame right: Theme.railWidth - 1
} }
implicitWidth: 400 readonly property real fillet: Theme.frameRadius
implicitHeight: Math.max(1, stack.implicitHeight) 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" color: "transparent"
exclusionMode: ExclusionMode.Ignore visible: root.live.length > 0 || root.reveal > 0
visible: root.live.length > 0
mask: Region { item: stack }
// Toasts currently on screen, newest first. // Toasts currently on screen, newest first.
property list<Notification> live: [] property list<Notification> live: []
@@ -53,101 +85,130 @@ PanelWindow {
root.live = next; root.live = next;
} }
Column { Item {
id: stack anchors.fill: parent
anchors.right: parent.right clip: true
anchors.top: parent.top
width: parent.width
spacing: Theme.padS
Repeater { Item {
model: root.live id: sheet
width: parent.width
height: parent.height
y: -height * (1 - root.reveal)
Item { // Off the bar through the left fillet, round the one free corner,
id: toast // onto the rail through the bottom fillet — Popout's "topRight".
required property var modelData Shape {
id: silhouette
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
width: stack.width readonly property real w: width
height: card.implicitHeight 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 { ShapePath {
id: card fillColor: Theme.hull
width: parent.width strokeColor: "transparent"
notif: toast.modelData PathSvg { path: silhouette.edge + ` L ${silhouette.w} 0 Z` }
onDismissed: { }
Notifs.dismiss(toast.modelData);
root.retire(toast.modelData); 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)
}
} }
} }
} }
+2 -7
View File
@@ -68,7 +68,8 @@ Popout {
anchors.right: parent.right anchors.right: parent.right
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
clip: true clip: true
spacing: Theme.padS // Entries carry their own header hairline, so they sit flush.
spacing: 0
model: Notifs.history model: Notifs.history
delegate: NotifCard { delegate: NotifCard {
@@ -79,12 +80,6 @@ Popout {
} }
add: Transition { add: Transition {
NumberAnimation {
properties: "x"
from: 40
duration: Theme.durSlow
easing.type: Easing.OutCubic
}
NumberAnimation { NumberAnimation {
property: "opacity" property: "opacity"
from: 0; to: 1 from: 0; to: 1