init: working version

This commit is contained in:
Kiana Sheibani 2025-10-07 19:43:46 -04:00
commit 7d8d7dacae
Signed by: toki
GPG key ID: 6CB106C25E86A9F7
109 changed files with 15066 additions and 0 deletions

View file

@ -0,0 +1,61 @@
import qs.config
import qs.custom
import qs.services
import Quickshell
import QtQuick
import QtQuick.Shapes
Shape {
id: root
required property Item wrapper
readonly property real rounding: Config.border.rounding
readonly property bool flatten: wrapper.height < rounding * 2
readonly property real roundingY: flatten ? wrapper.height / 2 : rounding
property real fullHeightRounding: wrapper.height >= QsWindow.window?.height - Config.border.thickness * 2 ? -rounding : rounding
ShapePath {
startX: root.wrapper.width + 0.5
startY: root.wrapper.height + 0.5
strokeWidth: -1
fillColor: Config.colors.bg
PathLine {
relativeX: -(root.wrapper.width + root.rounding)
relativeY: 0
}
PathArc {
relativeX: root.rounding
relativeY: -root.roundingY
radiusX: root.rounding
radiusY: Math.min(root.rounding, root.wrapper.height)
direction: PathArc.Counterclockwise
}
PathLine {
relativeX: 0
relativeY: -root.wrapper.height + root.roundingY * 2
}
PathArc {
relativeX: root.fullHeightRounding
relativeY: -root.roundingY
radiusX: Math.abs(root.fullHeightRounding)
radiusY: Math.min(root.rounding, root.wrapper.height)
direction: root.fullHeightRounding < 0 ? PathArc.Counterclockwise : PathArc.Clockwise
}
PathLine {
relativeX: root.wrapper.height > 0 ? root.wrapper.width - root.rounding - root.fullHeightRounding : root.wrapper.width
relativeY: 0
}
PathArc {
relativeX: root.rounding
relativeY: -root.rounding
radiusX: root.rounding
radiusY: root.rounding
direction: PathArc.Counterclockwise
}
}
Behavior on fullHeightRounding {
Anim {}
}
}

View file

@ -0,0 +1,195 @@
import qs.config
import qs.custom
import qs.services
import Quickshell
import Quickshell.Widgets
import QtQuick
Item {
id: root
required property PersistentProperties uiState
required property Item panels
readonly property int padding: 15
readonly property var monitor: Hypr.monitorFor(QsWindow.window.screen)
readonly property var notifs: Notifs.list.filter(n => n.popup === monitor)
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.right: parent.right
implicitWidth: implicitHeight > 0 ? Config.notifs.width + padding * 2 : 0
implicitHeight: {
const count = list.count;
if (count === 0)
return 0;
let height = (count - 1) * 10 + padding * 2;
for (let i = 0; i < count; i++)
height += list.itemAtIndex(i)?.nonAnimHeight ?? 0;
if (uiState.osd) {
const h = panels.osd.y - Config.border.rounding * 2;
if (height > h)
height = h;
}
return Math.min((QsWindow.window?.screen?.height ?? 0) - Config.bar.height - Config.border.thickness - padding, height);
}
ClippingWrapperRectangle {
anchors.fill: parent
anchors.margins: root.padding
color: "transparent"
radius: 17
CustomListView {
id: list
model: ScriptModel {
values: root.notifs
}
anchors.fill: parent
orientation: Qt.Vertical
spacing: 0
cacheBuffer: QsWindow.window?.screen.height ?? 0
delegate: Item {
id: wrapper
required property Notifs.Notif modelData
required property int index
readonly property alias nonAnimHeight: notif.nonAnimHeight
property int idx
onIndexChanged: {
if (index !== -1)
idx = index;
}
implicitWidth: notif.implicitWidth
implicitHeight: notif.implicitHeight + (idx === 0 ? 0 : 10)
ListView.onRemove: removeAnim.start()
SequentialAnimation {
id: removeAnim
PropertyAction {
target: wrapper
property: "ListView.delayRemove"
value: true
}
PropertyAction {
target: wrapper
property: "enabled"
value: false
}
PropertyAction {
target: wrapper
property: "implicitHeight"
value: 0
}
PropertyAction {
target: wrapper
property: "z"
value: 1
}
Anim {
target: notif
property: "x"
to: (notif.x >= 0 ? Config.notifs.width : -Config.notifs.width) * 2
easing.bezierCurve: Config.anim.curves.emphasized
}
PropertyAction {
target: wrapper
property: "ListView.delayRemove"
value: false
}
}
ClippingRectangle {
anchors.top: parent.top
anchors.topMargin: wrapper.idx === 0 ? 0 : 10
color: "transparent"
radius: notif.radius
implicitWidth: notif.implicitWidth
implicitHeight: notif.implicitHeight
Notification {
id: notif
notif: wrapper.modelData
}
}
}
move: Transition {
NotifAnim {
property: "y"
}
}
displaced: Transition {
NotifAnim {
property: "y"
}
}
ExtraIndicator {
anchors.top: parent.top
extra: {
const count = list.count;
if (count === 0)
return 0;
const scrollY = list.contentY;
let height = 0;
for (let i = 0; i < count; i++) {
height += (list.itemAtIndex(i)?.nonAnimHeight ?? 0) + 10;
if (height - 10 >= scrollY)
return i;
}
return count;
}
}
ExtraIndicator {
anchors.bottom: parent.bottom
extra: {
const count = list.count;
if (count === 0)
return 0;
const scrollY = list.contentHeight - (list.contentY + list.height);
let height = 0;
for (let i = count - 1; i >= 0; i--) {
height += (list.itemAtIndex(i)?.nonAnimHeight ?? 0) + 10;
if (height - 10 >= scrollY)
return count - i - 1;
}
return 0;
}
}
}
}
Behavior on implicitHeight {
NotifAnim {}
}
component NotifAnim: Anim {
duration: Config.anim.durations.expressiveDefaultSpatial
easing.bezierCurve: Config.anim.curves.expressiveDefaultSpatial
}
}

