This commit is contained in:
2026-05-28 21:45:21 +02:00
parent 653439ac24
commit a356a3d369
610 changed files with 205679 additions and 0 deletions
@@ -0,0 +1,415 @@
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 bool isCompact: Settings.data.notifications?.density === "compact"
readonly property int notificationWidth: Math.round((isCompact ? 320 : 440) * Style.uiScaleRatio)
readonly property int shadowPadding: Style.shadowBlurMax + Style.marginL
width: notificationWidth + shadowPadding * 2
height: Math.round(contentLayout.implicitHeight + Style.margin2M * 2 + shadowPadding * 2)
visible: true
opacity: 0
scale: initialScale
property real progress: 1.0
property bool isHovered: false
property real swipeOffset: 0
property real swipeOffsetY: 0
property real pressGlobalX: 0
property real pressGlobalY: 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 bool useVerticalSwipe: location === "bottom" || location === "top"
readonly property real swipeStartThreshold: Math.round(18 * Style.uiScaleRatio)
readonly property real swipeDismissThreshold: Math.max(110, background.width * 0.32)
readonly property real verticalSwipeDismissThreshold: Math.max(70, background.height * 0.35)
transform: Translate {
x: root.swipeOffset
y: root.swipeOffsetY
}
function clampSwipeDelta(deltaX) {
if (isRight)
return Math.max(0, deltaX);
if (isLeft)
return Math.min(0, deltaX);
return deltaX;
}
function clampVerticalSwipeDelta(deltaY) {
if (location === "bottom")
return Math.max(0, deltaY);
if (location === "top")
return Math.min(0, deltaY);
return deltaY;
}
HoverHandler {
onHoveredChanged: {
isHovered = hovered;
if (isHovered) {
resumeTimer.stop();
if (progressAnimation.running && !progressAnimation.paused) {
progressAnimation.pause();
}
} else {
resumeTimer.start();
}
}
}
Timer {
id: resumeTimer
interval: 50
repeat: false
onTriggered: {
if (!isHovered && 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, Color.adaptiveOpacity(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, Color.adaptiveOpacity(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, Color.adaptiveOpacity(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
}
}
Behavior on swipeOffsetY {
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
onPressed: mouse => {
const globalPoint = toastDragArea.mapToGlobal(mouse.x, mouse.y);
root.pressGlobalX = globalPoint.x;
root.pressGlobalY = globalPoint.y;
root.isSwiping = false;
}
onPositionChanged: mouse => {
if (!(mouse.buttons & Qt.LeftButton))
return;
const globalPoint = toastDragArea.mapToGlobal(mouse.x, mouse.y);
const rawDeltaX = globalPoint.x - root.pressGlobalX;
const rawDeltaY = globalPoint.y - root.pressGlobalY;
const deltaX = root.clampSwipeDelta(rawDeltaX);
const deltaY = root.clampVerticalSwipeDelta(rawDeltaY);
if (!root.isSwiping) {
if (root.useVerticalSwipe) {
if (Math.abs(deltaY) < root.swipeStartThreshold)
return;
root.isSwiping = true;
} else {
if (Math.abs(deltaX) < root.swipeStartThreshold)
return;
root.isSwiping = true;
}
}
if (root.useVerticalSwipe) {
root.swipeOffset = 0;
root.swipeOffsetY = deltaY;
} else {
root.swipeOffset = deltaX;
root.swipeOffsetY = 0;
}
}
onReleased: mouse => {
if (mouse.button !== Qt.LeftButton)
return;
if (root.isSwiping) {
root.isSwiping = false;
const dismissDistance = root.useVerticalSwipe ? Math.abs(root.swipeOffsetY) : Math.abs(root.swipeOffset);
const threshold = root.useVerticalSwipe ? root.verticalSwipeDismissThreshold : root.swipeDismissThreshold;
if (dismissDistance >= threshold) {
root.hide();
} else {
root.swipeOffset = 0;
root.swipeOffsetY = 0;
}
return;
}
root.hide();
}
onCanceled: {
root.isSwiping = false;
root.swipeOffset = 0;
root.swipeOffsetY = 0;
}
cursorShape: Qt.PointingHandCursor
}
RowLayout {
id: contentLayout
anchors.fill: background
anchors.topMargin: isCompact ? Style.marginS : Style.marginM
anchors.bottomMargin: isCompact ? Style.marginS : Style.marginM
anchors.leftMargin: isCompact ? Style.marginM : Style.margin2M
anchors.rightMargin: isCompact ? Style.marginM : Style.margin2M
spacing: isCompact ? Style.marginM : 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: isCompact ? Style.fontSizeXL : 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: isCompact ? Style.fontSizeM : Style.fontSizeL
font.weight: Style.fontWeightBold
wrapMode: Text.WordWrap
visible: text.length > 0
}
NText {
Layout.fillWidth: true
text: root.description
color: Color.mOnSurface
pointSize: isCompact ? Style.fontSizeS : Style.fontSizeM
wrapMode: Text.WordWrap
maximumLineCount: isCompact ? 2 : 20
elide: isCompact ? Text.ElideRight : Text.ElideNone
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
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;
isHovered = false;
isSwiping = false;
swipeOffset = 0;
swipeOffsetY = 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;
swipeOffsetY = 0;
opacity = 0;
scale = initialScale;
hideAnimation.restart();
}
function hideImmediately() {
hideAnimation.stop();
progressAnimation.stop();
isSwiping = false;
swipeOffset = 0;
swipeOffsetY = 0;
opacity = 0;
scale = initialScale;
root.hidden();
}
}
@@ -0,0 +1,22 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Wayland
import qs.Commons
import qs.Widgets
Variants {
model: {
const screens = Quickshell.screens.filter(screen => Settings.data.notifications.monitors.includes(screen.name));
// Empty list can mean two things :
// - No (visible) notification display activated in settings
// - One or more (not visible) displays are activated but unplugged
// In both cases we fallback to show notification on all screens
return screens.length === 0 ? Quickshell.screens : screens;
}
delegate: ToastScreen {
required property ShellScreen modelData
screen: modelData
}
}
@@ -0,0 +1,246 @@
import QtQuick
import Quickshell
import Quickshell.Wayland
import qs.Commons
import qs.Services.UI
Item {
id: root
required property ShellScreen screen
// Local queue for this screen only (bounded to prevent memory issues)
property var messageQueue: []
property int maxQueueSize: 10
property bool isShowingToast: false
// If true, immediately show new toasts
property bool replaceOnNew: true
Connections {
target: ToastService
function onNotify(title, description, icon, type, duration, actionLabel, actionCallback) {
root.enqueueToast({
"title": title,
"description": description,
"icon": icon,
"type": type,
"duration": duration,
"actionLabel": actionLabel || "",
"actionCallback": actionCallback || null,
"timestamp": Date.now()
});
}
function onDismiss() {
root.dismissToast();
}
}
// Clear queue on component destruction to prevent orphaned toasts
Component.onDestruction: {
messageQueue = [];
isShowingToast = false;
hideTimer.stop();
quickSwitchTimer.stop();
}
function dismissToast() {
if (windowLoader.item) {
hideTimer.stop();
windowLoader.item.hideToast();
}
}
function enqueueToast(toastData) {
// Safe logging - fix the substring bug
var descPreview = (toastData.description || "").substring(0, 100).replace(/\n/g, " ");
Logger.d("ToastScreen", "Queuing", toastData.type, ":", toastData.title, descPreview);
// Bounded queue to prevent unbounded memory growth
if (messageQueue.length >= maxQueueSize) {
Logger.d("ToastScreen", "Queue full, dropping oldest toast");
messageQueue.shift();
}
if (replaceOnNew && isShowingToast) {
// Cancel current toast and clear queue for latest toast
messageQueue = []; // Clear existing queue
messageQueue.push(toastData);
// Hide current toast immediately
root.dismissToast();
// Process new toast after a brief delay
isShowingToast = false;
quickSwitchTimer.restart();
} else {
// Queue the toast
messageQueue.push(toastData);
processQueue();
}
}
Timer {
id: quickSwitchTimer
interval: 50 // Brief delay for smooth transition
onTriggered: root.processQueue()
}
function processQueue() {
if (messageQueue.length === 0 || isShowingToast) {
return;
}
var data = messageQueue.shift();
isShowingToast = true;
// Store the toast data for when loader is ready
windowLoader.pendingToast = data;
// Activate the loader - onStatusChanged will handle showing the toast
windowLoader.active = true;
}
function onToastHidden() {
isShowingToast = false;
// Deactivate the loader to completely remove the window and free memory
windowLoader.active = false;
// Small delay before processing next toast
hideTimer.restart();
}
Timer {
id: hideTimer
interval: 200
onTriggered: root.processQueue()
}
// The loader that creates/destroys the PanelWindow as needed
// This is good for RAM efficiency when toasts are infrequent
Loader {
id: windowLoader
active: false // Only active when showing a toast
// Store pending toast data
property var pendingToast: null
onStatusChanged: {
// When loader becomes ready, show the pending toast
if (status === Loader.Ready && pendingToast !== null) {
item.showToast(pendingToast.title, pendingToast.description, pendingToast.icon, pendingToast.type, pendingToast.duration, pendingToast.actionLabel, pendingToast.actionCallback);
pendingToast = null;
}
}
sourceComponent: PanelWindow {
id: panel
property alias toastItem: toastItem
screen: root.screen
// Parse location setting
readonly property string location: Settings.data.notifications?.location || "top_right"
readonly property bool isTop: location.startsWith("top")
readonly property bool isBottom: location.startsWith("bottom")
readonly property bool isLeft: location.endsWith("_left")
readonly property bool isRight: location.endsWith("_right")
readonly property bool isCentered: location === "top" || location === "bottom"
readonly property bool isFramed: Settings.data.bar.barType === "framed"
readonly property real frameThickness: Settings.data.bar.frameThickness ?? 8
readonly property string barPos: Settings.getBarPositionForScreen(panel.screen?.name)
readonly property bool isFloating: Settings.data.bar.barType === "floating"
readonly property real barHeight: Style.getBarHeightForScreen(panel.screen?.name)
// Calculate bar and frame offsets for each edge separately
readonly property int barOffsetTop: {
if (barPos !== "top")
return isFramed ? frameThickness : 0;
const floatMarginV = isFloating ? Math.ceil(Settings.data.bar.marginVertical) : 0;
return barHeight + floatMarginV;
}
readonly property int barOffsetBottom: {
if (barPos !== "bottom")
return isFramed ? frameThickness : 0;
const floatMarginV = isFloating ? Math.ceil(Settings.data.bar.marginVertical) : 0;
return barHeight + floatMarginV;
}
readonly property int barOffsetLeft: {
if (barPos !== "left")
return isFramed ? frameThickness : 0;
const floatMarginH = isFloating ? Math.ceil(Settings.data.bar.marginHorizontal) : 0;
return barHeight + floatMarginH;
}
readonly property int barOffsetRight: {
if (barPos !== "right")
return isFramed ? frameThickness : 0;
const floatMarginH = isFloating ? Math.ceil(Settings.data.bar.marginHorizontal) : 0;
return barHeight + floatMarginH;
}
readonly property int shadowPadding: Style.shadowBlurMax + Style.marginL
// Anchoring
anchors.top: isTop
anchors.bottom: isBottom
anchors.left: isLeft
anchors.right: isRight
// Margins for PanelWindow - only apply bar offset for the specific edge where the bar is
margins.top: isTop ? barOffsetTop - shadowPadding + Style.marginM : 0
margins.bottom: isBottom ? barOffsetBottom - shadowPadding + Style.marginM : 0
margins.left: isLeft ? barOffsetLeft - shadowPadding + Style.marginM : 0
margins.right: isRight ? barOffsetRight - shadowPadding + Style.marginM : 0
implicitWidth: Math.round(toastItem.width)
implicitHeight: Math.round(toastItem.height)
color: "transparent"
WlrLayershell.layer: (Settings.data.notifications && Settings.data.notifications.overlayLayer) ? WlrLayer.Overlay : WlrLayer.Top
WlrLayershell.namespace: "noctalia-toast-" + (screen?.name || "unknown")
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
WlrLayershell.exclusionMode: ExclusionMode.Ignore
// Make shadow area click-through, only toast content is clickable
mask: Region {
x: 0
y: 0
width: panel.width
height: panel.height
intersection: Intersection.Xor
Region {
// The clickable content area is inset by shadowPadding from all edges
x: panel.shadowPadding
y: panel.shadowPadding
width: Math.max(0, panel.width - panel.shadowPadding * 2)
height: Math.max(0, panel.height - panel.shadowPadding * 2)
intersection: Intersection.Subtract
}
}
function showToast(title, description, icon, type, duration, actionLabel, actionCallback) {
toastItem.show(title, description, icon, type, duration, actionLabel, actionCallback);
}
function hideToast() {
toastItem.hideImmediately();
}
Toast {
id: toastItem
onHidden: root.onToastHidden()
}
}
}
}