The rebase itself is the previous five commits replayed onto main; this is what
they needed once they landed there, kept separate so the replay stays readable.
The shell did not start. A Column in this branch's notification settings menu
assigned its own implicitWidth and implicitHeight, which a Qt 6 positioner
computes and does not let you set. That took out every contract that launches a
shell -- six of the seven failures were this one line, and none of them said so
until the error was read to the bottom. A Column already measures itself from its
children, so the bindings are simply gone.
Three assertions pinned an implementation main has since replaced, and each is
updated to pin the intent rather than the mechanism:
- The display picker now reads primaryFirstMonitors, which is monitors sorted
with the primary first. Still populated from what is connected, which is what
the contract is for; the sorted list is the point, so the picker opens on the
display somebody is most likely to mean. This branch made that change and
broke its own contract without noticing.
- The accent swatches come from the accentName schema rather than
Object.keys(Theme.accents). Same swatches, same order, one source shared with
every other enum row.
- The OSD used to take no pointer input at all. It takes some now, because this
branch's own design calls for a secondary click on a visible OSD to open its
settings, and a Wayland input region cannot admit one button and refuse
another. The rule that survives is that the region stays bounded to the OSD's
own card: it floats over other windows for a couple of seconds, and a region
bigger than the card would swallow clicks meant for something underneath.
Theme's accent table moved to ThemeProfiles, which is this branch's point -- a
curated accent and a custom profile become the same kind of record. main had
meanwhile given each accent a `gnome` member, the nearest name in GNOME's fixed
accent-color enum, which is what makes libadwaita applications recolor instead of
staying in GNOME blue. That member moved into the curated table rather than being
dropped, and adwaita-accent-contract now reads it where it lives.
Where main had simply moved further along the same path, main won: the focused
border driven by the chosen accent rather than a hardcoded pair, the gradient
built through the shared serializer rather than a hand-rolled string, the
multi-edge dock geometry. This branch's context menu, keyboard focus and
accessibility work sit on top of those rather than beside them.
Three new contracts arrived carrying .sh and lost it, along with the references
in this branch's own plan.
124 contracts pass.
Claude-Session: https://claude.ai/code/session_01NvgBuSWB5sE43yWmg21ozj
351 lines
11 KiB
QML
351 lines
11 KiB
QML
// One notification, rendered GNOME-style: app icon, app name, summary, body,
|
|
// optional image, action buttons and an inline reply box.
|
|
//
|
|
// Shared by the toasts and the notification center so a notification looks the
|
|
// same wherever you meet it.
|
|
//
|
|
// IconButton/ThemedIcon come from the quicksettings module — they belong in
|
|
// widgets/, but that directory is owned elsewhere.
|
|
|
|
import QtQuick
|
|
import Quickshell
|
|
import Quickshell.Widgets
|
|
import Quickshell.Services.Notifications
|
|
import qs.config
|
|
import qs.services
|
|
import qs.modules.quicksettings
|
|
import qs.modules.bar
|
|
import qs.widgets
|
|
|
|
Rectangle {
|
|
id: root
|
|
|
|
required property var notification
|
|
|
|
// The center renders a denser card than a toast banner.
|
|
property bool compact: false
|
|
|
|
signal dismissed
|
|
readonly property alias hovered: hover.containsMouse
|
|
// Raised when the reply field takes focus: the toast window has to ask the
|
|
// compositor for keyboard focus before anything can be typed.
|
|
signal replyFocusChanged(bool focused)
|
|
|
|
// The image sits outside `layout`, so the card has to be tall enough for
|
|
// whichever of the two is bigger.
|
|
implicitHeight: Math.max(layout.implicitHeight + 24, image.visible ? 96 : 0)
|
|
radius: Theme.cardRadius
|
|
border.width: 0
|
|
// A real popover surface, the same one the date menu and clipboard panel
|
|
// use, rather than a 6% foreground tint.
|
|
//
|
|
// A tint that faint has nothing behind it: the toast window is transparent,
|
|
// so the card was sitting directly on whatever happened to be on screen and
|
|
// the text competed with it. Notifications are the one surface someone reads
|
|
// without having chosen to look at it, so it has to be legible over a
|
|
// bright photo and a white document alike.
|
|
color: hover.containsMouse
|
|
? Theme.alpha(Theme.mix(Theme.bgPopover, Theme.fg, 0.06), Theme.toastAlpha)
|
|
: Theme.alpha(Theme.bgPopover, Theme.toastAlpha)
|
|
|
|
Behavior on color {
|
|
ColorAnimation {
|
|
duration: Theme.durFast
|
|
}
|
|
}
|
|
|
|
// Critical notifications get a red edge rather than a color wash, so the
|
|
// text contrast never changes.
|
|
Rectangle {
|
|
anchors.left: parent.left
|
|
anchors.top: parent.top
|
|
anchors.bottom: parent.bottom
|
|
anchors.margins: 6
|
|
width: 3
|
|
radius: 1.5
|
|
border.width: 0
|
|
color: Theme.urgent
|
|
visible: root.notification.urgency === NotificationUrgency.Critical
|
|
}
|
|
|
|
readonly property var defaultAction: {
|
|
for (const action of root.notification.actions) {
|
|
if (action.identifier === "default")
|
|
return action;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// "default" is the card click, so it never becomes a button.
|
|
readonly property bool hasButtons: {
|
|
for (const action of root.notification.actions) {
|
|
if (action.identifier !== "default")
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Everything except the buttons: clicking the body runs the notification's
|
|
// default action, which is what GNOME does.
|
|
MouseArea {
|
|
id: hover
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: root.defaultAction ? Qt.PointingHandCursor : Qt.ArrowCursor
|
|
onClicked: {
|
|
if (root.defaultAction)
|
|
root.defaultAction.invoke();
|
|
}
|
|
}
|
|
|
|
IconImage {
|
|
id: appIcon
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: 14
|
|
anchors.top: parent.top
|
|
anchors.topMargin: 13
|
|
implicitSize: 22
|
|
asynchronous: true
|
|
source: {
|
|
if (root.notification.appIcon !== "")
|
|
return Quickshell.iconPath(root.notification.appIcon, true);
|
|
const entry = DesktopEntries.byId(root.notification.desktopEntry);
|
|
return entry ? Quickshell.iconPath(entry.icon, true) : "";
|
|
}
|
|
visible: source !== ""
|
|
}
|
|
|
|
IconButton {
|
|
id: closeButton
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 6
|
|
anchors.top: parent.top
|
|
anchors.topMargin: 6
|
|
size: 24
|
|
iconSize: 12
|
|
tint: Theme.fgDim
|
|
icon: "window-close-symbolic"
|
|
onClicked: root.dismissed()
|
|
}
|
|
|
|
IconButton {
|
|
id: settingsMenuButton
|
|
anchors.right: closeButton.left
|
|
anchors.rightMargin: 2
|
|
anchors.top: parent.top
|
|
anchors.topMargin: 6
|
|
size: 24
|
|
iconSize: 14
|
|
tint: Theme.fgDim
|
|
icon: "view-more-symbolic"
|
|
iconFallback: "open-menu-symbolic"
|
|
onClicked: settingsMenu.visible = !settingsMenu.visible
|
|
}
|
|
|
|
Popover {
|
|
id: settingsMenu
|
|
anchorItem: settingsMenuButton
|
|
|
|
// A Column already measures itself from its children, and in Qt 6 both
|
|
// implicit sizes are read-only on a positioner -- assigning them makes
|
|
// the whole shell fail to load rather than just this menu.
|
|
Column {
|
|
TrayMenuRow {
|
|
id: notificationSettings
|
|
label: "Notification settings"
|
|
onActivated: {
|
|
ShellState.openSettings("notifications");
|
|
settingsMenu.visible = false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Column {
|
|
id: layout
|
|
anchors.left: appIcon.visible ? appIcon.right : parent.left
|
|
anchors.leftMargin: appIcon.visible ? 10 : 14
|
|
anchors.right: image.visible ? image.left : settingsMenuButton.left
|
|
anchors.rightMargin: 8
|
|
anchors.top: parent.top
|
|
anchors.topMargin: 12
|
|
spacing: 3
|
|
|
|
Text {
|
|
width: parent.width
|
|
text: {
|
|
const app = root.notification.appName || "Notification";
|
|
const time = Notifs.timeText(root.notification.id);
|
|
return time === "" ? app : app + " · " + time;
|
|
}
|
|
color: Theme.fgMuted
|
|
elide: Text.ElideRight
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
}
|
|
|
|
Text {
|
|
width: parent.width
|
|
visible: text !== ""
|
|
text: root.notification.summary
|
|
color: Theme.fg
|
|
elide: Text.ElideRight
|
|
maximumLineCount: 2
|
|
wrapMode: Text.Wrap
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSize
|
|
font.weight: Font.DemiBold
|
|
}
|
|
|
|
Text {
|
|
width: parent.width
|
|
visible: text !== ""
|
|
text: root.notification.body
|
|
color: Theme.fgDim
|
|
// The server advertises bodyMarkupSupported, so bodies arrive with
|
|
// the freedesktop subset of HTML in them.
|
|
textFormat: Text.StyledText
|
|
linkColor: Theme.accent
|
|
elide: Text.ElideRight
|
|
wrapMode: Text.Wrap
|
|
maximumLineCount: root.compact ? 6 : 3
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSize
|
|
}
|
|
|
|
Item {
|
|
width: 1
|
|
height: 4
|
|
visible: actionRow.visible || replyBox.visible
|
|
}
|
|
|
|
Flow {
|
|
id: actionRow
|
|
width: parent.width
|
|
spacing: 6
|
|
visible: root.hasButtons
|
|
|
|
Repeater {
|
|
model: root.notification.actions
|
|
|
|
Rectangle {
|
|
id: actionButton
|
|
|
|
required property var modelData
|
|
|
|
visible: actionButton.modelData.identifier !== "default"
|
|
width: visible ? actionLabel.implicitWidth + 22 : 0
|
|
height: visible ? 28 : 0
|
|
radius: Theme.pillRadius
|
|
border.width: 0
|
|
color: actionMouse.containsMouse ? Theme.alpha(Theme.accent, 0.28) : Theme.alpha(Theme.fg, 0.1)
|
|
|
|
Behavior on color {
|
|
ColorAnimation {
|
|
duration: Theme.durFast
|
|
}
|
|
}
|
|
|
|
Text {
|
|
id: actionLabel
|
|
anchors.centerIn: parent
|
|
text: actionButton.modelData.text
|
|
color: Theme.fg
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
font.weight: Font.Medium
|
|
}
|
|
|
|
MouseArea {
|
|
id: actionMouse
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: actionButton.modelData.invoke()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
id: replyBox
|
|
width: parent.width
|
|
height: 36
|
|
visible: root.notification.hasInlineReply
|
|
radius: Theme.cardRadius - 2
|
|
border.width: 1
|
|
border.color: replyInput.activeFocus ? Theme.alpha(Theme.accent, 0.5) : Theme.alpha(Theme.fg, 0.12)
|
|
color: Theme.alpha(Theme.fg, 0.08)
|
|
|
|
TextInput {
|
|
id: replyInput
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: 10
|
|
anchors.right: replySend.left
|
|
anchors.rightMargin: 4
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
color: Theme.fg
|
|
selectionColor: Theme.alpha(Theme.accent, 0.5)
|
|
selectedTextColor: Theme.fg
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSize
|
|
clip: true
|
|
|
|
onActiveFocusChanged: root.replyFocusChanged(replyInput.activeFocus)
|
|
|
|
onAccepted: {
|
|
if (replyInput.text.length === 0)
|
|
return;
|
|
root.notification.sendInlineReply(replyInput.text);
|
|
replyInput.text = "";
|
|
root.dismissed();
|
|
}
|
|
|
|
Text {
|
|
anchors.fill: parent
|
|
verticalAlignment: Text.AlignVCenter
|
|
visible: replyInput.text === ""
|
|
text: root.notification.inlineReplyPlaceholder || "Reply"
|
|
color: Theme.fgMuted
|
|
font: replyInput.font
|
|
}
|
|
}
|
|
|
|
IconButton {
|
|
id: replySend
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 3
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
size: 28
|
|
iconSize: 14
|
|
icon: "go-next-symbolic"
|
|
onClicked: replyInput.accepted()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Notification images (album art, avatars, screenshots) sit on the right.
|
|
ClippingRectangle {
|
|
id: image
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 14
|
|
anchors.top: closeButton.bottom
|
|
anchors.topMargin: 4
|
|
implicitWidth: 52
|
|
implicitHeight: 52
|
|
radius: 8
|
|
border.width: 0
|
|
color: "transparent"
|
|
visible: root.notification.image !== ""
|
|
|
|
Image {
|
|
anchors.fill: parent
|
|
source: root.notification.image
|
|
fillMode: Image.PreserveAspectCrop
|
|
asynchronous: true
|
|
sourceSize.width: 104
|
|
sourceSize.height: 104
|
|
}
|
|
}
|
|
}
|