View file

@ -0,0 +1,599 @@
pragma ComponentBehavior: Bound
import qs.config
import qs.custom
import qs.services
import qs.util
import Quickshell
import Quickshell.Widgets
import Quickshell.Services.Notifications
import QtQuick
import QtQuick.Layouts
import QtQuick.Shapes
CustomRect {
id: root
required property Notifs.Notif notif
readonly property bool hasImage: notif.image.length > 0
readonly property bool hasAppIcon: notif.appIcon.length > 0
readonly property int nonAnimHeight: inner.nonAnimHeight + inner.anchors.margins * 2
property bool inPopup: true
property bool expanded: false
color: root.notif.urgency === NotificationUrgency.Critical ? Config.colors.errorBg : Config.colors.container
radius: 12
implicitWidth: Config.notifs.width
implicitHeight: inner.implicitHeight + inner.anchors.margins * 2
onExpandedChanged: {
if (root.inPopup && expanded)
root.notif.timer.stop();
}
x: inPopup ? Config.notifs.width : 0
Component.onCompleted: x = 0
Behavior on x {
Anim {
easing.bezierCurve: Config.anim.curves.emphasizedDecel
}
}
RetainableLock {
object: root.notif.notification
locked: true
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: root.expanded && body.hoveredLink ? Qt.PointingHandCursor : pressed ? Qt.ClosedHandCursor : undefined
acceptedButtons: Qt.AllButtons
preventStealing: true
onEntered: {
if (root.inPopup)
root.notif.timer.stop();
}
onExited: {
if (root.inPopup && !pressed && !root.expanded)
root.notif.timer.start();
}
drag.target: parent
drag.axis: Drag.XAxis
onPressed: event => {
if (event.button === Qt.MiddleButton)
root.notif.notification.dismiss();
}
onReleased: event => {
if (root.inPopup && !containsMouse && !root.expanded)
root.notif.timer.start();
if (Math.abs(root.x) < Config.notifs.width * Config.notifs.clearThreshold)
root.x = 0;
else if (root.inPopup)
root.notif.popup = null;
else
root.notif.notification.dismiss();
}
onClicked: event => {
if (event.button === Qt.LeftButton) {
const actions = root.notif.actions;
if (actions?.length === 1)
actions[0].invoke();
else
root.expanded = !root.expanded;
} else if (event.button === Qt.RightButton) {
root.expanded = !root.expanded;
}
}
Item {
id: inner
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 8
readonly property real nonAnimHeight: root.expanded ? summary.height + appName.height + body.height + actions.height + 16
: Config.notifs.imageSize
implicitHeight: nonAnimHeight
Behavior on implicitHeight {
Anim {
duration: Config.anim.durations.expressiveDefaultSpatial
easing.bezierCurve: Config.anim.curves.expressiveDefaultSpatial
}
}
Loader {
id: image
active: root.hasImage
asynchronous: true
anchors.left: parent.left
anchors.top: parent.top
width: Config.notifs.imageSize
height: Config.notifs.imageSize
visible: root.hasImage || root.hasAppIcon
sourceComponent: ClippingRectangle {
radius: 1000
implicitWidth: Config.notifs.imageSize
implicitHeight: Config.notifs.imageSize
Image {
anchors.fill: parent
source: Qt.resolvedUrl(root.notif.image)
fillMode: Image.PreserveAspectCrop
cache: false
asynchronous: true
}
}
}
Loader {
id: appIcon
active: root.hasAppIcon || !root.hasImage
asynchronous: true
anchors.horizontalCenter: root.hasImage ? undefined : image.horizontalCenter
anchors.verticalCenter: root.hasImage ? undefined : image.verticalCenter
anchors.right: root.hasImage ? image.right : undefined
anchors.bottom: root.hasImage ? image.bottom : undefined
sourceComponent: CustomRect {
radius: 1000
color: root.notif.urgency === NotificationUrgency.Critical ? Config.colors.error
: root.notif.urgency === NotificationUrgency.Low ? Config.colors.inactive
: Config.colors.notification
implicitWidth: root.hasImage ? Config.notifs.badgeSize : Config.notifs.imageSize
implicitHeight: root.hasImage ? Config.notifs.badgeSize : Config.notifs.imageSize
Loader {
id: icon
active: root.hasAppIcon
asynchronous: true
anchors.centerIn: parent
width: Math.round(parent.width * 0.6)
height: Math.round(parent.width * 0.6)
sourceComponent: IconImage {
anchors.fill: parent
source: Quickshell.iconPath(root.notif.appIcon)
asynchronous: true
}
}
Loader {
active: !root.hasAppIcon
asynchronous: true
anchors.centerIn: parent
sourceComponent: MaterialIcon {
text: Icons.getNotifIcon(root.notif.summary, root.notif.urgency)
color: root.notif.urgency === NotificationUrgency.Low ? Config.colors.primary
: Config.colors.primaryDark
font.pointSize: Config.font.size.larger
}
}
}
}
CustomText {
id: appName
anchors.top: parent.top
anchors.left: image.right
anchors.leftMargin: 10
animate: true
text: appNameMetrics.elidedText
maximumLineCount: 1
color: root.notif.urgency === NotificationUrgency.Critical ? Config.colors.primary : Config.colors.tertiary
font.pointSize: Config.font.size.small
opacity: root.expanded ? 1 : 0
visible: opacity > 0
Behavior on opacity {
Anim {}
}
}
TextMetrics {
id: appNameMetrics
text: root.notif.appName
font.family: appName.font.family
font.pointSize: appName.font.pointSize
elide: Text.ElideRight
elideWidth: closeBtn.x - timeDetail.width - time.width - timeSep.width - summaryPreview.x - 21
}
CustomText {
id: summaryPreview
anchors.top: parent.top
anchors.left: image.right
anchors.leftMargin: 10
anchors.topMargin: 2
animate: true
text: summaryPreviewMetrics.elidedText
color: root.notif.urgency === NotificationUrgency.Low ? Config.colors.primary : Config.colors.secondary
opacity: root.expanded ? 0 : 1
visible: opacity > 0
states: State {
name: "expanded"
when: root.expanded
AnchorChanges {
target: summaryPreview
anchors.top: appName.bottom
}
}
transitions: Transition {
AnchorAnimation {
duration: Config.anim.durations.normal
easing.type: Easing.BezierSpline
easing.bezierCurve: Config.anim.curves.standard
}
}
Behavior on opacity {
Anim {}
}
}
TextMetrics {
id: summaryPreviewMetrics
text: root.notif.summary
font.family: summaryPreview.font.family
font.pointSize: summaryPreview.font.pointSize
elide: Text.ElideRight
elideWidth: closeBtn.x - time.width - timeSep.width - summaryPreview.x - 21
}
CustomText {
id: summary
anchors.top: parent.top
anchors.left: image.right
anchors.right: parent.right
anchors.leftMargin: 10
anchors.topMargin: 5
anchors.rightMargin: 10
animate: true
text: root.notif.summary
color: root.notif.urgency === NotificationUrgency.Low ? Config.colors.primary : Config.colors.secondary
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
states: State {
name: "expanded"
when: root.expanded
AnchorChanges {
target: summary
anchors.top: appName.bottom
}
}
transitions: Transition {
AnchorAnimation {
duration: Config.anim.durations.normal
easing.type: Easing.BezierSpline
easing.bezierCurve: Config.anim.curves.standard
}
}
opacity: root.expanded ? 1 : 0
visible: opacity > 0
Behavior on opacity {
Anim {}
}
}
CustomText {
id: timeSep
anchors.top: parent.top
anchors.left: summaryPreview.right
anchors.leftMargin: 7
anchors.topMargin: root.expanded ? 0 : 3
text: "•"
color: root.notif.urgency === NotificationUrgency.Critical ? Config.colors.primary : Config.colors.tertiary
font.pointSize: Config.font.size.small
states: State {
name: "expanded"
when: root.expanded
AnchorChanges {
target: timeSep
anchors.left: appName.right
}
}
transitions: Transition {
AnchorAnimation {
duration: Config.anim.durations.normal
easing.type: Easing.BezierSpline
easing.bezierCurve: Config.anim.curves.standard
}
}
}
CustomText {
id: time
anchors.top: parent.top
anchors.left: timeSep.right
anchors.leftMargin: 7
anchors.topMargin: root.expanded ? 0 : 2
animate: true
horizontalAlignment: Text.AlignLeft
text: root.notif.timeSince
color: root.notif.urgency === NotificationUrgency.Critical ? Config.colors.primary : Config.colors.tertiary
font.pointSize: Config.font.size.small
}
CustomText {
id: timeDetail
anchors.verticalCenter: time.verticalCenter
anchors.left: time.right
anchors.leftMargin: 5
horizontalAlignment: Text.AlignLeft
text: `(${root.notif.timeStr})`
color: root.notif.urgency === NotificationUrgency.Critical ? Config.colors.primary : Config.colors.tertiary
font.pointSize: Config.font.size.small
opacity: root.expanded ? 1 : 0
Behavior on opacity {
Anim {}
}
}
StateLayer {
id: closeBtn
anchors.right: expandBtn.left
anchors.top: parent.top
implicitWidth: 20
implicitHeight: 20
function onClicked() {
root.notif.notification.dismiss();
}
MaterialIcon {
id: closeIcon
anchors.centerIn: parent
animate: true
text: "close"
font.pointSize: Config.font.size.normal
}
}
StateLayer {
id: expandBtn
anchors.right: parent.right
anchors.top: parent.top
implicitWidth: 20
implicitHeight: 20
color: root.notif.urgency === NotificationUrgency.Critical ? Config.colors.secondary : Config.colors.primary
radius: 1000
function onClicked() {
root.expanded = !root.expanded
}
MaterialIcon {
id: expandIcon
anchors.centerIn: parent
anchors.verticalCenterOffset: text === "expand_more" ? 1 : 0
animate: true
text: root.expanded ? "expand_less" : "expand_more"
font.pointSize: Config.font.size.normal
}
}
CustomText {
id: bodyPreview
anchors.left: summaryPreview.left
anchors.right: parent.right
anchors.top: summaryPreview.bottom
anchors.topMargin: 3
anchors.rightMargin: 7
animate: true
textFormat: Text.MarkdownText
text: bodyPreviewMetrics.elidedText
color: root.notif.urgency === NotificationUrgency.Low ? Config.colors.tertiary : Config.colors.primary
font.pointSize: Config.font.size.small
opacity: root.expanded ? 0 : 1
visible: opacity > 0
Behavior on opacity {
Anim {}
}
}
TextMetrics {
id: bodyPreviewMetrics
text: root.notif.bodyOneLine
font.family: bodyPreview.font.family
font.pointSize: bodyPreview.font.pointSize
elide: Text.ElideRight
elideWidth: bodyPreview.width
}
CustomText {
id: body
anchors.left: summary.left
anchors.right: parent.right
anchors.top: summary.bottom
anchors.rightMargin: 7
anchors.topMargin: 3
animate: true
textFormat: Text.MarkdownText
text: root.notif.body
color: root.notif.urgency === NotificationUrgency.Low ? Config.colors.tertiary : Config.colors.primary
font.pointSize: Config.font.size.small
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
height: text ? implicitHeight : 0
onLinkActivated: link => {
if (!root.expanded)
return;
Quickshell.execDetached(["xdg-open", link]);
root.notif.notification.dismiss(); // TODO: change back to popup when notif dock impled
}
opacity: root.expanded ? 1 : 0
visible: opacity > 0
Behavior on opacity {
Anim {}
}
}
RowLayout {
id: actions
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: body.bottom
anchors.topMargin: 10
spacing: 10
opacity: root.expanded ? 1 : 0
Behavior on opacity {
Anim {}
}
Action {
modelData: QtObject {
readonly property string text: qsTr("Close")
function invoke(): void {
root.notif.notification.dismiss();
}
}
}
Repeater {
model: root.notif.actions
delegate: Component {
Action {}
}
}
}
}
}
CustomRect {
id: progressBar
visible: root.inPopup
anchors.bottom: parent.bottom
height: 2
color: root.notif.urgency === NotificationUrgency.Critical ? Config.colors.error : Config.colors.inactive
opacity: root.notif.timer.running ? 1 : 0
NumberAnimation on implicitWidth {
from: root.width
to: 0
duration: Config.notifs.defaultExpireTimeout
running: root.notif.timer.running
}
Behavior on opacity {
Anim { duration: Config.anim.durations.small }
}
}
component Action: CustomRect {
id: action
required property var modelData
radius: 1000
color: root.notif.urgency === NotificationUrgency.Critical ? Config.colors.error : Config.colors.inactive
implicitWidth: actionText.width + 20
implicitHeight: actionText.height + 10
Layout.preferredWidth: implicitWidth
Layout.preferredHeight: implicitHeight
StateLayer {
anchors.fill: parent
radius: 1000
color: root.notif.urgency === NotificationUrgency.Critical ? "#ffffff" : Config.colors.primary
function onClicked(): void {
action.modelData.invoke();
}
}
CustomText {
id: actionText
anchors.centerIn: parent
text: actionTextMetrics.elidedText
color: root.notif.urgency === NotificationUrgency.Critical ? Config.colors.primaryDark
: root.notif.urgency === NotificationUrgency.Low ? Config.colors.primary : Config.colors.secondary
font.pointSize: Config.font.size.small
}
TextMetrics {
id: actionTextMetrics
text: action.modelData.text
font.family: actionText.font.family
font.pointSize: actionText.font.pointSize
elide: Text.ElideRight
elideWidth: {
const numActions = root.notif.actions.length + 1;
return (inner.width - actions.spacing * (numActions - 1)) / numActions - 20;
}
}
}
}

View file

@ -0,0 +1,34 @@
import qs.config
import qs.custom
import Quickshell
import Quickshell.Services.Notifications
import QtQuick
Item {
id: root
required property PersistentProperties uiState
required property Item panels
implicitHeight: content.implicitHeight
implicitWidth: content.implicitWidth
Background {
id: background
visible: false
wrapper: root
}
GlowEffect {
source: background
glowColor: content.notifs.find(n => n.urgency === NotificationUrgency.Critical) ?
Config.colors.error : Config.colors.notification
}
Content {
id: content
uiState: root.uiState
panels: root.panels
}
}