Files
noctalia-shell/Modules/Toast/ToastScreen.qml
T
anthonyhab f34dcc11b9 feat: convert bar margins from percentages to pixels
Change bar margin settings from percentage values (0-1) to integer pixel values (0-18). Add migration 38 to convert existing settings. Update all margin calculations to use pixel values directly. Update UI labels to show "px" instead of "%".
2026-01-07 12:20:07 -05:00

235 lines
7.4 KiB
QML

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()
});
}
}
// Clear queue on component destruction to prevent orphaned toasts
Component.onDestruction: {
messageQueue = [];
isShowingToast = false;
hideTimer.stop();
quickSwitchTimer.stop();
}
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
if (windowLoader.item) {
hideTimer.stop();
windowLoader.item.hideToast();
}
// 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 string barPos: Settings.data.bar.position
readonly property bool isFloating: Settings.data.bar.floating
// Calculate bar offsets for each edge separately
readonly property int barOffsetTop: {
if (barPos !== "top")
return 0;
const floatMarginV = isFloating ? Math.ceil(Settings.data.bar.marginVertical) : 0;
return Style.barHeight + floatMarginV;
}
readonly property int barOffsetBottom: {
if (barPos !== "bottom")
return 0;
const floatMarginV = isFloating ? Math.ceil(Settings.data.bar.marginVertical) : 0;
return Style.barHeight + floatMarginV;
}
readonly property int barOffsetLeft: {
if (barPos !== "left")
return 0;
const floatMarginH = isFloating ? Math.ceil(Settings.data.bar.marginHorizontal) : 0;
return Style.barHeight + floatMarginH;
}
readonly property int barOffsetRight: {
if (barPos !== "right")
return 0;
const floatMarginH = isFloating ? Math.ceil(Settings.data.bar.marginHorizontal) : 0;
return Style.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();
}
SimpleToast {
id: toastItem
onHidden: root.onToastHidden()
}
}
}
}