diff --git a/Assets/Translations/de.json b/Assets/Translations/de.json index 2650f931..5decdf24 100644 --- a/Assets/Translations/de.json +++ b/Assets/Translations/de.json @@ -281,6 +281,14 @@ "floating-distance": { "label": "Dock-Schwebeabstand", "description": "Schwebeabstand vom Bildschirmrand anpassen." + }, + "position": { + "label": "Position", + "description": "Wählen Sie, an welcher Bildschirmkante das Dock angezeigt werden soll.", + "bottom": "Unten", + "top": "Oben", + "left": "Links", + "right": "Rechts" } }, "monitors": { diff --git a/Assets/Translations/en.json b/Assets/Translations/en.json index a2d86a96..c2bcef73 100644 --- a/Assets/Translations/en.json +++ b/Assets/Translations/en.json @@ -279,6 +279,14 @@ "floating-distance": { "label": "Dock floating distance", "description": "Adjust the floating distance from the screen edge." + }, + "position": { + "label": "Position", + "description": "Choose which edge of the screen to display the dock on.", + "bottom": "Bottom", + "top": "Top", + "left": "Left", + "right": "Right" } }, "monitors": { diff --git a/Assets/Translations/es.json b/Assets/Translations/es.json index b33947a9..554a7e51 100644 --- a/Assets/Translations/es.json +++ b/Assets/Translations/es.json @@ -277,6 +277,14 @@ "floating-distance": { "label": "Distancia de flotación del dock", "description": "Ajusta la distancia de flotación desde el borde de la pantalla." + }, + "position": { + "label": "Posición", + "description": "Elige en qué borde de la pantalla mostrar el dock.", + "bottom": "Inferior", + "top": "Superior", + "left": "Izquierda", + "right": "Derecha" } }, "monitors": { diff --git a/Assets/Translations/fr.json b/Assets/Translations/fr.json index a50b0c02..140bf682 100644 --- a/Assets/Translations/fr.json +++ b/Assets/Translations/fr.json @@ -277,6 +277,14 @@ "floating-distance": { "label": "Distance de flottaison du dock", "description": "Ajustez la distance de flottaison par rapport au bord de l'écran." + }, + "position": { + "label": "Position", + "description": "Choisissez sur quel bord de l'écran afficher le dock.", + "bottom": "Bas", + "top": "Haut", + "left": "Gauche", + "right": "Droite" } }, "monitors": { diff --git a/Assets/Translations/pt.json b/Assets/Translations/pt.json index eacc2ee7..ebd9704a 100644 --- a/Assets/Translations/pt.json +++ b/Assets/Translations/pt.json @@ -277,6 +277,14 @@ "floating-distance": { "label": "Distância de flutuação da dock", "description": "Ajuste a distância de flutuação da borda da tela." + }, + "position": { + "label": "Posição", + "description": "Escolha em qual borda da tela exibir a dock.", + "bottom": "Inferior", + "top": "Superior", + "left": "Esquerda", + "right": "Direita" } }, "monitors": { diff --git a/Assets/Translations/zh-CN.json b/Assets/Translations/zh-CN.json index d0514a6f..f7a37c9b 100644 --- a/Assets/Translations/zh-CN.json +++ b/Assets/Translations/zh-CN.json @@ -277,6 +277,14 @@ "floating-distance": { "label": "Dock 浮动距离", "description": "调整距离屏幕边缘的浮动距离。" + }, + "position": { + "label": "位置", + "description": "选择在屏幕的哪一边显示 Dock。", + "bottom": "底部", + "top": "顶部", + "left": "左侧", + "right": "右侧" } }, "monitors": { diff --git a/Assets/settings-default.json b/Assets/settings-default.json index 2d2a2d2d..210f2941 100644 --- a/Assets/settings-default.json +++ b/Assets/settings-default.json @@ -1,5 +1,5 @@ { - "settingsVersion": 13, + "settingsVersion": 14, "bar": { "position": "top", "backgroundOpacity": 1, @@ -118,7 +118,8 @@ "floatingRatio": 1, "onlySameOutput": true, "monitors": [], - "pinnedApps": [] + "pinnedApps": [], + "position": "bottom" }, "network": { "wifiEnabled": true diff --git a/Commons/Settings.qml b/Commons/Settings.qml index 3c12b05a..246205b6 100644 --- a/Commons/Settings.qml +++ b/Commons/Settings.qml @@ -128,7 +128,7 @@ Singleton { JsonAdapter { id: adapter - property int settingsVersion: 13 + property int settingsVersion: 14 // bar property JsonObject bar: JsonObject { @@ -252,6 +252,7 @@ Singleton { property list monitors: [] // Desktop entry IDs pinned to the dock (e.g., "org.kde.konsole", "firefox.desktop") property list pinnedApps: [] + property string position: "bottom" } // network diff --git a/Modules/Dock/Dock.qml b/Modules/Dock/Dock.qml index 53fd7142..fd633183 100644 --- a/Modules/Dock/Dock.qml +++ b/Modules/Dock/Dock.qml @@ -76,8 +76,20 @@ Variants { // Bar detection and positioning properties readonly property bool hasBar: modelData && modelData.name ? (Settings.data.bar.monitors.includes(modelData.name) || (Settings.data.bar.monitors.length === 0)) : false readonly property bool barAtBottom: hasBar && Settings.data.bar.position === "bottom" + readonly property bool barAtTop: hasBar && Settings.data.bar.position === "top" + readonly property bool barAtLeft: hasBar && Settings.data.bar.position === "left" + readonly property bool barAtRight: hasBar && Settings.data.bar.position === "right" readonly property int barHeight: Style.barHeight * scaling + // Dock positioning properties + readonly property string dockPosition: Settings.data.dock.position || "bottom" + readonly property bool dockAtBottom: dockPosition === "bottom" + readonly property bool dockAtTop: dockPosition === "top" + readonly property bool dockAtLeft: dockPosition === "left" + readonly property bool dockAtRight: dockPosition === "right" + readonly property bool dockHorizontal: dockAtLeft || dockAtRight + readonly property bool dockVertical: dockAtTop || dockAtBottom + // Shared state between windows property bool dockHovered: false property bool anyAppHovered: false @@ -101,6 +113,43 @@ Variants { } } + // Helper functions for margin calculations + function getBottomMargin() { + switch (Settings.data.bar.position) { + case "bottom": + return (Style.barHeight + Style.marginM) * scaling + (Settings.data.bar.floating ? Settings.data.bar.marginVertical * Style.marginXL * scaling + floatingMargin : floatingMargin) + default: + return floatingMargin + } + } + + function getTopMargin() { + switch (Settings.data.bar.position) { + case "top": + return (Style.barHeight + Style.marginM) * scaling + (Settings.data.bar.floating ? Settings.data.bar.marginVertical * Style.marginXL * scaling + floatingMargin : floatingMargin) + default: + return floatingMargin + } + } + + function getLeftMargin() { + switch (Settings.data.bar.position) { + case "left": + return (Style.barHeight + Style.marginM) * scaling + (Settings.data.bar.floating ? Settings.data.bar.marginHorizontal * Style.marginXL * scaling + floatingMargin : floatingMargin) + default: + return floatingMargin + } + } + + function getRightMargin() { + switch (Settings.data.bar.position) { + case "right": + return (Style.barHeight + Style.marginM) * scaling + (Settings.data.bar.floating ? Settings.data.bar.marginHorizontal * Style.marginXL * scaling + floatingMargin : floatingMargin) + default: + return floatingMargin + } + } + // Function to update the combined dock apps model function updateDockApps() { const runningApps = ToplevelManager ? (ToplevelManager.toplevels.values || []) : [] @@ -199,20 +248,22 @@ Variants { id: peekWindow screen: modelData - anchors.bottom: true - anchors.left: true - anchors.right: true + anchors.bottom: dockAtBottom + anchors.top: dockAtTop + anchors.left: dockAtLeft + anchors.right: dockAtRight focusable: false color: Color.transparent WlrLayershell.namespace: "noctalia-dock-peek" WlrLayershell.exclusionMode: ExclusionMode.Auto // Always exclusive - implicitHeight: peekHeight + implicitHeight: dockVertical ? peekHeight : undefined + implicitWidth: dockHorizontal ? peekHeight : undefined Rectangle { anchors.fill: parent - color: barAtBottom ? Qt.alpha(Color.mSurface, Settings.data.bar.backgroundOpacity) : Color.transparent + color: (barAtBottom && dockAtBottom) || (barAtTop && dockAtTop) || (barAtLeft && dockAtLeft) || (barAtRight && dockAtRight) ? Qt.alpha(Color.mSurface, Settings.data.bar.backgroundOpacity) : Color.transparent } MouseArea { @@ -256,17 +307,16 @@ Variants { implicitWidth: dockContainerWrapper.width implicitHeight: dockContainerWrapper.height - // Position above the bar if it's at bottom - anchors.bottom: true + // Position dock based on settings + anchors.bottom: dockAtBottom + anchors.top: dockAtTop + anchors.left: dockAtLeft + anchors.right: dockAtRight - margins.bottom: { - switch (Settings.data.bar.position) { - case "bottom": - return (Style.barHeight + Style.marginM) * scaling + (Settings.data.bar.floating ? Settings.data.bar.marginVertical * Style.marginXL * scaling + floatingMargin : floatingMargin) - default: - return floatingMargin - } - } + margins.bottom: dockAtBottom ? getBottomMargin() : 0 + margins.top: dockAtTop ? getTopMargin() : 0 + margins.left: dockAtLeft ? getLeftMargin() : 0 + margins.right: dockAtRight ? getRightMargin() : 0 // Rectangle { // anchors.fill: parent @@ -279,8 +329,12 @@ Variants { id: dockContainerWrapper width: dockContainer.width height: dockContainer.height - anchors.horizontalCenter: parent.horizontalCenter - anchors.bottom: parent.bottom + anchors.horizontalCenter: dockVertical ? parent.horizontalCenter : undefined + anchors.verticalCenter: dockHorizontal ? parent.verticalCenter : undefined + anchors.bottom: dockAtBottom ? parent.bottom : undefined + anchors.top: dockAtTop ? parent.top : undefined + anchors.left: dockAtLeft ? parent.left : undefined + anchors.right: dockAtRight ? parent.right : undefined // Apply animations to this wrapper opacity: hidden ? 0 : 1 @@ -303,8 +357,8 @@ Variants { Rectangle { id: dockContainer - width: dockLayout.implicitWidth + Style.marginM * scaling * 2 - height: Math.round(iconSize * 1.5) + width: dockVertical ? dockLayout.implicitWidth + Style.marginM * scaling * 2 : dockLayoutHorizontal.implicitWidth + Style.marginM * scaling * 2 + height: dockVertical ? dockLayout.implicitHeight + Style.marginM * scaling * 2 : dockLayoutHorizontal.implicitHeight + Style.marginM * scaling * 2 color: Qt.alpha(Color.mSurface, Settings.data.dock.backgroundOpacity) anchors.centerIn: parent radius: Style.radiusL * scaling @@ -340,8 +394,8 @@ Variants { Item { id: dock - width: dockLayout.implicitWidth - height: parent.height - (Style.marginM * 2 * scaling) + width: dockVertical ? dockLayout.implicitWidth : dockLayoutHorizontal.implicitWidth + height: dockVertical ? dockLayout.implicitHeight : dockLayoutHorizontal.implicitHeight anchors.centerIn: parent function getAppIcon(appData): string { @@ -353,8 +407,10 @@ Variants { RowLayout { id: dockLayout spacing: Style.marginM * scaling - Layout.preferredHeight: parent.height anchors.centerIn: parent + visible: dockVertical + Layout.fillHeight: false + Layout.fillWidth: false Repeater { model: dockApps @@ -445,6 +501,7 @@ Variants { DockMenu { id: contextMenu scaling: root.scaling + dockPosition: root.dockPosition onHoveredChanged: menuHovered = hovered onRequestClose: { contextMenu.hide() @@ -561,6 +618,221 @@ Variants { } } } + + ColumnLayout { + id: dockLayoutHorizontal + spacing: Style.marginM * scaling + anchors.centerIn: parent + visible: dockHorizontal + Layout.fillHeight: false + Layout.fillWidth: false + + Repeater { + model: dockApps + + delegate: Item { + id: appButtonHorizontal + Layout.preferredWidth: iconSize + Layout.preferredHeight: iconSize + Layout.alignment: Qt.AlignCenter + + property bool isActive: modelData.toplevel && ToplevelManager.activeToplevel && ToplevelManager.activeToplevel === modelData.toplevel + property bool hovered: appMouseAreaHorizontal.containsMouse + property string appId: modelData ? modelData.appId : "" + property string appTitle: modelData ? (modelData.title || modelData.appId) : "" + property bool isRunning: modelData && (modelData.type === "running" || modelData.type === "pinned-running") + + // Listen for the toplevel being closed + Connections { + target: modelData?.toplevel + function onClosed() { + Qt.callLater(root.updateDockApps) + } + } + + Image { + id: appIconHorizontal + width: iconSize + height: iconSize + anchors.centerIn: parent + source: dock.getAppIcon(modelData) + visible: source.toString() !== "" + sourceSize.width: iconSize * 2 + sourceSize.height: iconSize * 2 + smooth: true + mipmap: true + antialiasing: true + fillMode: Image.PreserveAspectFit + cache: true + + // Dim pinned apps that aren't running + opacity: appButtonHorizontal.isRunning ? 1.0 : 0.6 + + scale: appButtonHorizontal.hovered ? 1.15 : 1.0 + + Behavior on scale { + NumberAnimation { + duration: Style.animationNormal + easing.type: Easing.OutBack + easing.overshoot: 1.2 + } + } + + Behavior on opacity { + NumberAnimation { + duration: Style.animationFast + easing.type: Easing.OutQuad + } + } + } + + // Fall back if no icon + NIcon { + anchors.centerIn: parent + visible: !appIconHorizontal.visible + icon: "question-mark" + pointSize: iconSize * 0.7 + color: appButtonHorizontal.isActive ? Color.mPrimary : Color.mOnSurfaceVariant + opacity: appButtonHorizontal.isRunning ? 1.0 : 0.6 + scale: appButtonHorizontal.hovered ? 1.15 : 1.0 + + Behavior on scale { + NumberAnimation { + duration: Style.animationFast + easing.type: Easing.OutBack + easing.overshoot: 1.2 + } + } + + Behavior on opacity { + NumberAnimation { + duration: Style.animationFast + easing.type: Easing.OutQuad + } + } + } + + // Context menu popup + DockMenu { + id: contextMenuHorizontal + scaling: root.scaling + dockPosition: root.dockPosition + onHoveredChanged: menuHovered = hovered + onRequestClose: { + contextMenuHorizontal.hide() + // Restart hide timer after menu action if auto-hide is enabled + if (autoHide && !dockHovered && !anyAppHovered && !peekHovered) { + hideTimer.restart() + } + } + onAppClosed: root.updateDockApps // Force immediate dock update when app is closed + onVisibleChanged: { + if (visible) { + root.currentContextMenu = contextMenuHorizontal + } else if (root.currentContextMenu === contextMenuHorizontal) { + root.currentContextMenu = null + // Reset menu hover state when menu becomes invisible + menuHovered = false + // Restart hide timer if conditions are met + if (autoHide && !dockHovered && !anyAppHovered && !peekHovered) { + hideTimer.restart() + } + } + } + } + + MouseArea { + id: appMouseAreaHorizontal + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton + + onEntered: { + anyAppHovered = true + const appName = appButtonHorizontal.appTitle || appButtonHorizontal.appId || "Unknown" + const tooltipText = appName.length > 40 ? appName.substring(0, 37) + "..." : appName + TooltipService.show(Screen, appButtonHorizontal, tooltipText, "top") + if (autoHide) { + showTimer.stop() + hideTimer.stop() + unloadTimer.stop() // Cancel unload if hovering app + } + } + + onExited: { + anyAppHovered = false + TooltipService.hide() + if (autoHide && !dockHovered && !peekHovered && !menuHovered) { + hideTimer.restart() + } + } + + onClicked: function (mouse) { + if (mouse.button === Qt.RightButton) { + // If right-clicking on the same app with an open context menu, close it + if (root.currentContextMenu === contextMenuHorizontal && contextMenuHorizontal.visible) { + root.closeAllContextMenus() + return + } + // Close any other existing context menu first + root.closeAllContextMenus() + // Hide tooltip when showing context menu + TooltipService.hide() + contextMenuHorizontal.show(appButtonHorizontal, modelData.toplevel || modelData) + return + } + + // Close any existing context menu for non-right-click actions + root.closeAllContextMenus() + + // Check if toplevel is still valid (not a stale reference) + const isValidToplevel = modelData?.toplevel && ToplevelManager && ToplevelManager.toplevels.values.includes(modelData.toplevel) + + if (mouse.button === Qt.MiddleButton && isValidToplevel && modelData.toplevel.close) { + modelData.toplevel.close() + Qt.callLater(root.updateDockApps) // Force immediate dock update + } else if (mouse.button === Qt.LeftButton) { + if (isValidToplevel && modelData.toplevel.activate) { + // Running app - activate it + modelData.toplevel.activate() + } else if (modelData?.appId) { + // Pinned app not running - launch it + Quickshell.execDetached(["gtk-launch", modelData.appId]) + } + } + } + } + + // Active indicator + Rectangle { + visible: isActive + width: iconSize * 0.1 + height: iconSize * 0.2 + color: Color.mPrimary + radius: Style.radiusXS * scaling + anchors.left: parent.right + anchors.verticalCenter: parent.verticalCenter + + // Pulse animation for active indicator + SequentialAnimation on opacity { + running: isActive + loops: Animation.Infinite + NumberAnimation { + to: 0.6 + duration: Style.animationSlowest + easing.type: Easing.InOutQuad + } + NumberAnimation { + to: 1.0 + duration: Style.animationSlowest + easing.type: Easing.InOutQuad + } + } + } + } + } + } } } } diff --git a/Modules/Dock/DockMenu.qml b/Modules/Dock/DockMenu.qml index 30de31e4..77565f1c 100644 --- a/Modules/Dock/DockMenu.qml +++ b/Modules/Dock/DockMenu.qml @@ -16,6 +16,7 @@ PopupWindow { property real scaling: 1.0 property bool hovered: menuMouseArea.containsMouse property var onAppClosed: null // Callback function for when an app is closed + property string dockPosition: Settings.data.dock.position || "bottom" // Track which menu item is hovered property int hoveredItem: -1 // -1: none, 0: focus, 1: pin, 2: close @@ -55,8 +56,8 @@ PopupWindow { } anchor.item: anchorItem - anchor.rect.x: anchorItem ? (anchorItem.width - implicitWidth) / 2 : 0 - anchor.rect.y: anchorItem ? -implicitHeight - (Style.marginM * scaling) : 0 + anchor.rect.x: anchorItem ? getAnchorX() : 0 + anchor.rect.y: anchorItem ? getAnchorY() : 0 function show(item, toplevelData) { if (!item) { @@ -73,6 +74,28 @@ PopupWindow { visible = false } + function getAnchorX() { + switch (dockPosition) { + case "left": + return anchorItem.width + (Style.marginM * scaling) + case "right": + return -implicitWidth - (Style.marginM * scaling) + default: + return (anchorItem.width - implicitWidth) / 2 + } + } + + function getAnchorY() { + switch (dockPosition) { + case "top": + return anchorItem.height + (Style.marginM * scaling) + case "bottom": + return -implicitHeight - (Style.marginM * scaling) + default: + return (anchorItem.height - implicitHeight) / 2 + } + } + // Helper function to determine which menu item is under the mouse function getHoveredItem(mouseY) { const itemHeight = 32 * scaling diff --git a/Modules/Settings/Tabs/DockTab.qml b/Modules/Settings/Tabs/DockTab.qml index b13e69f5..ba1c5f2c 100644 --- a/Modules/Settings/Tabs/DockTab.qml +++ b/Modules/Settings/Tabs/DockTab.qml @@ -29,6 +29,33 @@ ColumnLayout { description: I18n.tr("settings.dock.appearance.section.description") } + ColumnLayout { + spacing: Style.marginXXS * scaling + Layout.fillWidth: true + NLabel { + label: I18n.tr("settings.dock.appearance.position.label") + description: I18n.tr("settings.dock.appearance.position.description") + } + NComboBox { + Layout.fillWidth: true + model: [{ + "key": "bottom", + "name": I18n.tr("settings.dock.appearance.position.bottom") + }, { + "key": "top", + "name": I18n.tr("settings.dock.appearance.position.top") + }, { + "key": "left", + "name": I18n.tr("settings.dock.appearance.position.left") + }, { + "key": "right", + "name": I18n.tr("settings.dock.appearance.position.right") + }] + currentKey: Settings.data.dock.position || "bottom" + onSelected: key => Settings.data.dock.position = key + } + } + NToggle { label: I18n.tr("settings.dock.appearance.auto-hide.label") description: I18n.tr("settings.dock.appearance.auto-hide.description")