Files
noctalia-shell/Modules/Toast/Toast.qml
T

375 lines
9.6 KiB
QML

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Services.System
import qs.Widgets
Item {
id: root
property string title: ""
property string description: ""
property string icon: ""
property string type: "notice"
property int duration: 3000
property string actionLabel: ""
property var actionCallback: null
readonly property real initialScale: 0.7
signal hidden
readonly property int notificationWidth: Math.round(440 * Style.uiScaleRatio)
readonly property int shadowPadding: Style.shadowBlurMax + Style.marginL
width: notificationWidth + shadowPadding * 2
height: Math.round(contentLayout.implicitHeight + Style.marginXL * 2 + shadowPadding * 2)
visible: true
opacity: 0
scale: initialScale
property real progress: 1.0
property int hoverCount: 0
property real swipeOffset: 0
property real pressGlobalX: 0
property bool isSwiping: false
readonly property string location: Settings.data.notifications?.location || "top_right"
readonly property bool isLeft: location.endsWith("_left")
readonly property bool isRight: location.endsWith("_right")
readonly property real swipeStartThreshold: Math.round(18 * Style.uiScaleRatio)
readonly property real swipeDismissThreshold: Math.max(110, background.width * 0.32)
transform: Translate {
x: root.swipeOffset
}
function clampSwipeDelta(deltaX) {
if (isRight)
return Math.max(0, deltaX);
if (isLeft)
return Math.min(0, deltaX);
return deltaX;
}
onHoverCountChanged: {
if (hoverCount > 0) {
resumeTimer.stop();
if (progressAnimation.running && !progressAnimation.paused) {
progressAnimation.pause();
}
} else {
resumeTimer.start();
}
}
Timer {
id: resumeTimer
interval: 50
repeat: false
onTriggered: {
if (hoverCount === 0 && progressAnimation.paused) {
progressAnimation.resume();
}
}
}
// Background rectangle (apply shadows here)
Rectangle {
id: background
anchors.fill: parent
anchors.margins: shadowPadding
radius: Style.radiusL
color: Qt.alpha(Color.mSurface, Settings.data.notifications.backgroundOpacity || 1.0)
// Colored border based on type
border.width: Style.borderS
border.color: {
var baseColor;
switch (root.type) {
case "error":
baseColor = Color.mError;
break;
default:
baseColor = Color.mOutline;
break;
}
return Qt.alpha(baseColor, Settings.data.notifications.backgroundOpacity || 1.0);
}
// Progress bar
Rectangle {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
height: 2
color: "transparent"
Rectangle {
id: progressBar
readonly property real progressWidth: background.width - (2 * background.radius)
height: parent.height
// Mirrored logic: centers the bar as it shrinks
x: background.radius + (progressWidth * (1 - root.progress)) / 2
width: progressWidth * root.progress
color: {
var baseColor;
switch (root.type) {
case "warning":
baseColor = Color.mPrimary;
break;
case "error":
baseColor = Color.mError;
break;
default:
baseColor = Color.mPrimary; // Match standard notification color
break;
}
return Qt.alpha(baseColor, Settings.data.notifications.backgroundOpacity || 1.0);
}
}
}
}
NDropShadow {
anchors.fill: background
source: background
autoPaddingEnabled: true
}
NumberAnimation {
id: progressAnimation
target: root
property: "progress"
from: 1.0
to: 0.0
duration: root.duration
easing.type: Easing.Linear
onFinished: {
if (root.progress === 0.0 && root.visible) {
root.hide();
}
}
}
// Timer: hideTimer removed, using progressAnimation
Behavior on opacity {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.OutCubic
}
}
Behavior on scale {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.OutCubic
}
}
Behavior on swipeOffset {
enabled: !root.isSwiping
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.OutCubic
}
}
Timer {
id: hideAnimation
interval: Style.animationFast
onTriggered: {
root.visible = false;
root.hidden();
}
}
// Cleanup on destruction
Component.onDestruction: {
progressAnimation.stop();
hideAnimation.stop();
}
// Click anywhere dismiss the toast (must be before content so action link can override)
MouseArea {
id: toastDragArea
anchors.fill: background
acceptedButtons: Qt.LeftButton
hoverEnabled: true
onEntered: {
root.hoverCount++;
}
onExited: {
root.hoverCount--;
}
onPressed: mouse => {
const globalPoint = toastDragArea.mapToGlobal(mouse.x, mouse.y);
root.pressGlobalX = globalPoint.x;
root.isSwiping = false;
}
onPositionChanged: mouse => {
if (!(mouse.buttons & Qt.LeftButton))
return;
const globalPoint = toastDragArea.mapToGlobal(mouse.x, mouse.y);
const deltaX = root.clampSwipeDelta(globalPoint.x - root.pressGlobalX);
if (!root.isSwiping) {
if (Math.abs(deltaX) < root.swipeStartThreshold)
return;
root.isSwiping = true;
}
root.swipeOffset = deltaX;
}
onReleased: mouse => {
if (mouse.button !== Qt.LeftButton)
return;
if (root.isSwiping) {
root.isSwiping = false;
if (Math.abs(root.swipeOffset) >= root.swipeDismissThreshold) {
root.hide();
} else {
root.swipeOffset = 0;
}
return;
}
root.hide();
}
onCanceled: {
root.isSwiping = false;
root.swipeOffset = 0;
}
cursorShape: Qt.PointingHandCursor
}
RowLayout {
id: contentLayout
anchors.fill: background
anchors.topMargin: Style.marginM
anchors.bottomMargin: Style.marginM
anchors.leftMargin: Style.marginXL
anchors.rightMargin: Style.marginXL
spacing: Style.marginL
// Icon
NIcon {
icon: if (root.icon !== "") {
return root.icon;
} else if (type === "warning") {
return "toast-warning";
} else if (type === "error") {
return "toast-error";
} else {
return "toast-notice";
}
color: {
switch (type) {
case "warning":
return Color.mPrimary;
case "error":
return Color.mError;
default:
return Color.mOnSurface;
}
}
pointSize: Style.fontSizeXXL * 1.5
Layout.alignment: Qt.AlignVCenter
}
// Label and description
ColumnLayout {
spacing: Style.marginXXS
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
NText {
Layout.fillWidth: true
text: root.title
color: Color.mOnSurface
pointSize: Style.fontSizeL
font.weight: Style.fontWeightBold
wrapMode: Text.WordWrap
visible: text.length > 0
}
NText {
Layout.fillWidth: true
text: root.description
color: Color.mOnSurface
pointSize: Style.fontSizeM
wrapMode: Text.WordWrap
visible: text.length > 0
}
// Action button
NButton {
text: root.actionLabel
visible: root.actionLabel.length > 0 && root.actionCallback !== null
Layout.topMargin: Style.marginXS
fontSize: Style.fontSizeS
backgroundColor: Color.mPrimary
textColor: hovered ? Color.mOnHover : Color.mOnPrimary
hoverColor: Color.mHover
outlined: false
implicitHeight: 24
onEntered: root.hoverCount++
onExited: root.hoverCount--
onClicked: {
if (root.actionCallback) {
root.actionCallback();
root.hide();
}
}
}
}
}
function show(msgTitle, msgDescription, msgIcon, msgType, msgDuration, msgActionLabel, msgActionCallback) {
// Stop all timers first
progressAnimation.stop();
hideAnimation.stop();
title = msgTitle;
description = msgDescription || "";
icon = msgIcon || "";
type = msgType || "notice";
duration = msgDuration || 3000;
actionLabel = msgActionLabel || "";
actionCallback = msgActionCallback || null;
visible = true;
opacity = 1.0;
scale = 1.0;
progress = 1.0;
hoverCount = 0;
isSwiping = false;
swipeOffset = 0;
// Configure and start animation
progressAnimation.duration = duration;
progressAnimation.from = 1.0;
progressAnimation.to = 0.0;
progressAnimation.restart();
}
function hide() {
progressAnimation.stop();
isSwiping = false;
swipeOffset = 0;
opacity = 0;
scale = initialScale;
hideAnimation.restart();
}
function hideImmediately() {
hideAnimation.stop();
progressAnimation.stop();
isSwiping = false;
swipeOffset = 0;
opacity = 0;
scale = initialScale;
root.hidden();
}
}