import QtQuick import Quickshell import qs.Commons import qs.Services.UI /** * SmartPanel for use within MainScreen */ Item { id: root // Screen property provided by MainScreen property ShellScreen screen: null // Panel content: Text, icons, etc... property Component panelContent: null // Panel size properties property real preferredWidth: 700 property real preferredHeight: 900 property real preferredWidthRatio property real preferredHeightRatio property color panelBackgroundColor: Color.mSurface property color panelBorderColor: Color.mOutline property var buttonItem: null property bool forceAttachToBar: false // Anchoring properties property bool panelAnchorHorizontalCenter: false property bool panelAnchorVerticalCenter: false property bool panelAnchorTop: false property bool panelAnchorBottom: false property bool panelAnchorLeft: false property bool panelAnchorRight: false // Button position properties property bool useButtonPosition: false property point buttonPosition: Qt.point(0, 0) property int buttonWidth: 0 property int buttonHeight: 0 // Edge snapping: if panel is within this distance (in pixels) from a screen edge, snap property real edgeSnapDistance: 50 // Edge snapping: if panel is within this distance (in pixels) from a screen edge, snap property bool exclusiveKeyboard: false // Track whether panel is open property bool isPanelOpen: false // Track actual visibility (delayed until content is loaded and sized) property bool isPanelVisible: false // Track size animation completion for sequential opacity animation property bool sizeAnimationComplete: false // Track close animation state: fade opacity first, then shrink size property bool isClosing: false property bool opacityFadeComplete: false property bool closeFinalized: false // Prevent double-finalization // Safety: Watchdog timers to prevent stuck states property bool closeWatchdogActive: false property bool openWatchdogActive: false // Keyboard event handlers - override these in specific panels to handle shortcuts // These are called from MainScreen's centralized shortcuts function onEscapePressed() { close() } function onTabPressed() {} function onShiftTabPressed() {} function onUpPressed() {} function onDownPressed() {} function onLeftPressed() {} function onRightPressed() {} function onReturnPressed() {} function onHomePressed() {} function onEndPressed() {} function onPageUpPressed() {} function onPageDownPressed() {} function onCtrlJPressed() {} function onCtrlKPressed() {} // Expose panel region for click-through mask readonly property var panelRegion: panelContent.maskRegion readonly property string barPosition: Settings.data.bar.position readonly property bool barIsVertical: barPosition === "left" || barPosition === "right" readonly property bool barFloating: Settings.data.bar.floating readonly property real barMarginH: barFloating ? Settings.data.bar.marginHorizontal * Style.marginXL : 0 readonly property real barMarginV: barFloating ? Settings.data.bar.marginVertical * Style.marginXL : 0 // Helper to detect if any anchor is explicitly set readonly property bool hasExplicitHorizontalAnchor: panelAnchorHorizontalCenter || panelAnchorLeft || panelAnchorRight readonly property bool hasExplicitVerticalAnchor: panelAnchorVerticalCenter || panelAnchorTop || panelAnchorBottom // Effective anchor properties (depend on allowAttach) // These are true when: // 1. Explicitly anchored, OR // 2. Using button position and bar is on that edge, OR // 3. Attached to bar with no explicit anchors (default centering behavior) readonly property bool effectivePanelAnchorTop: panelAnchorTop || (useButtonPosition && barPosition === "top") || (panelContent.allowAttach && !hasExplicitVerticalAnchor && barPosition === "top" && !barIsVertical) readonly property bool effectivePanelAnchorBottom: panelAnchorBottom || (useButtonPosition && barPosition === "bottom") || (panelContent.allowAttach && !hasExplicitVerticalAnchor && barPosition === "bottom" && !barIsVertical) readonly property bool effectivePanelAnchorLeft: panelAnchorLeft || (useButtonPosition && barPosition === "left") || (panelContent.allowAttach && !hasExplicitHorizontalAnchor && barPosition === "left" && barIsVertical) readonly property bool effectivePanelAnchorRight: panelAnchorRight || (useButtonPosition && barPosition === "right") || (panelContent.allowAttach && !hasExplicitHorizontalAnchor && barPosition === "right" && barIsVertical) signal opened signal closed Connections { target: Style function onUiScaleRatioChanged() { if (root.isPanelOpen && root.isPanelVisible) { root.setPosition() } } } // Panel visibility and sizing visible: isPanelVisible width: parent ? parent.width : 0 height: parent ? parent.height : 0 // Panel control functions function toggle(buttonItem, buttonName) { if (!isPanelOpen) { open(buttonItem, buttonName) } else { close() } } function open(buttonItem, buttonName) { if (!buttonItem && buttonName) { buttonItem = BarService.lookupWidget(buttonName, screen.name) } if (buttonItem) { root.buttonItem = buttonItem // Map button position to screen coordinates var buttonPos = buttonItem.mapToItem(null, 0, 0) root.buttonPosition = Qt.point(buttonPos.x, buttonPos.y) root.buttonWidth = buttonItem.width root.buttonHeight = buttonItem.height root.useButtonPosition = true } else { // No button provided: reset button position mode root.buttonItem = null root.useButtonPosition = false } // Set isPanelOpen to trigger content loading, but don't show yet isPanelOpen = true // Notify PanelService PanelService.willOpenPanel(root) // Position and visibility will be set by Loader.onLoaded // This ensures no flicker from default size to content size } function close() { // Start close sequence: fade opacity first isClosing = true sizeAnimationComplete = false closeFinalized = false // Stop the open animation timer if it's still running opacityTrigger.stop() openWatchdogActive = false openWatchdogTimer.stop() // Start close watchdog timer closeWatchdogActive = true closeWatchdogTimer.restart() // If opacity is already 0 (closed during open animation before fade-in), // skip directly to size animation if (root.opacity === 0.0) { opacityFadeComplete = true } else { opacityFadeComplete = false } // Opacity will fade out, then size will shrink, then finalizeClose() will complete Logger.d("SmartPanel", "Closing panel", objectName) } function finalizeClose() { // Prevent double-finalization if (root.closeFinalized) { Logger.w("SmartPanel", "finalizeClose called but already finalized - ignoring", objectName) return } // Complete the close sequence after animations finish root.closeFinalized = true root.closeWatchdogActive = false closeWatchdogTimer.stop() root.isPanelVisible = false root.isPanelOpen = false root.isClosing = false root.opacityFadeComplete = false PanelService.closedPanel(root) closed() Logger.d("SmartPanel", "Panel close finalized", objectName) } function setPosition() { // Don't calculate position if parent dimensions aren't available yet // This prevents centering around (0,0) when width/height are still 0 if (!root.width || !root.height) { Logger.d("SmartPanel", "Skipping setPosition - dimensions not ready:", root.width, "x", root.height) // Retry on next frame when dimensions should be available Qt.callLater(setPosition) return } // Calculate panel dimensions first (needed for positioning) var w // Priority 1: Content-driven size (dynamic) if (contentLoader.item && contentLoader.item.contentPreferredWidth !== undefined) { w = contentLoader.item.contentPreferredWidth } // Priority 2: Ratio-based size else if (root.preferredWidthRatio !== undefined) { w = Math.round(Math.max(root.width * root.preferredWidthRatio, root.preferredWidth)) } // Priority 3: Static preferred width else { w = root.preferredWidth } var panelWidth = Math.min(w, root.width - Style.marginL * 2) var h // Priority 1: Content-driven size (dynamic) if (contentLoader.item && contentLoader.item.contentPreferredHeight !== undefined) { h = contentLoader.item.contentPreferredHeight } // Priority 2: Ratio-based size else if (root.preferredHeightRatio !== undefined) { h = Math.round(Math.max(root.height * root.preferredHeightRatio, root.preferredHeight)) } // Priority 3: Static preferred height else { h = root.preferredHeight } var panelHeight = Math.min(h, root.height - Style.barHeight - Style.marginL * 2) // Update panelBackground target size (will be animated) panelBackground.targetWidth = panelWidth panelBackground.targetHeight = panelHeight // Calculate position var calculatedX var calculatedY // ===== X POSITIONING ===== if (root.useButtonPosition && root.width > 0 && panelWidth > 0) { if (root.barIsVertical) { // For vertical bars if (panelContent.allowAttach) { // Attached panels: align with bar edge (left or right side) if (root.barPosition === "left") { var leftBarEdge = root.barMarginH + Style.barHeight calculatedX = leftBarEdge } else { // right var rightBarEdge = root.width - root.barMarginH - Style.barHeight calculatedX = rightBarEdge - panelWidth } } else { // Detached panels: center on button X position var panelX = root.buttonPosition.x + root.buttonWidth / 2 - panelWidth / 2 var minX = Style.marginL var maxX = root.width - panelWidth - Style.marginL // Account for vertical bar taking up space if (root.barPosition === "left") { minX = root.barMarginH + Style.barHeight + Style.marginL } else if (root.barPosition === "right") { maxX = root.width - root.barMarginH - Style.barHeight - panelWidth - Style.marginL } panelX = Math.max(minX, Math.min(panelX, maxX)) calculatedX = panelX } } else { // For horizontal bars, center panel on button X position var panelX = root.buttonPosition.x + root.buttonWidth / 2 - panelWidth / 2 if (panelContent.allowAttach) { var cornerInset = root.barFloating ? Style.radiusL * 2 : 0 var barLeftEdge = root.barMarginH + cornerInset var barRightEdge = root.width - root.barMarginH - cornerInset panelX = Math.max(barLeftEdge, Math.min(panelX, barRightEdge - panelWidth)) } else { panelX = Math.max(Style.marginL, Math.min(panelX, root.width - panelWidth - Style.marginL)) } calculatedX = panelX } } else { // Standard anchor positioning if (root.panelAnchorHorizontalCenter) { if (root.barIsVertical) { if (root.barPosition === "left") { var availableStart = root.barMarginH + Style.barHeight var availableWidth = root.width - availableStart calculatedX = availableStart + (availableWidth - panelWidth) / 2 } else if (root.barPosition === "right") { var availableWidth = root.width - root.barMarginH - Style.barHeight calculatedX = (availableWidth - panelWidth) / 2 } else { calculatedX = (root.width - panelWidth) / 2 } } else { calculatedX = (root.width - panelWidth) / 2 } } else if (root.effectivePanelAnchorRight) { if (panelContent.allowAttach && root.barIsVertical && root.barPosition === "right") { var rightBarEdge = root.width - root.barMarginH - Style.barHeight calculatedX = rightBarEdge - panelWidth } else if (panelContent.allowAttach) { // Account for corner inset when bar is floating, horizontal, AND panel is on same edge as bar var panelOnSameEdgeAsBar = (root.barPosition === "top" && root.effectivePanelAnchorTop) || (root.barPosition === "bottom" && root.effectivePanelAnchorBottom) if (!root.barIsVertical && root.barFloating && panelOnSameEdgeAsBar) { var rightCornerInset = Style.radiusL * 2 calculatedX = root.width - root.barMarginH - rightCornerInset - panelWidth } else { calculatedX = root.width - panelWidth } } else { calculatedX = root.width - panelWidth - Style.marginL } } else if (root.effectivePanelAnchorLeft) { if (panelContent.allowAttach && root.barIsVertical && root.barPosition === "left") { var leftBarEdge = root.barMarginH + Style.barHeight calculatedX = leftBarEdge } else if (panelContent.allowAttach) { // Account for corner inset when bar is floating, horizontal, AND panel is on same edge as bar var panelOnSameEdgeAsBar = (root.barPosition === "top" && root.effectivePanelAnchorTop) || (root.barPosition === "bottom" && root.effectivePanelAnchorBottom) if (!root.barIsVertical && root.barFloating && panelOnSameEdgeAsBar) { var leftCornerInset = Style.radiusL * 2 calculatedX = root.barMarginH + leftCornerInset } else { calculatedX = 0 } } else { calculatedX = Style.marginL } } else { // No explicit anchor: default to centering on bar if (root.barIsVertical) { if (root.barPosition === "left") { var availableStart = root.barMarginH + Style.barHeight var availableWidth = root.width - availableStart - Style.marginL calculatedX = availableStart + (availableWidth - panelWidth) / 2 } else { var availableWidth = root.width - root.barMarginH - Style.barHeight - Style.marginL calculatedX = Style.marginL + (availableWidth - panelWidth) / 2 } } else { if (panelContent.allowAttach) { var cornerInset = Style.radiusL + (root.barFloating ? Style.radiusL : 0) var barLeftEdge = root.barMarginH + cornerInset var barRightEdge = root.width - root.barMarginH - cornerInset var centeredX = (root.width - panelWidth) / 2 calculatedX = Math.max(barLeftEdge, Math.min(centeredX, barRightEdge - panelWidth)) } else { calculatedX = (root.width - panelWidth) / 2 } } } } // Edge snapping for X if (panelContent.allowAttach && !root.barFloating && root.width > 0 && panelWidth > 0) { var leftEdgePos = root.barMarginH if (root.barPosition === "left") { leftEdgePos = root.barMarginH + Style.barHeight } var rightEdgePos = root.width - root.barMarginH - panelWidth if (root.barPosition === "right") { rightEdgePos = root.width - root.barMarginH - Style.barHeight - panelWidth } // Only snap to left edge if panel is actually meant to be at left (or no explicit anchor) var shouldSnapToLeft = root.effectivePanelAnchorLeft || (!root.hasExplicitHorizontalAnchor && root.barPosition === "left") // Only snap to right edge if panel is actually meant to be at right (or no explicit anchor) var shouldSnapToRight = root.effectivePanelAnchorRight || (!root.hasExplicitHorizontalAnchor && root.barPosition === "right") if (shouldSnapToLeft && Math.abs(calculatedX - leftEdgePos) <= root.edgeSnapDistance) { calculatedX = leftEdgePos } else if (shouldSnapToRight && Math.abs(calculatedX - rightEdgePos) <= root.edgeSnapDistance) { calculatedX = rightEdgePos } } // ===== Y POSITIONING ===== if (root.useButtonPosition && root.height > 0 && panelHeight > 0) { if (root.barPosition === "top") { var topBarEdge = root.barMarginV + Style.barHeight if (panelContent.allowAttach) { calculatedY = topBarEdge } else { calculatedY = topBarEdge + Style.marginM } } else if (root.barPosition === "bottom") { var bottomBarEdge = root.height - root.barMarginV - Style.barHeight if (panelContent.allowAttach) { calculatedY = bottomBarEdge - panelHeight } else { calculatedY = bottomBarEdge - panelHeight - Style.marginM } } else if (root.barIsVertical) { var panelY = root.buttonPosition.y + root.buttonHeight / 2 - panelHeight / 2 var extraPadding = (panelContent.allowAttach && root.barFloating) ? Style.radiusL : 0 if (panelContent.allowAttach) { var cornerInset = extraPadding + (root.barFloating ? Style.radiusL : 0) var barTopEdge = root.barMarginV + cornerInset var barBottomEdge = root.height - root.barMarginV - cornerInset panelY = Math.max(barTopEdge, Math.min(panelY, barBottomEdge - panelHeight)) } else { panelY = Math.max(Style.marginL + extraPadding, Math.min(panelY, root.height - panelHeight - Style.marginL - extraPadding)) } calculatedY = panelY } } else { // Standard anchor positioning var barOffset = 0 if (!panelContent.allowAttach) { if (root.barPosition === "top") { barOffset = root.barMarginV + Style.barHeight + Style.marginM } else if (root.barPosition === "bottom") { barOffset = root.barMarginV + Style.barHeight + Style.marginM } } else { if (root.effectivePanelAnchorTop && root.barPosition === "top") { calculatedY = root.barMarginV + Style.barHeight } else if (root.effectivePanelAnchorBottom && root.barPosition === "bottom") { calculatedY = root.height - root.barMarginV - Style.barHeight - panelHeight } else if (!root.hasExplicitVerticalAnchor) { if (root.barPosition === "top") { calculatedY = root.barMarginV + Style.barHeight } else if (root.barPosition === "bottom") { calculatedY = root.height - root.barMarginV - Style.barHeight - panelHeight } } } if (calculatedY === undefined) { if (root.panelAnchorVerticalCenter) { if (!root.barIsVertical) { if (root.barPosition === "top") { var availableStart = root.barMarginV + Style.barHeight var availableHeight = root.height - availableStart calculatedY = availableStart + (availableHeight - panelHeight) / 2 } else if (root.barPosition === "bottom") { var availableHeight = root.height - root.barMarginV - Style.barHeight calculatedY = (availableHeight - panelHeight) / 2 } else { calculatedY = (root.height - panelHeight) / 2 } } else { calculatedY = (root.height - panelHeight) / 2 } } else if (root.effectivePanelAnchorTop) { if (panelContent.allowAttach) { calculatedY = 0 } else { var topBarOffset = (root.barPosition === "top") ? barOffset : 0 calculatedY = topBarOffset + Style.marginL } } else if (root.effectivePanelAnchorBottom) { if (panelContent.allowAttach) { calculatedY = root.height - panelHeight } else { var bottomBarOffset = (root.barPosition === "bottom") ? barOffset : 0 calculatedY = root.height - panelHeight - bottomBarOffset - Style.marginL } } else { if (root.barIsVertical) { if (panelContent.allowAttach) { var cornerInset = root.barFloating ? Style.radiusL * 2 : 0 var barTopEdge = root.barMarginV + cornerInset var barBottomEdge = root.height - root.barMarginV - cornerInset var centeredY = (root.height - panelHeight) / 2 calculatedY = Math.max(barTopEdge, Math.min(centeredY, barBottomEdge - panelHeight)) } else { calculatedY = (root.height - panelHeight) / 2 } } else { if (panelContent.allowAttach && !root.barIsVertical) { if (root.barPosition === "top") { calculatedY = root.barMarginV + Style.barHeight } else if (root.barPosition === "bottom") { calculatedY = root.height - root.barMarginV - Style.barHeight - panelHeight } } else { if (root.barPosition === "top") { calculatedY = barOffset + Style.marginL } else if (root.barPosition === "bottom") { calculatedY = Style.marginL } else { calculatedY = Style.marginL } } } } } } // Edge snapping for Y if (panelContent.allowAttach && !root.barFloating && root.height > 0 && panelHeight > 0) { var topEdgePos = root.barMarginV if (root.barPosition === "top") { topEdgePos = root.barMarginV + Style.barHeight } var bottomEdgePos = root.height - root.barMarginV - panelHeight if (root.barPosition === "bottom") { bottomEdgePos = root.height - root.barMarginV - Style.barHeight - panelHeight } // Only snap to top edge if panel is actually meant to be at top (or no explicit anchor) var shouldSnapToTop = root.effectivePanelAnchorTop || (!root.hasExplicitVerticalAnchor && root.barPosition === "top") // Only snap to bottom edge if panel is actually meant to be at bottom (or no explicit anchor) var shouldSnapToBottom = root.effectivePanelAnchorBottom || (!root.hasExplicitVerticalAnchor && root.barPosition === "bottom") if (shouldSnapToTop && Math.abs(calculatedY - topEdgePos) <= root.edgeSnapDistance) { calculatedY = topEdgePos } else if (shouldSnapToBottom && Math.abs(calculatedY - bottomEdgePos) <= root.edgeSnapDistance) { calculatedY = bottomEdgePos } } // Apply calculated positions (set targets for animation) panelBackground.targetX = calculatedX panelBackground.targetY = calculatedY Logger.d("SmartPanel", "Position calculated:", calculatedX, calculatedY) Logger.d("SmartPanel", " Panel size:", panelWidth, "x", panelHeight) Logger.d("SmartPanel", " Parent size:", root.width, "x", root.height) } // Watch for changes in content-driven sizes and update position Connections { target: contentLoader.item ignoreUnknownSignals: true function onContentPreferredWidthChanged() { if (root.isPanelOpen && root.isPanelVisible) { root.setPosition() } } function onContentPreferredHeightChanged() { if (root.isPanelOpen && root.isPanelVisible) { root.setPosition() } } } // Opacity animation // Opening: fade in after size animation reaches 75% // Closing: fade out immediately opacity: { if (isClosing) return 0.0 // Fade out when closing if (isPanelVisible && sizeAnimationComplete) return 1.0 // Fade in when opening return 0.0 } Behavior on opacity { NumberAnimation { id: opacityAnimation duration: root.isClosing ? Style.animationFaster : Style.animationFast easing.type: Easing.OutQuad onRunningChanged: { // Safety: If animation didn't run (zero duration), handle immediately if (!running && duration === 0) { if (root.isClosing && root.opacity === 0.0) { root.opacityFadeComplete = true var shouldFinalizeNow = panelContent.maskRegion && !panelContent.maskRegion.shouldAnimateWidth && !panelContent.maskRegion.shouldAnimateHeight if (shouldFinalizeNow) { Logger.d("SmartPanel", "Zero-duration opacity + no size animation - finalizing", root.objectName) Qt.callLater(root.finalizeClose) } } else if (root.isPanelVisible && root.opacity === 1.0) { // Open completed with zero duration root.openWatchdogActive = false openWatchdogTimer.stop() } return } // When opacity fade completes during close, trigger size animation if (!running && root.isClosing && root.opacity === 0.0) { root.opacityFadeComplete = true // If no size animation will run (centered attached panels only), finalize immediately // Detached panels (allowAttach === false) should always animate from top var shouldFinalizeNow = panelContent.maskRegion && !panelContent.maskRegion.shouldAnimateWidth && !panelContent.maskRegion.shouldAnimateHeight if (shouldFinalizeNow) { Logger.d("SmartPanel", "No animation - finalizing immediately", root.objectName) Qt.callLater(root.finalizeClose) } else { Logger.d("SmartPanel", "Animation will run - waiting for size animation", root.objectName, "shouldAnimateHeight:", panelContent.maskRegion.shouldAnimateHeight, "shouldAnimateWidth:", panelContent.maskRegion.shouldAnimateWidth) } } // When opacity fade completes during open, stop watchdog else if (!running && root.isPanelVisible && root.opacity === 1.0) { root.openWatchdogActive = false openWatchdogTimer.stop() } } } } // Timer to trigger opacity fade at 50% of size animation Timer { id: opacityTrigger interval: Style.animationNormal * 0.5 repeat: false onTriggered: { if (root.isPanelVisible) { root.sizeAnimationComplete = true } } } // Watchdog timer for open sequence (safety mechanism) Timer { id: openWatchdogTimer interval: Style.animationNormal * 3 // 3x normal animation time repeat: false onTriggered: { if (root.openWatchdogActive) { Logger.w("SmartPanel", "Open watchdog timeout - forcing panel visible state", root.objectName) root.openWatchdogActive = false // Force completion of open sequence if (root.isPanelOpen && !root.isPanelVisible) { root.isPanelVisible = true root.sizeAnimationComplete = true } } } } // Watchdog timer for close sequence (safety mechanism) Timer { id: closeWatchdogTimer interval: Style.animationFast * 3 // 3x fast animation time repeat: false onTriggered: { if (root.closeWatchdogActive && !root.closeFinalized) { Logger.w("SmartPanel", "Close watchdog timeout - forcing panel close", root.objectName) // Force finalization Qt.callLater(root.finalizeClose) } } } // ------------------------------------------------ // Panel Content Item { id: panelContent anchors.fill: parent // Screen-dependent attachment properties readonly property bool allowAttach: Settings.data.ui.panelsAttachedToBar || root.forceAttachToBar readonly property bool allowAttachToBar: { if (!(Settings.data.ui.panelsAttachedToBar || root.forceAttachToBar) || Settings.data.bar.backgroundOpacity < 1.0) { return false } // A panel can only be attached to a bar if there is a bar on that screen var monitors = Settings.data.bar.monitors || [] var result = monitors.length === 0 || monitors.includes(root.screen?.name || "") return result } // Edge detection - detect if panel is touching screen edges readonly property bool touchingLeftEdge: allowAttach && panelBackground.x <= 1 readonly property bool touchingRightEdge: allowAttach && (panelBackground.x + panelBackground.width) >= (root.width - 1) readonly property bool touchingTopEdge: allowAttach && panelBackground.y <= 1 readonly property bool touchingBottomEdge: allowAttach && (panelBackground.y + panelBackground.height) >= (root.height - 1) // Bar edge detection - detect if panel is touching bar edges (for cases where centered panels snap to bar due to height constraints) readonly property bool touchingTopBar: allowAttachToBar && root.barPosition === "top" && !root.barIsVertical && Math.abs(panelBackground.y - (root.barMarginV + Style.barHeight)) <= 1 readonly property bool touchingBottomBar: allowAttachToBar && root.barPosition === "bottom" && !root.barIsVertical && Math.abs((panelBackground.y + panelBackground.height) - (root.height - root.barMarginV - Style.barHeight)) <= 1 readonly property bool touchingLeftBar: allowAttachToBar && root.barPosition === "left" && root.barIsVertical && Math.abs(panelBackground.x - (root.barMarginH + Style.barHeight)) <= 1 readonly property bool touchingRightBar: allowAttachToBar && root.barPosition === "right" && root.barIsVertical && Math.abs((panelBackground.x + panelBackground.width) - (root.width - root.barMarginH - Style.barHeight)) <= 1 // Expose panelBackground for mask region property alias maskRegion: panelBackground // The actual panel background - provides geometry for PanelBackground rendering Item { id: panelBackground // Store target dimensions (set by setPosition()) property real targetWidth: root.preferredWidth property real targetHeight: root.preferredHeight property real targetX: root.x property real targetY: root.y property var bezierCurve: [0.05, 0, 0.133, 0.06, 0.166, 0.4, 0.208, 0.82, 0.25, 1, 1, 1] // Determine which edges the panel is closest to for animation direction // Use target position (not animated position) to avoid binding loops readonly property bool willTouchTopBar: { if (!isPanelVisible) return false if (!panelContent.allowAttachToBar || root.barPosition !== "top" || root.barIsVertical) return false var targetTopBarY = root.barMarginV + Style.barHeight return Math.abs(panelBackground.targetY - targetTopBarY) <= 1 } readonly property bool willTouchBottomBar: { if (!isPanelVisible) return false if (!panelContent.allowAttachToBar || root.barPosition !== "bottom" || root.barIsVertical) return false var targetBottomBarY = root.height - root.barMarginV - Style.barHeight - panelBackground.targetHeight return Math.abs(panelBackground.targetY - targetBottomBarY) <= 1 } readonly property bool willTouchLeftBar: { if (!isPanelVisible) return false if (!panelContent.allowAttachToBar || root.barPosition !== "left" || !root.barIsVertical) return false var targetLeftBarX = root.barMarginH + Style.barHeight return Math.abs(panelBackground.targetX - targetLeftBarX) <= 1 } readonly property bool willTouchRightBar: { if (!isPanelVisible) return false if (!panelContent.allowAttachToBar || root.barPosition !== "right" || !root.barIsVertical) return false var targetRightBarX = root.width - root.barMarginH - Style.barHeight - panelBackground.targetWidth return Math.abs(panelBackground.targetX - targetRightBarX) <= 1 } readonly property bool willTouchTopEdge: isPanelVisible && panelContent.allowAttach && panelBackground.targetY <= 1 readonly property bool willTouchBottomEdge: isPanelVisible && panelContent.allowAttach && (panelBackground.targetY + panelBackground.targetHeight) >= (root.height - 1) readonly property bool willTouchLeftEdge: isPanelVisible && panelContent.allowAttach && panelBackground.targetX <= 1 readonly property bool willTouchRightEdge: isPanelVisible && panelContent.allowAttach && (panelBackground.targetX + panelBackground.targetWidth) >= (root.width - 1) readonly property bool isActuallyAttachedToAnyEdge: { if (!isPanelVisible) return false return willTouchTopBar || willTouchBottomBar || willTouchLeftBar || willTouchRightBar || willTouchTopEdge || willTouchBottomEdge || willTouchLeftEdge || willTouchRightEdge } readonly property bool animateFromTop: { // Before panel is visible, default to top animation to avoid diagonal animation on first open if (!isPanelVisible) { return true } // PRIORITY 1: Bar attachment (always takes precedence) // Attached to bar at top if (willTouchTopBar) { return true } // PRIORITY 2: Screen edge attachment (only if not touching bar) // Attached to screen top edge (not bar) if (willTouchTopEdge && !willTouchTopBar && !willTouchBottomBar && !willTouchLeftBar && !willTouchRightBar) { return true } // If panel is not attached to any edge, animate from top by default if (!isActuallyAttachedToAnyEdge) { return true } return false } readonly property bool animateFromBottom: { if (!isPanelVisible) { return false } // PRIORITY 1: Bar attachment (always takes precedence) // Attached to bar at bottom if (willTouchBottomBar) { return true } // PRIORITY 2: Screen edge attachment (only if not touching bar) // Attached to screen bottom edge (not bar) if (willTouchBottomEdge && !willTouchTopBar && !willTouchBottomBar && !willTouchLeftBar && !willTouchRightBar) { return true } return false } readonly property bool animateFromLeft: { if (!isPanelVisible) { return false } // PRIORITY 1: Bar attachment (always takes precedence) // If touching any horizontal bar, don't animate from left if (willTouchTopBar || willTouchBottomBar) { return false } // Attached to bar at left if (willTouchLeftBar) { return true } // PRIORITY 2: Screen edge attachment (only if not touching any bar) // Don't animate from left if also touching top/bottom edge (priority: vertical over horizontal) var touchingTopEdge = isPanelVisible && panelContent.allowAttach && panelBackground.targetY <= 1 var touchingBottomEdge = isPanelVisible && panelContent.allowAttach && (panelBackground.targetY + panelBackground.targetHeight) >= (root.height - 1) if (touchingTopEdge || touchingBottomEdge) { return false } // Attached to screen left edge (not bar) if (willTouchLeftEdge && !willTouchLeftBar && !willTouchTopBar && !willTouchBottomBar && !willTouchRightBar) { return true } return false } readonly property bool animateFromRight: { if (!isPanelVisible) { return false } // PRIORITY 1: Bar attachment (always takes precedence) // If touching any horizontal bar, don't animate from right if (willTouchTopBar || willTouchBottomBar) { return false } // Attached to bar at right if (willTouchRightBar) { return true } // PRIORITY 2: Screen edge attachment (only if not touching any bar) // Don't animate from right if also touching top/bottom edge (priority: vertical over horizontal) var touchingTopEdge = isPanelVisible && panelContent.allowAttach && panelBackground.targetY <= 1 var touchingBottomEdge = isPanelVisible && panelContent.allowAttach && (panelBackground.targetY + panelBackground.targetHeight) >= (root.height - 1) if (touchingTopEdge || touchingBottomEdge) { return false } // Attached to screen right edge (not bar) if (willTouchRightEdge && !willTouchLeftBar && !willTouchTopBar && !willTouchBottomBar && !willTouchRightBar) { return true } return false } // Determine animation axis based on which edge is closest // Priority: horizontal edges (top/bottom) take precedence over vertical edges (left/right) // This prevents diagonal animations when panel is attached to a corner readonly property bool shouldAnimateWidth: !shouldAnimateHeight && (animateFromLeft || animateFromRight) readonly property bool shouldAnimateHeight: animateFromTop || animateFromBottom // Current animated width/height (referenced by x/y for right/bottom positioning) readonly property real currentWidth: { // When closing and opacity fade complete, shrink width if animating horizontally if (isClosing && opacityFadeComplete && shouldAnimateWidth) return 0 // When visible or closing (before opacity fade), keep full width if (isClosing || isPanelVisible) return targetWidth // Initial state before visible: always start at 0 for width animation // (will only animate if shouldAnimateWidth becomes true when isPanelVisible changes) return 0 } readonly property real currentHeight: { // When closing and opacity fade complete, shrink height if animating vertically if (isClosing && opacityFadeComplete && shouldAnimateHeight) return 0 // When visible or closing (before opacity fade), keep full height if (isClosing || isPanelVisible) return targetHeight // Initial state before visible: always start at 0 for height animation // (will only animate if shouldAnimateHeight becomes true when isPanelVisible changes) return 0 } width: currentWidth height: currentHeight x: { // Offset x to make panel grow/shrink from the appropriate edge if (animateFromRight) { // Keep the RIGHT edge fixed at its target position if (isPanelVisible || isClosing) { var targetRightEdge = targetX + targetWidth return targetRightEdge - width } } return targetX } y: { // Offset y to make panel grow/shrink from the appropriate edge if (animateFromBottom) { // Keep the BOTTOM edge fixed at its target position if (isPanelVisible || isClosing) { var targetBottomEdge = targetY + targetHeight return targetBottomEdge - height } } return targetY } Behavior on width { NumberAnimation { id: widthAnimation duration: { if (!panelBackground.shouldAnimateWidth) return 0 return root.isClosing ? Style.animationFast : Style.animationNormal } easing.type: Easing.BezierSpline easing.bezierCurve: panelBackground.bezierCurve onRunningChanged: { // Safety: Zero-duration animation handling if (!running && duration === 0) { if (root.isClosing && panelBackground.width === 0 && panelBackground.shouldAnimateWidth) { Logger.d("SmartPanel", "Zero-duration width animation - finalizing", root.objectName) Qt.callLater(root.finalizeClose) } return } // When width shrink completes during close, finalize if (!running && root.isClosing && panelBackground.width === 0 && panelBackground.shouldAnimateWidth) { Qt.callLater(root.finalizeClose) } } } } Behavior on height { NumberAnimation { id: heightAnimation duration: { if (!panelBackground.shouldAnimateHeight) return 0 return root.isClosing ? Style.animationFast : Style.animationNormal } easing.type: Easing.BezierSpline easing.bezierCurve: panelBackground.bezierCurve onRunningChanged: { // Safety: Zero-duration animation handling if (!running && duration === 0) { if (root.isClosing && panelBackground.height === 0 && panelBackground.shouldAnimateHeight) { Logger.d("SmartPanel", "Zero-duration height animation - finalizing", root.objectName) Qt.callLater(root.finalizeClose) } return } // When height shrink completes during close, finalize if (!running && root.isClosing && panelBackground.height === 0 && panelBackground.shouldAnimateHeight) { Qt.callLater(root.finalizeClose) } } } } // Corner states for PanelBackground to read // State -1: No radius (flat/square corner) // State 0: Normal (inner curve) // State 1: Horizontal inversion (outer curve on X-axis) // State 2: Vertical inversion (outer curve on Y-axis) // Smart corner state calculation based on bar attachment and edge touching property int topLeftCornerState: { var barInverted = panelContent.allowAttachToBar && ((root.barPosition === "top" && !root.barIsVertical && root.effectivePanelAnchorTop) || (root.barPosition === "left" && root.barIsVertical && root.effectivePanelAnchorLeft)) var barTouchInverted = panelContent.touchingTopBar || panelContent.touchingLeftBar // Invert if touching either edge that forms this corner (left OR top), regardless of bar position var edgeInverted = panelContent.allowAttach && (panelContent.touchingLeftEdge || panelContent.touchingTopEdge) var oppositeEdgeInverted = panelContent.allowAttach && (panelContent.touchingTopEdge && !root.barIsVertical && root.barPosition !== "top") if (barInverted || barTouchInverted || edgeInverted || oppositeEdgeInverted) { // Determine inversion direction based on which edge is touched if (panelContent.touchingLeftEdge && panelContent.touchingTopEdge) return 0 // Both edges: no inversion (normal rounded corner) if (panelContent.touchingLeftEdge) return 2 // Left edge: vertical inversion if (panelContent.touchingTopEdge) return 1 // Top edge: horizontal inversion return root.barIsVertical ? 2 : 1 } return 0 } property int topRightCornerState: { var barInverted = panelContent.allowAttachToBar && ((root.barPosition === "top" && !root.barIsVertical && root.effectivePanelAnchorTop) || (root.barPosition === "right" && root.barIsVertical && root.effectivePanelAnchorRight)) var barTouchInverted = panelContent.touchingTopBar || panelContent.touchingRightBar // Invert if touching either edge that forms this corner (right OR top), regardless of bar position var edgeInverted = panelContent.allowAttach && (panelContent.touchingRightEdge || panelContent.touchingTopEdge) var oppositeEdgeInverted = panelContent.allowAttach && (panelContent.touchingTopEdge && !root.barIsVertical && root.barPosition !== "top") if (barInverted || barTouchInverted || edgeInverted || oppositeEdgeInverted) { // Determine inversion direction based on which edge is touched if (panelContent.touchingRightEdge && panelContent.touchingTopEdge) return 0 // Both edges: no inversion (normal rounded corner) if (panelContent.touchingRightEdge) return 2 // Right edge: vertical inversion if (panelContent.touchingTopEdge) return 1 // Top edge: horizontal inversion return root.barIsVertical ? 2 : 1 } return 0 } property int bottomLeftCornerState: { var barInverted = panelContent.allowAttachToBar && ((root.barPosition === "bottom" && !root.barIsVertical && root.effectivePanelAnchorBottom) || (root.barPosition === "left" && root.barIsVertical && root.effectivePanelAnchorLeft)) var barTouchInverted = panelContent.touchingBottomBar || panelContent.touchingLeftBar // Invert if touching either edge that forms this corner (left OR bottom), regardless of bar position var edgeInverted = panelContent.allowAttach && (panelContent.touchingLeftEdge || panelContent.touchingBottomEdge) var oppositeEdgeInverted = panelContent.allowAttach && (panelContent.touchingBottomEdge && !root.barIsVertical && root.barPosition !== "bottom") if (barInverted || barTouchInverted || edgeInverted || oppositeEdgeInverted) { // Determine inversion direction based on which edge is touched if (panelContent.touchingLeftEdge && panelContent.touchingBottomEdge) return 0 // Both edges: no inversion (normal rounded corner) if (panelContent.touchingLeftEdge) return 2 // Left edge: vertical inversion if (panelContent.touchingBottomEdge) return 1 // Bottom edge: horizontal inversion return root.barIsVertical ? 2 : 1 } return 0 } property int bottomRightCornerState: { var barInverted = panelContent.allowAttachToBar && ((root.barPosition === "bottom" && !root.barIsVertical && root.effectivePanelAnchorBottom) || (root.barPosition === "right" && root.barIsVertical && root.effectivePanelAnchorRight)) var barTouchInverted = panelContent.touchingBottomBar || panelContent.touchingRightBar // Invert if touching either edge that forms this corner (right OR bottom), regardless of bar position var edgeInverted = panelContent.allowAttach && (panelContent.touchingRightEdge || panelContent.touchingBottomEdge) var oppositeEdgeInverted = panelContent.allowAttach && (panelContent.touchingBottomEdge && !root.barIsVertical && root.barPosition !== "bottom") if (barInverted || barTouchInverted || edgeInverted || oppositeEdgeInverted) { // Determine inversion direction based on which edge is touched if (panelContent.touchingRightEdge && panelContent.touchingBottomEdge) return 0 // Both edges: no inversion (normal rounded corner) if (panelContent.touchingRightEdge) return 2 // Right edge: vertical inversion if (panelContent.touchingBottomEdge) return 1 // Bottom edge: horizontal inversion return root.barIsVertical ? 2 : 1 } return 0 } // MouseArea to catch clicks on the panel and prevent them from reaching the background // This prevents closing the panel when clicking inside it MouseArea { anchors.fill: parent acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton z: -1 // Behind content, but on the panel background onClicked: mouse => { mouse.accepted = true // Accept and ignore - prevents propagation to background } } } // Panel top content: Text, icons, etc... Loader { id: contentLoader active: isPanelOpen x: panelBackground.x y: panelBackground.y width: panelBackground.width height: panelBackground.height sourceComponent: root.panelContent // When content finishes loading, calculate position then make visible onLoaded: { // Calculate position FIRST so targetX/targetY are set before animation starts // This prevents the panel from animating from (0,0) on first open setPosition() // THEN make panel visible on the next frame to ensure all bindings have updated // Qt.callLater defers execution until all current bindings are evaluated Qt.callLater(function () { root.isPanelVisible = true opacityTrigger.start() // Start open watchdog timer root.openWatchdogActive = true openWatchdogTimer.start() opened() }) } } } }