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

+158 -97
View File
@@ -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<Notification> 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)
}
}
}
}