diff --git a/Dock.qml b/Dock.qml index 79a22ff..98639ad 100644 --- a/Dock.qml +++ b/Dock.qml @@ -1,7 +1,8 @@ -// suva.dock — centered autohiding app dock with labels, drag reorder, -// multi-window management, and intelligent scale-aware autohide. +// omadock — centered autohiding app dock with drag reorder, multi-window +// management, workspace hints, and intelligent scale-aware autohide. import QtQuick +import QtQuick.Effects import Quickshell import Quickshell.Io import Quickshell.Wayland @@ -15,6 +16,58 @@ Item { // ----------------------------------------------------- inline components + // Hover bubble with a dwell delay, so sweeping the pointer across the dock + // does not flash a label for every icon it passes. + component HoverTooltip: BorderSurface { + id: bubble + + property string text: "" + property bool hovered: false + property bool blocked: false + property bool shown: false + + visible: bubble.shown && bubble.text !== "" && root.showTooltips + && !bubble.blocked && root.contextAppId === "" + z: 300 + color: Color.tooltip.background + borderSpec: Border.surfaceSpec("tooltip", "border", Color.tooltip.border, 1) + radius: Style.cornerRadius + padding: Style.space(4) + width: bubbleLabel.implicitWidth + contentLeftInset + contentRightInset + height: bubbleLabel.implicitHeight + contentTopInset + contentBottomInset + + onHoveredChanged: { + if (bubble.hovered) dwell.restart() + else { + dwell.stop() + bubble.shown = false + } + } + + onBlockedChanged: if (bubble.blocked) { + dwell.stop() + bubble.shown = false + } + + Timer { + id: dwell + interval: root.tooltipDelay + onTriggered: bubble.shown = true + } + + Text { + id: bubbleLabel + x: bubble.contentLeftInset + y: bubble.contentTopInset + width: bubble.width - bubble.contentLeftInset - bubble.contentRightInset + text: bubble.text + color: Color.tooltip.text + font.family: Style.font.family + font.pixelSize: Style.font.caption + horizontalAlignment: Text.AlignHCenter + } + } + component DockItem: Item { id: item @@ -28,77 +81,216 @@ Item { property bool pinned: false signal activateRequested(string appId) + signal newWindowRequested(string appId) signal menuRequested(string appId, real cx, real cy) signal dragStarted(string appId) signal dragMoved(string appId, real x) signal dragDropped(string appId) signal wheelScrolled(string appId, int direction) - width: root.iconSlot + // Only the wave lets a slot grow; zoom keeps the layout still and simply + // draws its icon larger. + width: root.iconSlot * (root.waveHover ? item.magnifyScale : 1) height: root.iconSlot property bool isDragging: false property bool _dragJustEnded: false property real dragStartX: 0 + property real bounceY: 0 + property real homeCenter: 0 + property real magnifyScale: { + if (root.waveHover) return root.magnifyScaleAt(item.homeCenter) + if (root.hoverEffect === "off") return 1 + return (area.containsMouse && !item.isDragging) ? root.zoomPeak : 1 + } + + Behavior on magnifyScale { + NumberAnimation { duration: 110; easing.type: Easing.OutQuad } + } + + // Live window state, read straight off the Hyprland handles carried in the + // model, so urgency and workspace moves land without a model rebuild. + + readonly property bool urgent: { + if (!root.showUrgentHint) return false + var list = item.windowList + for (var i = 0; i < list.length; i++) { + var handle = list[i] ? list[i].hypr : null + if (handle && handle.urgent) return true + } + return false + } + + readonly property bool minimized: { + var list = item.windowList + if (list.length === 0) return false + for (var i = 0; i < list.length; i++) { + var handle = list[i] ? list[i].hypr : null + var ws = handle ? handle.workspace : null + if (!ws || ws.name !== root.minimizedWorkspace) return false + } + return true + } + + readonly property bool onFocusedWorkspace: { + var list = item.windowList + for (var i = 0; i < list.length; i++) { + var handle = list[i] ? list[i].hypr : null + var ws = handle ? handle.workspace : null + if (ws && ws.id === root.focusedWorkspaceId) return true + } + return false + } + + // Where a left click would take you, when that is somewhere else. + readonly property string workspaceHint: { + if (!item.running || item.minimized || item.onFocusedWorkspace) return "" + var handle = item.windowList.length > 0 ? item.windowList[0].hypr : null + var ws = handle ? handle.workspace : null + return ws ? DockModel.workspaceShort(ws.id, ws.name) : "" + } + + readonly property bool starting: root.launchPending[item.appId] !== undefined + + // Live, unlike a flag copied into the model: only one window is focused, and + // the manager always knows which. + function windowFocused(window) { + return !!window && !!ToplevelManager.activeToplevel + && window.toplevel === ToplevelManager.activeToplevel + } + + readonly property string tooltipText: { + if (item.name === "") return "" + if (item.starting) return item.name + " [starting…]" + if (item.minimized) return item.name + " [minimized]" + if (item.workspaceHint !== "") return item.name + " [" + item.workspaceHint + "]" + return item.name + } + + // The pulse carries both attention states: urgency, and a launch in + // progress, where it breathes under the bounce. + property real pulse: 1.0 + readonly property bool pulsing: item.urgent || item.starting + onPulsingChanged: if (!item.pulsing) item.pulse = 1.0 + + SequentialAnimation on pulse { + running: item.pulsing + loops: Animation.Infinite + NumberAnimation { from: 1.0; to: 0.35; duration: 650; easing.type: Easing.InOutQuad } + NumberAnimation { from: 0.35; to: 1.0; duration: 650; easing.type: Easing.InOutQuad } + } opacity: item.isDragging ? 0.35 : 1.0 Behavior on opacity { NumberAnimation { duration: 120 } } - Rectangle { - anchors.fill: iconBg - anchors.margins: Style.space(2) - radius: (root.dockShape === "round" || root.dockShape === "pill") ? width / 2 : (root.dockShape === "square" ? 0 : 8) - color: area.containsMouse - ? (area.pressed ? Style.pressedFill : Style.hoverFill) - : (item.active ? Style.selectedFill : "transparent") - border.color: area.containsMouse ? Style.hoverBorderColor : "transparent" - border.width: Style.hoverBorderWidth + SequentialAnimation on bounceY { + running: item.starting && root.launchBounce + loops: Animation.Infinite + NumberAnimation { from: 0; to: -Style.space(13); duration: 260; easing.type: Easing.OutQuad } + NumberAnimation { from: -Style.space(13); to: 0; duration: 260; easing.type: Easing.OutBounce } + PauseAnimation { duration: 220 } + } + onStartingChanged: if (!item.starting) item.bounceY = 0 + + // The icon carries every state on its own: it grows on hover, dips on + // press, bounces while starting. No plate, no frame — the only chrome in + // the slot is the running indicator underneath. + Item { + id: iconBox + anchors.fill: parent + anchors.bottomMargin: item.running ? Style.space(5) : 0 + + scale: area.pressed ? 0.92 : 1.0 + transformOrigin: Item.Bottom + Behavior on scale { NumberAnimation { duration: 90; easing.type: Easing.OutQuad } } + + transform: Translate { + y: item.bounceY + } + // Sits on the dock floor and grows upward, so a magnified icon never + // reaches down over the running dot beneath it. Image { id: iconImg - anchors.centerIn: parent - width: root.iconSize - Style.space(10) + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: Math.round((iconBox.height - root.baseIconArt) / 2) + width: root.baseIconArt * item.magnifyScale height: width source: item.icon !== "" ? item.icon : Quickshell.iconPath("application-x-executable", true) sourceSize: Qt.size(width * Screen.devicePixelRatio, height * Screen.devicePixelRatio) visible: source !== "" + opacity: item.starting ? (0.4 + 0.6 * item.pulse) : 1.0 mipmap: true smooth: true + + // Urgency has to reach past the icon art itself, so it rings the slot. + Rectangle { + anchors.centerIn: parent + width: parent.width + Style.space(8) + height: width + radius: width / 2 + visible: item.urgent + color: "transparent" + border.width: Math.max(1, Style.space(2)) + border.color: Util.alpha(Color.urgent, 0.25 + 0.65 * item.pulse) + } } } - // Running / multi-window indicator + // How the running indicator reads: one dot per window, and the third widens + // into a pill once there are more than three, because past three nobody + // counts dots at this size — the tooltip lists them by name instead. Colour + // carries state, size carries focus, the way this dock always did it. + readonly property color indicatorColor: item.urgent + ? Color.urgent + : (item.active + ? Color.bar.active + : Util.alpha(root.dockForeground, item.minimized ? 0.3 : 0.65)) + readonly property real indicatorOpacity: item.urgent ? (0.4 + 0.6 * item.pulse) : 1.0 + readonly property real dotSize: item.active ? Style.space(5) : Style.space(4) + + // Fixed at the slot bottom, never scaled or pushed out of the dock. Row { id: indicatorRow - anchors.horizontalCenter: iconBg.horizontalCenter - anchors.top: iconBg.bottom - anchors.topMargin: Style.space(1) + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: Style.space(1) spacing: Style.space(2) visible: item.running + z: 2 Rectangle { - width: item.active ? Style.space(7) : Style.space(4) - height: item.active ? Style.space(3) : Style.space(2) + width: item.dotSize + height: item.dotSize radius: height / 2 - color: item.active ? Color.bar.active : Util.alpha(Color.bar.text, 0.6) + color: item.indicatorColor + opacity: item.indicatorOpacity + Behavior on width { NumberAnimation { duration: 120 } } } - // Secondary dot for multiple open windows Rectangle { visible: item.windows > 1 - width: Style.space(3) - height: Style.space(2) + width: item.dotSize + height: item.dotSize radius: height / 2 - color: item.active ? Color.bar.active : Util.alpha(Color.bar.text, 0.45) + color: item.indicatorColor + opacity: item.indicatorOpacity + Behavior on width { NumberAnimation { duration: 120 } } } - } - Item { - id: iconBg - width: root.iconSlot - height: root.iconSlot + // The third dot doubles as "three or more". + Rectangle { + visible: item.windows > 2 + width: item.windows > 3 ? item.dotSize * 2 : item.dotSize + height: item.dotSize + radius: height / 2 + color: item.indicatorColor + opacity: item.indicatorOpacity + Behavior on width { NumberAnimation { duration: 120 } } + } } MouseArea { @@ -106,7 +298,7 @@ Item { anchors.fill: parent hoverEnabled: true cursorShape: item.isDragging ? Qt.ClosedHandCursor : Qt.PointingHandCursor - acceptedButtons: Qt.LeftButton | Qt.RightButton + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton onWheel: function(wheel) { if (wheel.angleDelta.y !== 0) { @@ -162,6 +354,8 @@ Item { var pt = item.mapToItem(dockCard, item.width / 2, 0) var gx = dockCard.x + (pt ? pt.x : (item.x + item.width / 2)) item.menuRequested(item.appId, gx, 0) + } else if (mouse.button === Qt.MiddleButton) { + item.newWindowRequested(item.appId) } else if (mouse.button === Qt.LeftButton) { item.activateRequested(item.appId) } @@ -170,26 +364,86 @@ Item { BorderSurface { id: itemTooltip - visible: area.containsMouse && !item.isDragging && item.name !== "" && root.showTooltips + property bool shown: false + readonly property bool wanted: area.containsMouse && !item.isDragging + && item.name !== "" && root.showTooltips && root.contextAppId === "" + visible: itemTooltip.shown && itemTooltip.wanted z: 300 color: Color.tooltip.background borderSpec: Border.surfaceSpec("tooltip", "border", Color.tooltip.border, 1) - radius: Style.cornerRadius - padding: Style.space(4) + radius: Style.cornerRadius > 0 ? Style.cornerRadius : 8 + padding: Style.space(6) x: (item.width - width) / 2 - y: -height - Style.space(8) - width: tooltipLabel.implicitWidth + contentLeftInset + contentRightInset - height: tooltipLabel.implicitHeight + contentTopInset + contentBottomInset - Text { - id: tooltipLabel + y: -height - Style.space(10) + width: tooltipContent.implicitWidth + contentLeftInset + contentRightInset + height: tooltipContent.implicitHeight + contentTopInset + contentBottomInset + + onWantedChanged: { + if (itemTooltip.wanted) tooltipDwell.restart() + else { + tooltipDwell.stop() + itemTooltip.shown = false + } + } + + Timer { + id: tooltipDwell + interval: root.tooltipDelay + onTriggered: itemTooltip.shown = true + } + + Column { + id: tooltipContent x: parent.contentLeftInset y: parent.contentTopInset - width: parent.width - parent.contentLeftInset - parent.contentRightInset - text: item.name - color: Color.tooltip.text - font.family: Style.font.family - font.pixelSize: Style.font.caption - horizontalAlignment: Text.AlignHCenter + spacing: Style.space(3) + + Text { + text: item.tooltipText !== "" ? item.tooltipText : item.name + color: Color.tooltip.text + font.family: Style.font.family + font.pixelSize: Style.font.caption + font.bold: root.advancedTooltips && item.running + horizontalAlignment: Text.AlignHCenter + anchors.horizontalCenter: parent.horizontalCenter + } + + Repeater { + model: (root.advancedTooltips && item.windowList && item.windowList.length > 0) + ? Math.min(item.windowList.length, 4) : 0 + delegate: Row { + spacing: Style.space(4) + anchors.horizontalCenter: parent.horizontalCenter + Rectangle { + width: Style.space(4) + height: Style.space(4) + radius: width / 2 + anchors.verticalCenter: parent.verticalCenter + color: item.windowFocused(item.windowList[index]) ? Color.bar.active : Util.alpha(Color.tooltip.text, 0.4) + } + Text { + text: { + var w = item.windowList[index] + var t = w ? root.windowRowLabel(w) : "" + return t.length > 30 ? t.slice(0, 28) + "…" : t + } + color: item.windowFocused(item.windowList[index]) ? Color.tooltip.text : Util.alpha(Color.tooltip.text, 0.75) + font.family: Style.font.family + font.pixelSize: Math.max(10, Style.font.caption - 2) + elide: Text.ElideRight + maximumLineCount: 1 + } + } + } + + Text { + visible: root.advancedTooltips && item.windowList.length > 4 + anchors.horizontalCenter: parent.horizontalCenter + text: "+" + (item.windowList.length - 4) + " more" + color: Util.alpha(Color.tooltip.text, 0.6) + font.family: Style.font.family + font.pixelSize: Math.max(10, Style.font.caption - 2) + } } } } @@ -199,29 +453,34 @@ Item { property string glyph: "" property string tooltip: "" - property color glyphColor: Color.bar.text + property color glyphColor: root.dockForeground property real glyphSize: root.iconSize * 0.42 signal pressed() signal menuRequested(real x, real y) - width: root.iconSlot - height: root.iconSlot + property real homeCenter: 0 + property real magnifyScale: { + if (root.waveHover) return root.magnifyScaleAt(btn.homeCenter) + if (root.hoverEffect === "off") return 1 + return area.containsMouse ? root.zoomPeak : 1 + } - Rectangle { - anchors.fill: parent - anchors.margins: Style.space(2) - radius: (root.dockShape === "round" || root.dockShape === "pill") ? width / 2 : (root.dockShape === "square" ? 0 : 8) - color: area.containsMouse ? (area.pressed ? Style.pressedFill : Style.hoverFill) : "transparent" - border.color: area.containsMouse ? Style.hoverBorderColor : "transparent" - border.width: Style.hoverBorderWidth + Behavior on magnifyScale { + NumberAnimation { duration: 110; easing.type: Easing.OutQuad } + } - Text { - anchors.centerIn: parent - text: btn.glyph - font.family: "omarchy" - font.pixelSize: btn.glyphSize - color: btn.glyphColor - } + width: root.iconSlot * (root.waveHover ? btn.magnifyScale : 1) + height: root.iconSlot + + Text { + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: parent.verticalCenter + text: btn.glyph + font.family: "omarchy" + font.pixelSize: btn.glyphSize + color: btn.glyphColor + transformOrigin: Item.Bottom + scale: btn.magnifyScale * (area.pressed ? 0.92 : 1.0) } MouseArea { @@ -241,29 +500,11 @@ Item { } } - BorderSurface { - id: btnTooltip - visible: area.containsMouse && btn.tooltip !== "" && root.showTooltips && root.contextAppId === "" - z: 300 - color: Color.tooltip.background - borderSpec: Border.surfaceSpec("tooltip", "border", Color.tooltip.border, 1) - radius: Style.cornerRadius - padding: Style.space(4) + HoverTooltip { + text: btn.tooltip + hovered: area.containsMouse x: (btn.width - width) / 2 y: -height - Style.space(8) - width: btnTooltipLabel.implicitWidth + contentLeftInset + contentRightInset - height: btnTooltipLabel.implicitHeight + contentTopInset + contentBottomInset - Text { - id: btnTooltipLabel - x: parent.contentLeftInset - y: parent.contentTopInset - width: parent.width - parent.contentLeftInset - parent.contentRightInset - text: btn.tooltip - color: Color.tooltip.text - font.family: Style.font.family - font.pixelSize: Style.font.caption - horizontalAlignment: Text.AlignHCenter - } } } @@ -278,7 +519,15 @@ Item { property bool isHeader: false signal triggered() - width: Math.max(180, label.implicitWidth + (crow.glyph !== "" || crow.checked ? Style.space(26) : 0) + Style.space(20)) + // Rows ask for what they need, then all get drawn at the menu's width, so + // hover and checked fills line up down the menu instead of stepping in and + // out with the length of each label. + readonly property bool isMenuContent: true + readonly property real markWidth: Style.space(14) + + implicitWidth: Math.max(180, Style.space(8) + crow.markWidth + Style.space(6) + + label.implicitWidth + Style.space(8)) + width: contextMenu.rowWidth > 0 ? contextMenu.rowWidth : crow.implicitWidth height: crow.isHeader ? Math.max(22, Style.space(22)) : Math.max(28, Style.space(28)) Rectangle { @@ -291,6 +540,7 @@ Item { } Row { + id: content anchors.left: parent.left anchors.leftMargin: Style.space(8) anchors.right: parent.right @@ -298,11 +548,15 @@ Item { anchors.verticalCenter: parent.verticalCenter spacing: Style.space(6) - // Omarchy checkmark glyph / dot + // The mark column is always reserved, so labels stay on one left edge and + // a row keeps its width when it gets checked. Text { - visible: crow.glyph !== "" || crow.checked + id: mark + width: crow.markWidth anchors.verticalCenter: parent.verticalCenter - text: crow.glyph !== "" ? crow.glyph : (crow.checked ? "\ue92b" : "") + horizontalAlignment: Text.AlignHCenter + opacity: (crow.glyph !== "" || crow.checked) ? 1 : 0 + text: crow.glyph !== "" ? crow.glyph : "\ue92b" font.family: "omarchy" font.pixelSize: Style.font.caption color: crow.checked ? Color.bar.active : (crow.isHeader ? Util.alpha(Color.menu.text, 0.5) : crow.textColor) @@ -311,7 +565,7 @@ Item { Text { id: label anchors.verticalCenter: parent.verticalCenter - width: parent.width - (crow.glyph !== "" || crow.checked ? Style.space(18) : 0) + width: content.width - mark.width - content.spacing text: crow.text color: crow.isHeader ? Util.alpha(Color.menu.text, 0.5) @@ -357,6 +611,108 @@ Item { readonly property var appLibrary: shell ? shell.appLibrary : null + // ------------------------------------------------- magnification + + // Raised cosine falloff, the curve Juan Pablo Zamora derived for this effect: + // size = min + ((1 - cos t) / 2) * (max - min) + // over an effectWidth-wide window centred on the cursor, which is + // 0.5 * (1 + cos(pi * d / R)) for a distance d and half-range R. Flat at the + // peak and flat where the effect ends, so icons neither snap at the apex nor + // pop into motion at the edge of the range. + // + // Slots grow, and the row grows with them. That is not a stylistic choice: + // the displacement an icon needs is the accumulated growth between it and the + // cursor, which integrates to (peak - 1) * R / 2 at the range edge — around + // 30px per side here. A fixed-width card has nowhere to put that, so nudging + // icons by a hand-picked amount instead leaves holes next to the pointer and + // crowding further out. Letting the row carry the extra width is what keeps + // every gap even. + // + // Distances are measured from each slot's *unmagnified* home centre, in + // window coordinates. Nothing that magnification changes feeds back into + // those numbers, so the wave cannot chase itself. + readonly property real magnifyPeak: 1.4 + readonly property real zoomPeak: 1.22 + readonly property real magnifyRange: root.iconSlot * 2.2 + readonly property real baseIconArt: root.iconSize - Style.space(4) + + // The card's own handler, lifted into window coordinates. Both terms move + // together as the card grows, so their sum stays the physical pointer. + readonly property real pointerX: cardHover.hovered + ? dockCard.x + cardHover.point.position.x + : -1e6 + + readonly property int appsSlots: root.showAppsButton ? 1 : 0 + readonly property bool hasSeparator: root.pinnedSection.length > 0 && root.runningSection.length > 0 + readonly property real gapWidth: Style.space(root.itemSpacing) + readonly property real separatorWidth: Style.space(1) + readonly property int slotTotal: root.appsSlots + root.pinnedSection.length + root.runningSection.length + readonly property int elementTotal: root.slotTotal + (root.hasSeparator ? 1 : 0) + + readonly property real baseRowWidth: root.slotTotal * root.iconSlot + + (root.hasSeparator ? root.separatorWidth : 0) + + Math.max(0, root.elementTotal - 1) * root.gapWidth + + // Where the row would start if nothing were magnified. The card is centred, + // so this only moves when the dock's contents change. + readonly property real baseRowLeft: (dockWindow.width + - (root.baseRowWidth + dockCard.contentLeftInset + dockCard.contentRightInset)) / 2 + + dockCard.contentLeftInset + + function slotHomeCenter(elementIndex, slotsBefore, sepBefore) { + return root.baseRowLeft + + elementIndex * root.gapWidth + + slotsBefore * root.iconSlot + + (sepBefore ? root.separatorWidth : 0) + + root.iconSlot / 2 + } + + function magnifyAt(homeCenter) { + if (!root.waveHover) return 0 + var distance = root.pointerX - homeCenter + if (Math.abs(distance) >= root.magnifyRange) return 0 + return 0.5 * (1 + Math.cos(Math.PI * distance / root.magnifyRange)) + } + + function magnifyScaleAt(homeCenter) { + return 1 + (root.magnifyPeak - 1) * root.magnifyAt(homeCenter) + } + + // ------------------------------------------------- contrast + + // The bar foreground is tuned for the bar's own background. A custom dock + // colour can land on the same side of the scale — a light theme's dark text + // on a dark card, or the reverse — so flip only when the two collide. + function isLight(value) { + return (0.2126 * value.r + 0.7152 * value.g + 0.0722 * value.b) > 0.5 + } + + // Corner radius for the dock card. "rounded" tracks the card's own height, so + // the panel keeps the same visual softness at any icon size. + readonly property int effectiveCardRadius: { + var h = dockCard.height > 0 ? dockCard.height : (root.iconSlot + Style.space(10)) + if (root.dockShape === "round" || root.dockShape === "pill") return Math.round(h / 2) + if (root.dockShape === "square") return 0 + if (root.dockShape === "theme" || root.dockShape === "auto") { + var n = Style.cornerRadius + return (typeof n === "number" && isFinite(n) && n >= 0) ? n : Math.max(14, Style.space(14)) + } + return Math.max(Style.space(14), Math.min(Style.space(28), Math.round(h * 0.26))) + } + + function cardRadius(height) { + return root.effectiveCardRadius + } + + readonly property color dockForeground: { + var custom = String(root.dockBgColor || "") + if (custom.charAt(0) !== "#") return Color.bar.text + + var cardIsLight = root.isLight(Qt.color(custom)) + if (cardIsLight !== root.isLight(Color.bar.text)) return Color.bar.text + return cardIsLight ? "#12100f" : "#f2efec" + } + // ------------------------------------------------- sizing property int configuredIconSize: 0 @@ -375,14 +731,40 @@ Item { function refreshDock() { root.dockModel = root.shell && root.shell.appLibrary - ? DockModel.buildEntries(root.pinnedIds, ToplevelManager.toplevels.values, root.appRows, root.shell.appLibrary) + ? DockModel.buildEntries(root.pinnedIds, ToplevelManager.toplevels.values, root.appRows, + root.shell.appLibrary, root.hyprToplevelFor) : { pinned: [], running: [] } + root.pruneLaunching() + root.pruneWindowState() } readonly property string activeId: ToplevelManager.activeToplevel ? DockModel.normalizeId(ToplevelManager.activeToplevel.appId) : "" + readonly property int focusedWorkspaceId: Hyprland.focusedWorkspace + ? Hyprland.focusedWorkspace.id + : -99999 + + // Hyprland has no minimize, so a window is parked on its own hidden special + // workspace. The workspace name is the state, which means it survives a shell + // restart; only the origin workspace is remembered here, and losing it just + // means the window comes back to wherever you are. + readonly property string minimizedWorkspace: "special:minimized" + property var minimizedOrigins: ({}) + + // Per app: the window it parked last, and the window it was in last. Both + // hold addresses rather than live handles — a closed window then leaves a + // stale string that the next prune drops, instead of a dangling object. + // Hyprland's own focusHistoryID would save the bookkeeping, but Quickshell + // only refreshes lastIpcObject on window open/close, so it goes stale the + // moment focus moves. + property var appRecentWindow: ({}) + + // Apps whose launch has been asked for but whose window has not shown up yet. + property var launchPending: ({}) + readonly property int launchTimeout: 12000 + // ------------------------------------------------- drag reorder state property string dragAppId: "" @@ -405,10 +787,30 @@ Item { property bool intelligentAutohide: true property bool showAppsButton: true property bool showTooltips: true + // "zoom" grows only the icon under the pointer and leaves the layout alone — + // the behaviour this dock shipped with, and the default. "wave" is the + // falloff: neighbours respond and the row carries the extra width. "off" is + // no hover growth at all. + property string hoverEffect: "zoom" + readonly property bool waveHover: root.hoverEffect === "wave" + property bool launchBounce: true + property bool advancedTooltips: true property real dockOpacity: 1.0 + readonly property real effectiveDockOpacity: { + if (root.dockOpacity < 0) { + var a = (Color.bar && Color.bar.background && typeof Color.bar.background.a === "number") ? Color.bar.background.a : 1.0 + return (isFinite(a) && a >= 0) ? a : 1.0 + } + return Math.max(0.0, Math.min(1.0, root.dockOpacity)) + } property string dockShape: "rounded" property string dockBgColor: "theme" property int itemSpacing: 4 + property string minimizeMode: "off" + readonly property bool clickToMinimize: root.minimizeMode !== "off" + property bool showUrgentHint: true + property int revealDelay: 160 + property int tooltipDelay: 450 property string settingsSubmenu: "" // ------------------------------------------------- autohide state @@ -424,6 +826,28 @@ Item { onTriggered: root.dockVisible = false } + // Dwell on the screen edge before revealing, so a pointer travelling to the + // bottom of a window does not summon the dock on its way past. + Timer { + id: revealTimer + interval: root.revealDelay + onTriggered: root.dockVisible = true + } + + // Coalesces model rebuilds: several signals can describe one window change. + Timer { + id: modelTimer + interval: 40 + onTriggered: root.refreshDock() + } + + Timer { + id: launchPruneTimer + interval: 500 + repeat: true + onTriggered: root.pruneLaunching() + } + // Reactive, debounced overlap check — zero CPU polling loops Timer { id: debounceOverlapTimer @@ -513,6 +937,7 @@ Item { // Mode 1: Always Show if (!root.autohide) { hideTimer.stop() + revealTimer.stop() root.dockVisible = true return } @@ -522,10 +947,13 @@ Item { // Hovered, Context Menu Open, or Dragging: keep visible if (isHovered) { hideTimer.stop() - root.dockVisible = true + if (root.dockVisible) revealTimer.stop() + else if (!revealTimer.running) revealTimer.restart() return } + revealTimer.stop() + // Mode 3: Intelligent Autohide without window overlap -> stay visible on empty desktop if (root.intelligentAutohide && !root.windowsOverlapDock) { hideTimer.stop() @@ -579,14 +1007,31 @@ Item { Connections { target: ToplevelManager.toplevels function onValuesChanged() { - root.refreshDock() + modelTimer.restart() debounceOverlapTimer.restart() } } + // Hyprland resolves its own handle for a window slightly apart from the + // Wayland announcement; rebuilding on both is what keeps the handles attached. + Connections { + target: Hyprland.toplevels + function onValuesChanged() { modelTimer.restart() } + } + Connections { target: ToplevelManager function onActiveToplevelChanged() { + var top = ToplevelManager.activeToplevel + if (top) { + var aid = DockModel.normalizeId(top.appId) + var address = root.windowAddress(root.hyprToplevelFor(top)) + if (aid && address) { + var recent = DockModel.copyMap(root.appRecentWindow) + recent[aid] = address + root.appRecentWindow = recent + } + } debounceOverlapTimer.restart() } } @@ -603,6 +1048,7 @@ Item { n === "changefloatingmode" || n === "fullscreen" || n === "pin" || n === "focusedmon") { debounceOverlapTimer.restart() } + if (n === "openwindow" || n === "closewindow") modelTimer.restart() } } @@ -629,12 +1075,38 @@ Item { root.intelligentAutohide = parsed && parsed.intelligentAutohide !== false root.showAppsButton = parsed && parsed.showAppsButton !== false root.showTooltips = parsed && parsed.showTooltips !== false + // Migrates the old boolean: an explicit magnification:false meant no growth. + root.hoverEffect = parsed && typeof parsed.hoverEffect === "string" + ? parsed.hoverEffect + : ((parsed && parsed.magnification === false) ? "off" : "zoom") + root.launchBounce = parsed && parsed.launchBounce !== false + root.advancedTooltips = parsed && parsed.advancedTooltips !== false root.screenName = parsed && typeof parsed.screen === "string" ? parsed.screen : "" root.configuredIconSize = parsed && typeof parsed.iconSize === "number" ? parsed.iconSize : 0 - root.dockOpacity = parsed && typeof parsed.opacity === "number" ? Math.max(0.0, Math.min(1.0, parsed.opacity)) : 1.0 + if (parsed && (parsed.opacity === "theme" || parsed.opacity === "auto" || parsed.opacity === -1)) { + root.dockOpacity = -1.0 + } else if (parsed && typeof parsed.opacity === "number") { + root.dockOpacity = Math.max(0.0, Math.min(1.0, parsed.opacity)) + } else { + root.dockOpacity = 1.0 + } root.dockShape = parsed && typeof parsed.shape === "string" ? parsed.shape : "rounded" root.dockBgColor = parsed && typeof parsed.bgColor === "string" ? parsed.bgColor : "theme" root.itemSpacing = parsed && typeof parsed.itemSpacing === "number" ? parsed.itemSpacing : 4 + if (parsed && typeof parsed.minimizeMode === "string") { + root.minimizeMode = parsed.minimizeMode + } else if (parsed && parsed.clickToMinimize === true) { + root.minimizeMode = "active" + } else { + root.minimizeMode = "off" + } + root.showUrgentHint = parsed ? parsed.showUrgentHint !== false : true + root.revealDelay = parsed && typeof parsed.revealDelay === "number" + ? Math.max(0, Math.min(2000, Math.round(parsed.revealDelay))) + : 160 + root.tooltipDelay = parsed && typeof parsed.tooltipDelay === "number" + ? Math.max(0, Math.min(5000, Math.round(parsed.tooltipDelay))) + : 450 } function rescanApps() { @@ -677,6 +1149,11 @@ Item { root.saveConfig() } + function setHoverEffect(mode) { + root.hoverEffect = mode + root.saveConfig() + } + function setDockShape(shape) { root.dockShape = shape root.saveConfig() @@ -698,7 +1175,317 @@ Item { } function cycleApp(appId, direction) { - DockModel.cycleAppWindow(ToplevelManager.toplevels.values, ToplevelManager.activeToplevel, appId, direction) + var entry = root.entryForId(appId) + var next = root.stepWindow(root.visibleWindows(entry ? (entry.windowList || []) : []), direction) + if (next) { + root.focusToplevel(next.toplevel) + return + } + // No handles to tell parked from visible: fall back to the pure order. + root.focusToplevel(DockModel.pickAppWindow( + ToplevelManager.toplevels.values, ToplevelManager.activeToplevel, appId, direction)) + } + + // ------------------------------------------------- window plumbing + + function hyprToplevelFor(toplevel) { + if (!toplevel || !Hyprland.toplevels) return null + var list = Hyprland.toplevels.values + for (var i = 0; i < list.length; i++) + if (list[i] && list[i].wayland === toplevel) return list[i] + return null + } + + function windowAddress(handle) { + var value = String((handle && handle.address) || "").trim() + if (!value) return "" + if (value.slice(0, 2) === "0x" || value.slice(0, 2) === "0X") value = value.slice(2) + return "0x" + value + } + + function luaString(value) { + return String(value == null ? "" : value).replace(/\\/g, "\\\\").replace(/"/g, '\\"') + } + + // Hyprland 0.56 moved dispatchers to Lua; Quickshell reports which syntax + // the running compositor speaks. + function hyprDispatch(lua, legacy) { + Hyprland.dispatch(Hyprland.usingLua ? lua : legacy) + } + + function workspaceTarget(workspace) { + if (!workspace) return "" + var name = String(workspace.name || "") + return name !== "" ? name : String(workspace.id) + } + + // Brings a window forward for real. The Wayland activate request only hands + // over keyboard focus, which leaves scrolling layouts parked where they were, + // so the compositor's own focus dispatcher does the work whenever we know the + // window's address. It switches the workspace on its way, so nothing else has + // to ask for that. + function focusToplevel(toplevel) { + if (!toplevel) return + var handle = root.hyprToplevelFor(toplevel) + var workspace = handle ? handle.workspace : null + + if (workspace && workspace.name === root.minimizedWorkspace) { + root.restoreWindow(handle) + return + } + + var address = root.windowAddress(handle) + if (!address) { + DockModel.focusWindow(toplevel) + return + } + + root.hyprDispatch('hl.dsp.focus({ window = "address:' + address + '" })', + "focuswindow address:" + address) + } + + function minimizeToplevel(toplevel) { + var handle = root.hyprToplevelFor(toplevel) + var address = root.windowAddress(handle) + if (!address) return false + + var origin = root.workspaceTarget(handle.workspace) + if (origin === root.minimizedWorkspace) return false + + var origins = DockModel.copyMap(root.minimizedOrigins) + origins[address] = origin + root.minimizedOrigins = origins + + root.hyprDispatch( + 'hl.dsp.window.move({ window = "address:' + address + '", workspace = "' + + root.luaString(root.minimizedWorkspace) + '", follow = false })', + "movetoworkspacesilent " + root.minimizedWorkspace + ",address:" + address) + return true + } + + function restoreWindow(handle) { + var address = root.windowAddress(handle) + if (!address) return false + + var target = root.minimizedOrigins[address] || root.workspaceTarget(Hyprland.focusedWorkspace) + if (!target) return false + + var origins = DockModel.copyMap(root.minimizedOrigins) + delete origins[address] + root.minimizedOrigins = origins + + root.hyprDispatch( + 'hl.dsp.window.move({ window = "address:' + address + '", workspace = "' + + root.luaString(target) + '", follow = true })', + "movetoworkspace " + target + ",address:" + address) + root.hyprDispatch('hl.dsp.focus({ window = "address:' + address + '" })', + "focuswindow address:" + address) + return true + } + + // The window an app should act on: the one it was last focused in, as long as + // it is still around and not parked. + function windowByAddress(windows, address) { + if (!address) return null + for (var i = 0; i < windows.length; i++) { + var win = windows[i] + if (!win || !win.toplevel) continue + var handle = root.hyprToplevelFor(win.toplevel) + if (root.windowAddress(handle) !== address) continue + var ws = handle ? handle.workspace : null + return (ws && ws.name === root.minimizedWorkspace) ? null : win + } + return null + } + + // The app's windows that are still on screen, in window order. + function visibleWindows(windows) { + var out = [] + for (var i = 0; i < windows.length; i++) { + var win = windows[i] + if (!win || !win.toplevel) continue + var handle = root.hyprToplevelFor(win.toplevel) + var ws = handle ? handle.workspace : null + if (!ws || ws.name !== root.minimizedWorkspace) out.push(win) + } + return out + } + + // Which of these windows holds the focus, if any. + function focusedIndex(windows) { + if (!ToplevelManager.activeToplevel) return -1 + for (var i = 0; i < windows.length; i++) + if (windows[i] && windows[i].toplevel === ToplevelManager.activeToplevel) return i + return -1 + } + + // A window of this app on the workspace you are looking at. + function windowHere(windows) { + for (var i = 0; i < windows.length; i++) { + var handle = root.hyprToplevelFor(windows[i].toplevel) + var ws = handle ? handle.workspace : null + if (ws && ws.id === root.focusedWorkspaceId) return windows[i] + } + return null + } + + // One step around the app's windows from wherever the focus is. + function stepWindow(windows, direction) { + if (windows.length === 0) return null + if (windows.length === 1) return windows[0] + + var step = direction < 0 ? -1 : 1 + var at = root.focusedIndex(windows) + if (at < 0) return windows[step > 0 ? 0 : windows.length - 1] + return windows[(at + step + windows.length) % windows.length] + } + + // Handles of this app's parked windows, in window order. Nothing is + // remembered for this: the workspace a window sits on is the answer, so a + // shell restart cannot lose track of one. + function parkedWindows(windows) { + var out = [] + for (var i = 0; i < windows.length; i++) { + var win = windows[i] + if (!win || !win.toplevel) continue + var handle = root.hyprToplevelFor(win.toplevel) + var ws = handle ? handle.workspace : null + if (ws && ws.name === root.minimizedWorkspace) out.push(handle) + } + return out + } + + function recentWindow(appId, windows) { + return root.windowByAddress(windows, root.appRecentWindow[appId]) + } + + function minimizeAllWindows(entry) { + var windows = entry ? (entry.windowList || []) : [] + var parked = false + for (var i = 0; i < windows.length; i++) { + var win = windows[i] + if (!win || !win.toplevel) continue + var handle = root.hyprToplevelFor(win.toplevel) + var ws = handle ? handle.workspace : null + if (ws && ws.name !== root.minimizedWorkspace && root.minimizeToplevel(win.toplevel)) + parked = true + } + return parked + } + + // The one window this app should put away: the focused one, else the one it + // was last focused in, else the first that is still on screen. + function minimizeOneWindow(entry) { + var windows = entry ? (entry.windowList || []) : [] + var target = null + + for (var i = 0; i < windows.length; i++) { + if (windows[i] && windows[i].toplevel === ToplevelManager.activeToplevel) { + target = windows[i] + break + } + } + if (!target) target = root.recentWindow(entry ? entry.appId : "", windows) + if (!target) { + for (var j = 0; j < windows.length; j++) { + var handle = root.hyprToplevelFor(windows[j].toplevel) + var ws = handle ? handle.workspace : null + if (ws && ws.name !== root.minimizedWorkspace) { + target = windows[j] + break + } + } + } + + return (target && target.toplevel) ? root.minimizeToplevel(target.toplevel) : false + } + + function minimizeApp(entry) { + return root.minimizeMode === "all" + ? root.minimizeAllWindows(entry) + : root.minimizeOneWindow(entry) + } + + // Everything the dock remembers about a window is keyed by address, so one + // pass over the live windows is enough to drop what closed. + function pruneWindowState() { + var live = {} + var list = Hyprland.toplevels ? Hyprland.toplevels.values : [] + for (var i = 0; i < list.length; i++) { + var address = root.windowAddress(list[i]) + if (address) live[address] = true + } + + root.minimizedOrigins = root.keepLive(root.minimizedOrigins, live, false) + root.appRecentWindow = root.keepLive(root.appRecentWindow, live, true) + } + + // byValue: the map holds addresses as values (app -> window) rather than keys. + function keepLive(map, live, byValue) { + var keys = Object.keys(map) + if (keys.length === 0) return map + + var next = {} + var dropped = false + for (var i = 0; i < keys.length; i++) { + var key = keys[i] + if (live[byValue ? map[key] : key]) next[key] = map[key] + else dropped = true + } + return dropped ? next : map + } + + // ------------------------------------------------- launch feedback + + function launchApp(appId, entry) { + if (!root.shell || !root.shell.appLibrary) return + var target = entry || root.entryForId(appId) + var deskEntry = DockModel.entryFor(root.appRows, appId) + var targetId = (deskEntry && deskEntry.id) ? deskEntry.id : appId + var targetName = (deskEntry && deskEntry.name) ? deskEntry.name : (target && target.name ? target.name : appId) + if (deskEntry && deskEntry.id) { + root.shell.appLibrary.launch(deskEntry.id, targetName) + } else { + var webAppMatch = String(appId).match(/^(?:chrome|chromium|brave|edge|microsoft-edge)-(.*?)__?-(?:default|profile.*)$/i) + || String(appId).match(/^(?:chrome|chromium|brave|edge|microsoft-edge)-(.*?)$/i) + if (webAppMatch) { + var webDomain = webAppMatch[1].replace(/^https?___?/i, "").replace(/__.*$/, "") + Quickshell.execDetached(["omarchy-launch-webapp", "https://" + webDomain]) + } else { + root.shell.appLibrary.launch(targetId, targetName) + } + } + root.markLaunching(appId, target ? target.windows : 0) + } + + function markLaunching(appId, windowsBefore) { + var pending = DockModel.copyMap(root.launchPending) + pending[appId] = { deadline: Date.now() + root.launchTimeout, windows: windowsBefore || 0 } + root.launchPending = pending + launchPruneTimer.start() + } + + // A pending launch ends when the app gained a window, or when waiting stops + // being informative. + function pruneLaunching() { + var now = Date.now() + var next = {} + var remaining = 0 + var changed = false + + for (var appId in root.launchPending) { + var pending = root.launchPending[appId] + var entry = root.entryForId(appId) + if ((entry && entry.windows > pending.windows) || now >= pending.deadline) { + changed = true + continue + } + next[appId] = pending + remaining++ + } + + if (changed) root.launchPending = next + if (remaining === 0) launchPruneTimer.stop() } function saveConfig() { @@ -713,25 +1500,98 @@ Item { conf.intelligentAutohide = root.intelligentAutohide conf.showAppsButton = root.showAppsButton conf.showTooltips = root.showTooltips + conf.hoverEffect = root.hoverEffect + delete conf.magnification + conf.launchBounce = root.launchBounce + conf.advancedTooltips = root.advancedTooltips if (root.screenName) conf.screen = root.screenName if (root.configuredIconSize > 0) conf.iconSize = root.configuredIconSize else delete conf.iconSize - conf.opacity = root.dockOpacity + conf.opacity = root.dockOpacity < 0 ? "theme" : root.dockOpacity conf.shape = root.dockShape conf.bgColor = root.dockBgColor conf.itemSpacing = root.itemSpacing - delete conf.magnification + conf.minimizeMode = root.minimizeMode + conf.clickToMinimize = root.minimizeMode !== "off" + conf.showUrgentHint = root.showUrgentHint + conf.revealDelay = root.revealDelay + conf.tooltipDelay = root.tooltipDelay configFile.setText(JSON.stringify(conf, null, 2)) } + // ------------------------------------------------- what a click means + // + // A left click says "give me this app". Everything below is decided from live + // state only — which windows exist, which are parked, whether the focus is + // already inside the app — so there is nothing to remember and nothing to go + // stale: + // + // no windows launch it + // focus elsewhere, something parked bring the parked one back + // focus elsewhere focus it, preferring this workspace + // focus inside, mode "all" park the whole app + // focus inside, several open step to the app's next window + // focus inside, one open park it, when parking is on + // + // Two of those rules carry the weight. Preferring a window on the current + // workspace keeps a click from teleporting you while the app is already in + // front of you. Stepping through windows is what makes every click on a + // multi-window app do something visible: parking one of several hands focus + // straight to a sibling, so the app never stops being active, and both a + // park-first and a restore-first rule end up stuck — one parks forever, the + // other toggles one window forever. Stepping has no such corner, and a + // specific window can still be parked from the context menu. function activate(appId) { if (!root.shell || !root.shell.appLibrary) return + var entry = root.entryForId(appId) - if (entry && entry.running) { - DockModel.activateApp(ToplevelManager.toplevels.values, ToplevelManager.activeToplevel, appId) - } else { - root.shell.appLibrary.launch(appId, entry ? entry.name : appId) + var windows = entry ? (entry.windowList || []) : [] + if (!entry || !entry.running || windows.length === 0) { + root.launchApp(appId, entry) + return + } + + var visible = root.visibleWindows(windows) + var parked = root.parkedWindows(windows) + + if (root.focusedIndex(windows) < 0) { + if (parked.length > 0) { + // Symmetric with how they left: "all" parks in one click and restores in + // one, "active" restores newest first. + if (root.minimizeMode === "all") { + for (var i = 0; i < parked.length; i++) root.restoreWindow(parked[i]) + } else { + root.restoreWindow(parked[parked.length - 1]) + } + return + } + + var target = root.windowHere(visible) || root.recentWindow(appId, visible) || visible[0] + if (target) root.focusToplevel(target.toplevel) + return } + + if (root.minimizeMode === "all") { + root.minimizeAllWindows(entry) + return + } + + if (visible.length > 1) { + var next = root.stepWindow(visible, 1) + if (next) root.focusToplevel(next.toplevel) + return + } + + if (root.minimizeMode === "active") root.minimizeOneWindow(entry) + } + + // Menu rows name the workspace a window sits on, including the parked ones. + function windowRowLabel(window) { + var title = String((window && window.title) || "Window") + var handle = window ? window.hypr : null + var workspace = handle ? handle.workspace : null + var label = workspace ? DockModel.workspaceLabel(workspace.name, workspace.id) : "" + return label !== "" ? "[" + label + "] " + title : title } function entryForId(appId) { @@ -757,7 +1617,9 @@ Item { root.contextName = entry ? entry.name : appId root.contextWindows = entry ? entry.windows : 0 root.contextWindowList = entry && entry.windowList ? entry.windowList : [] - root.contextPinned = DockModel.isPinned(root.pinnedIds, appId) + var deskEntry = DockModel.entryFor(root.appRows, appId) + var canonicalId = (deskEntry && deskEntry.id) ? deskEntry.id : appId + root.contextPinned = DockModel.isPinned(root.pinnedIds, appId) || (canonicalId !== appId && DockModel.isPinned(root.pinnedIds, canonicalId)) root.contextX = x root.contextY = y root.contextAppId = appId @@ -767,6 +1629,23 @@ Item { root.contextAppId = "" } + // Widest piece of content in the open menu. Only implicit widths are read, so + // feeding the result back into every row cannot loop. + function menuContentWidth(item) { + var widest = 0 + if (!item) return widest + + var kids = item.children + for (var i = 0; i < kids.length; i++) { + var kid = kids[i] + if (!kid || !kid.visible) continue + if (kid.isMenuContent === true && kid.implicitWidth > widest) widest = kid.implicitWidth + var nested = root.menuContentWidth(kid) + if (nested > widest) widest = nested + } + return widest + } + // ------------------------------------------------- panel window PanelWindow { @@ -781,7 +1660,7 @@ Item { exclusiveZone: (!root.autohide) ? Math.round(dockCard.height + Style.gapsOut * 2) : 0 anchors { bottom: true; left: true; right: true } - implicitHeight: 450 + implicitHeight: 650 mask: Region { item: dockCard @@ -843,6 +1722,30 @@ Item { // ------------------------------------------------------------ dock card + Item { + id: cardShadow + visible: root.dockBgColor !== "none" && root.effectiveDockOpacity > 0.05 + // Follows the card out of view; a blur left behind would hang on screen + // after the dock has gone. + opacity: dockCard.opacity + anchors.fill: dockCard + anchors.margins: -Style.space(18) + z: 0 + layer.enabled: true + layer.effect: MultiEffect { + blurEnabled: true + blur: 1.0 + blurMax: 40 + } + + Rectangle { + anchors.fill: parent + anchors.margins: Style.space(18) + radius: dockCard.radius + color: Qt.rgba(0, 0, 0, 0.4) + } + } + BorderSurface { id: dockCard @@ -852,12 +1755,10 @@ Item { return root.dockBgColor } - color: Util.alpha(effectiveBgColor, root.dockOpacity) - borderSpec: Border.flat(Util.alpha(Color.bar.text, Math.max(0.28, root.dockOpacity * 0.4)), 1) - radius: (root.dockShape === "round" || root.dockShape === "pill") - ? Math.round(height / 2) - : (root.dockShape === "square" ? 0 : Math.max(14, Style.space(14))) - padding: Style.space(4) + color: Util.alpha(effectiveBgColor, root.effectiveDockOpacity) + borderSpec: Border.flat(Util.alpha(root.dockForeground, Math.max(0.22, root.effectiveDockOpacity * 0.32)), 1) + radius: root.cardRadius(height) + padding: Style.space(5) z: 1 HoverHandler { @@ -913,6 +1814,7 @@ Item { DockIconButton { visible: root.showAppsButton + homeCenter: root.slotHomeCenter(0, 0, false) glyph: "\ue900" tooltip: "Apps" onPressed: root.toggleAppsMenu() @@ -931,9 +1833,11 @@ Item { running: modelData.running windows: modelData.windows windowList: modelData.windowList + homeCenter: root.slotHomeCenter(root.appsSlots + index, root.appsSlots + index, false) pinned: true active: modelData.appId === root.activeId onActivateRequested: function(aid) { root.activate(aid) } + onNewWindowRequested: function(aid) { root.launchApp(aid, null) } onMenuRequested: function(aid, cx, cy) { root.openContext(aid, cx, cy) } onWheelScrolled: function(aid, dir) { root.cycleApp(aid, dir) } onDragStarted: function(aid) { @@ -986,7 +1890,7 @@ Item { anchors.verticalCenter: parent.verticalCenter width: Style.space(1) height: root.iconSize * 0.7 - color: Util.alpha(Color.bar.text, 0.25) + color: Util.alpha(root.dockForeground, 0.25) } Repeater { @@ -998,9 +1902,14 @@ Item { running: modelData.running windows: modelData.windows windowList: modelData.windowList + homeCenter: root.slotHomeCenter( + root.appsSlots + root.pinnedSection.length + (root.hasSeparator ? 1 : 0) + index, + root.appsSlots + root.pinnedSection.length + index, + root.hasSeparator) pinned: false active: modelData.appId === root.activeId onActivateRequested: function(aid) { root.activate(aid) } + onNewWindowRequested: function(aid) { root.launchApp(aid, null) } onMenuRequested: function(aid, cx, cy) { root.openContext(aid, cx, cy) } onWheelScrolled: function(aid, dir) { root.cycleApp(aid, dir) } } @@ -1031,8 +1940,12 @@ Item { radius: Style.cornerRadius padding: Style.space(4) + readonly property real rowWidth: root.contextAppId !== "" + ? root.menuContentWidth(menuColumn) + : 0 + width: root.contextAppId !== "" - ? menuColumn.implicitWidth + contentLeftInset + contentRightInset + ? rowWidth + contentLeftInset + contentRightInset : 0 height: root.contextAppId !== "" ? menuColumn.implicitHeight + contentTopInset + contentBottomInset @@ -1060,7 +1973,7 @@ Item { spacing: Style.space(1) visible: root.contextAppId === "__dock_settings__" - // 1. Main Settings Menu Page + // 1. Main Categories Page (Minimalist & Categorized) Column { spacing: Style.space(2) visible: root.settingsSubmenu === "" @@ -1071,17 +1984,49 @@ Item { } ContextRow { - text: "Autohide: " + (root.autohide ? (root.intelligentAutohide ? "Intelligent" : "Auto Hide") : "Always Show") + " ›" - onTriggered: root.settingsSubmenu = "autohide" + text: "Appearance ›" + onTriggered: root.settingsSubmenu = "appearance" + } + + ContextRow { + text: "Behavior & Windows ›" + onTriggered: root.settingsSubmenu = "behavior" + } + + ContextRow { + text: "Effects & Animations ›" + onTriggered: root.settingsSubmenu = "effects" + } + + ContextRow { + text: "Size & Spacing ›" + onTriggered: root.settingsSubmenu = "size_spacing" } + } + + // 2. Appearance Category Page + Column { + spacing: Style.space(1) + visible: root.settingsSubmenu === "appearance" ContextRow { - text: "Shape: " + (root.dockShape === "round" || root.dockShape === "pill" ? "Round" : (root.dockShape === "square" ? "Square" : "Rounded")) + " ›" + text: "‹ Back" + textColor: Color.bar.active + onTriggered: root.settingsSubmenu = "" + } + + ContextRow { + text: "Appearance" + isHeader: true + } + + ContextRow { + text: "Shape: " + (root.dockShape === "theme" || root.dockShape === "auto" ? "Auto (Theme)" : (root.dockShape === "round" || root.dockShape === "pill" ? "Round" : (root.dockShape === "square" ? "Square" : "Rounded"))) + " ›" onTriggered: root.settingsSubmenu = "shape" } ContextRow { - text: "Background: " + (root.dockOpacity >= 0.95 ? "Opaque" : (root.dockOpacity >= 0.75 ? "Glass" : (root.dockOpacity >= 0.55 ? "Frosted Glass" : (root.dockOpacity >= 0.20 ? "Translucent" : "Transparent")))) + " ›" + text: "Opacity: " + (root.dockOpacity < 0 ? "Auto (Theme)" : (root.dockOpacity >= 0.95 ? "Opaque" : (root.dockOpacity >= 0.75 ? "Glass" : (root.dockOpacity >= 0.55 ? "Frosted Glass" : (root.dockOpacity >= 0.20 ? "Translucent" : "Transparent"))))) + " ›" onTriggered: root.settingsSubmenu = "opacity" } @@ -1089,21 +2034,81 @@ Item { text: "Color: " + (root.dockBgColor === "theme" || !root.dockBgColor ? "Theme" : (root.dockBgColor === "none" ? "No Color" : "Custom")) + " ›" onTriggered: root.settingsSubmenu = "color" } + } + + // 3. Behavior & Windows Category Page + Column { + spacing: Style.space(1) + visible: root.settingsSubmenu === "behavior" ContextRow { - text: "Icon Size: " + root.iconSize + "px ›" - onTriggered: root.settingsSubmenu = "size" + text: "‹ Back" + textColor: Color.bar.active + onTriggered: root.settingsSubmenu = "" } ContextRow { - text: "Spacing: " + (root.itemSpacing <= 2 ? "Compact" : (root.itemSpacing <= 5 ? "Normal" : "Relaxed")) + " ›" - onTriggered: root.settingsSubmenu = "spacing" + text: "Behavior & Windows" + isHeader: true } - Rectangle { - width: parent.width - height: 1 - color: Util.alpha(Color.menu.border, 0.4) + ContextRow { + text: "Autohide: " + (root.autohide ? (root.intelligentAutohide ? "Intelligent" : "Auto Hide") : "Always Show") + " ›" + onTriggered: root.settingsSubmenu = "autohide" + } + + ContextRow { + text: "Minimize On Click: " + (root.minimizeMode === "all" ? "All Windows" : (root.minimizeMode === "active" ? "Active Window" : "Disabled")) + " ›" + onTriggered: root.settingsSubmenu = "minimize" + } + + ContextRow { + text: "Urgent Highlights" + checked: root.showUrgentHint + onTriggered: { + root.showUrgentHint = !root.showUrgentHint + root.saveConfig() + } + } + } + + // 4. Effects & Animations Category Page + Column { + spacing: Style.space(1) + visible: root.settingsSubmenu === "effects" + + ContextRow { + text: "‹ Back" + textColor: Color.bar.active + onTriggered: root.settingsSubmenu = "" + } + + ContextRow { + text: "Effects & Animations" + isHeader: true + } + + ContextRow { + text: "Hover: " + (root.hoverEffect === "wave" ? "Wave" : (root.hoverEffect === "off" ? "None" : "Zoom")) + " ›" + onTriggered: root.settingsSubmenu = "hover" + } + + ContextRow { + text: "Launch Bounce" + checked: root.launchBounce + onTriggered: { + root.launchBounce = !root.launchBounce + root.saveConfig() + } + } + + ContextRow { + text: "Window Previews" + checked: root.advancedTooltips + onTriggered: { + root.advancedTooltips = !root.advancedTooltips + root.saveConfig() + } } ContextRow { @@ -1116,10 +2121,45 @@ Item { } } - // 2. Autohide Submenu Page + // Hover Effect Page Column { spacing: Style.space(1) - visible: root.settingsSubmenu === "autohide" + visible: root.settingsSubmenu === "hover" + + ContextRow { + text: "‹ Back" + textColor: Color.bar.active + onTriggered: root.settingsSubmenu = "effects" + } + + ContextRow { + text: "Hover Effect" + isHeader: true + } + + ContextRow { + text: "Zoom" + checked: root.hoverEffect !== "wave" && root.hoverEffect !== "off" + onTriggered: root.setHoverEffect("zoom") + } + + ContextRow { + text: "Wave" + checked: root.hoverEffect === "wave" + onTriggered: root.setHoverEffect("wave") + } + + ContextRow { + text: "None" + checked: root.hoverEffect === "off" + onTriggered: root.setHoverEffect("off") + } + } + + // 5. Size & Spacing Category Page + Column { + spacing: Style.space(1) + visible: root.settingsSubmenu === "size_spacing" ContextRow { text: "‹ Back" @@ -1127,6 +2167,33 @@ Item { onTriggered: root.settingsSubmenu = "" } + ContextRow { + text: "Size & Spacing" + isHeader: true + } + + ContextRow { + text: "Icon Size: " + root.iconSize + "px ›" + onTriggered: root.settingsSubmenu = "size" + } + + ContextRow { + text: "Spacing: " + (root.itemSpacing <= 2 ? "Compact" : (root.itemSpacing <= 5 ? "Normal" : "Relaxed")) + " ›" + onTriggered: root.settingsSubmenu = "spacing" + } + } + + // 6. Autohide Submenu Page + Column { + spacing: Style.space(1) + visible: root.settingsSubmenu === "autohide" + + ContextRow { + text: "‹ Back" + textColor: Color.bar.active + onTriggered: root.settingsSubmenu = "behavior" + } + ContextRow { text: "Autohide Mode" isHeader: true @@ -1151,7 +2218,51 @@ Item { } } - // 3. Shape Submenu Page + // 7. Minimize Mode Submenu Page + Column { + spacing: Style.space(1) + visible: root.settingsSubmenu === "minimize" + + ContextRow { + text: "‹ Back" + textColor: Color.bar.active + onTriggered: root.settingsSubmenu = "behavior" + } + + ContextRow { + text: "Minimize On Click" + isHeader: true + } + + ContextRow { + text: "Disabled" + checked: root.minimizeMode === "off" + onTriggered: { + root.minimizeMode = "off" + root.saveConfig() + } + } + + ContextRow { + text: "Active Window (Most Recent)" + checked: root.minimizeMode === "active" + onTriggered: { + root.minimizeMode = "active" + root.saveConfig() + } + } + + ContextRow { + text: "All Windows of App" + checked: root.minimizeMode === "all" + onTriggered: { + root.minimizeMode = "all" + root.saveConfig() + } + } + } + + // 8. Shape Submenu Page Column { spacing: Style.space(1) visible: root.settingsSubmenu === "shape" @@ -1159,7 +2270,7 @@ Item { ContextRow { text: "‹ Back" textColor: Color.bar.active - onTriggered: root.settingsSubmenu = "" + onTriggered: root.settingsSubmenu = "appearance" } ContextRow { @@ -1167,6 +2278,12 @@ Item { isHeader: true } + ContextRow { + text: "Auto (Theme)" + checked: root.dockShape === "theme" || root.dockShape === "auto" + onTriggered: root.setDockShape("theme") + } + ContextRow { text: "Rounded" checked: root.dockShape === "rounded" @@ -1174,7 +2291,7 @@ Item { } ContextRow { - text: "Round" + text: "Round (Pill)" checked: root.dockShape === "round" || root.dockShape === "pill" onTriggered: root.setDockShape("round") } @@ -1186,7 +2303,7 @@ Item { } } - // 4. Background Color Submenu Page + // 9. Background Color Submenu Page Column { spacing: Style.space(1) visible: root.settingsSubmenu === "color" @@ -1194,7 +2311,7 @@ Item { ContextRow { text: "‹ Back" textColor: Color.bar.active - onTriggered: root.settingsSubmenu = "" + onTriggered: root.settingsSubmenu = "appearance" } ContextRow { @@ -1226,6 +2343,7 @@ Item { } Grid { + readonly property bool isMenuContent: true columns: 5 spacing: Style.space(3) anchors.horizontalCenter: parent.horizontalCenter @@ -1271,7 +2389,7 @@ Item { } } - // 4. Background Opacity Submenu Page + // 10. Background Opacity Submenu Page Column { spacing: Style.space(1) visible: root.settingsSubmenu === "opacity" @@ -1279,7 +2397,7 @@ Item { ContextRow { text: "‹ Back" textColor: Color.bar.active - onTriggered: root.settingsSubmenu = "" + onTriggered: root.settingsSubmenu = "appearance" } ContextRow { @@ -1287,6 +2405,12 @@ Item { isHeader: true } + ContextRow { + text: "Auto (Theme)" + checked: root.dockOpacity < 0 + onTriggered: root.setDockOpacity(-1.0) + } + ContextRow { text: "Opaque (100%)" checked: root.dockOpacity >= 0.95 @@ -1313,12 +2437,12 @@ Item { ContextRow { text: "Transparent (0%)" - checked: root.dockOpacity < 0.20 + checked: root.dockOpacity >= 0.0 && root.dockOpacity < 0.20 onTriggered: root.setDockOpacity(0.0) } } - // 5. Icon Size Submenu Page + // 11. Icon Size Submenu Page Column { spacing: Style.space(1) visible: root.settingsSubmenu === "size" @@ -1326,7 +2450,7 @@ Item { ContextRow { text: "‹ Back" textColor: Color.bar.active - onTriggered: root.settingsSubmenu = "" + onTriggered: root.settingsSubmenu = "size_spacing" } ContextRow { @@ -1359,7 +2483,7 @@ Item { } } - // 6. Icon Spacing Submenu Page + // 12. Icon Spacing Submenu Page Column { spacing: Style.space(1) visible: root.settingsSubmenu === "spacing" @@ -1367,7 +2491,7 @@ Item { ContextRow { text: "‹ Back" textColor: Color.bar.active - onTriggered: root.settingsSubmenu = "" + onTriggered: root.settingsSubmenu = "size_spacing" } ContextRow { @@ -1413,9 +2537,9 @@ Item { Repeater { model: root.contextWindowList delegate: ContextRow { - text: modelData.title || "Window" + text: root.windowRowLabel(modelData) onTriggered: { - DockModel.focusWindow(modelData.toplevel) + root.focusToplevel(modelData.toplevel) root.closeContext() } } @@ -1431,8 +2555,16 @@ Item { ContextRow { text: root.contextWindows > 0 ? "New Window" : "Launch" onTriggered: { - if (root.shell && root.shell.appLibrary) - root.shell.appLibrary.launch(root.contextAppId, root.contextName) + root.launchApp(root.contextAppId, null) + root.closeContext() + } + } + + ContextRow { + text: "Minimize Window" + visible: root.minimizeMode !== "off" && root.contextWindows > 1 + onTriggered: { + root.minimizeOneWindow(root.entryForId(root.contextAppId)) root.closeContext() } } @@ -1440,7 +2572,9 @@ Item { ContextRow { text: root.contextPinned ? "Unpin from Dock" : "Pin to Dock" onTriggered: { - root.togglePin(root.contextAppId) + var deskEntry = DockModel.entryFor(root.appRows, root.contextAppId) + var canonicalId = (deskEntry && deskEntry.id) ? deskEntry.id : root.contextAppId + root.togglePin(canonicalId) root.closeContext() } } diff --git a/DockModel.js b/DockModel.js index bb20bc3..79e1e6f 100644 --- a/DockModel.js +++ b/DockModel.js @@ -1,6 +1,14 @@ // Pure helpers for the dock plugin. No QML state — the host object owns the // model; this file only turns inputs into output arrays. +var IGNORED_TOKENS = { + "org": true, "com": true, "io": true, "net": true, "app": true, "bin": true, + "linux": true, "desktop": true, "client": true, "gui": true, "wrapper": true, + "window": true, "default": true, "profile": true, "profile_1": true, "profile_2": true, + "chrome": true, "chromium": true, "brave": true, "edge": true, "microsoft-edge": true, + "https": true, "http": true, "www": true, "x86_64": true, "x86": true, "amd64": true, "lib": true +}; + function stripDesktop(id) { var value = String(id == null ? "" : id).trim() if (value.slice(-8) === ".desktop") value = value.slice(0, -8) @@ -19,6 +27,86 @@ function toArray(list) { function normalizeId(id) { return stripDesktop(id) +}function copyMap(src) { + var out = {} + for (var key in src) out[key] = src[key] + return out +} + +// Compact workspace label for a tooltip: numbered workspaces only. Special +// workspaces have no number worth showing, so they get nothing. +function workspaceShort(wsId, wsName) { + if (wsId === null || wsId === undefined || wsId < 0) return "" + var name = String(wsName == null ? "" : wsName) + if (name && name.length <= 2) return name + return String(wsId) +} + +// Spelled-out label for menu rows: "3", "scratchpad", "minimized". +function workspaceLabel(wsName, wsId) { + var name = String(wsName == null ? "" : wsName).trim() + if (name.indexOf("special:") === 0) return name.slice(8) + if (name) return name + return (wsId === null || wsId === undefined) ? "" : String(wsId) +} + +function getCandidates(id) { + var raw = stripDesktop(id).toLowerCase() + if (!raw) return [] + var list = [raw] + + // WebApp extraction (Chrome, Chromium, Brave, Edge PWAs) + var webAppMatch = raw.match(/^(?:chrome|chromium|brave|edge|microsoft-edge)-(.*?)__?-(?:default|profile.*)$/i) + || raw.match(/^(?:chrome|chromium|brave|edge|microsoft-edge)-(.*?)$/i) + if (webAppMatch) { + var webTarget = webAppMatch[1].replace(/^https?___?/i, "").replace(/__.*$/, "") + if (webTarget && list.indexOf(webTarget) < 0) list.push(webTarget) + var webDomain = webTarget.split(/[\.\/_]+/) + for (var w = 0; w < webDomain.length; w++) { + var seg = webDomain[w] + if (seg && list.indexOf(seg) < 0) list.push(seg) + } + } + + // Split by dots, underscores, dashes, slashes + var parts = raw.split(/[\.\/_-]+/) + for (var i = 0; i < parts.length; i++) { + var p = parts[i] + if (p && list.indexOf(p) < 0) list.push(p) + } + + var len = list.length + for (var i = 0; i < len; i++) { + var item = list[i] + var stripped = item.replace(/[-_](app|bin|linux|gtk|wrapper|desktop|client|qt\d?|gui)$/i, "") + if (stripped && list.indexOf(stripped) < 0) list.push(stripped) + var prefixStripped = item.replace(/^(app|bin|linux|gtk|wrapper|desktop|client|qt\d?|gui)[-_]/i, "") + if (prefixStripped && list.indexOf(prefixStripped) < 0) list.push(prefixStripped) + } + + var out = [] + for (var i = 0; i < list.length; i++) { + var s = list[i] + if (s && !IGNORED_TOKENS[s] && out.indexOf(s) < 0) { + out.push(s) + } + } + return out +} + +function isAppMatch(idA, idB) { + if (!idA || !idB) return false + var a = stripDesktop(idA).toLowerCase() + var b = stripDesktop(idB).toLowerCase() + if (a === b) return true + + var candsA = getCandidates(a) + var candsB = getCandidates(b) + for (var i = 0; i < candsA.length; i++) { + var ca = candsA[i] + if (candsB.indexOf(ca) >= 0) return true + } + return false } function parsePinned(raw) { @@ -105,42 +193,48 @@ function entryFor(appRows, appId) { var row = appRows[i] var entry = row && row.entry if (!entry) continue - if (stripDesktop(entry.id) === want) return entry + if (stripDesktop(entry.id) === want || stripDesktop(entry.id).toLowerCase() === wantLower) return entry } - // 2. Case-insensitive ID match + // 2. Multi-token candidate match (e.g. chrome-x.com__-Default -> X.desktop, org.localsend.localsend_app -> localsend.desktop) + var wantCands = getCandidates(want) for (var i = 0; i < appRows.length; i++) { var entry = appRows[i] && appRows[i].entry if (!entry) continue - if (stripDesktop(entry.id).toLowerCase() === wantLower) return entry + var entryCands = getCandidates(entry.id) + .concat(getCandidates(entry.name)) + .concat(getCandidates(entry.icon)) + for (var k = 0; k < wantCands.length; k++) { + var cand = wantCands[k] + if (entryCands.indexOf(cand) >= 0) return entry + } } - // 3. Suffix/prefix match for reverse domain names (e.g. org.gnome.Nautilus vs nautilus) + // 3. Webapp Exec URL Match (if entry.exec contains candidate domain or URL) for (var i = 0; i < appRows.length; i++) { var entry = appRows[i] && appRows[i].entry if (!entry) continue - var eid = stripDesktop(entry.id).toLowerCase() - var lastDot = eid.lastIndexOf(".") - var shortEid = lastDot >= 0 ? eid.slice(lastDot + 1) : eid - var lastDotWant = wantLower.lastIndexOf(".") - var shortWant = lastDotWant >= 0 ? wantLower.slice(lastDotWant + 1) : wantLower - - if (shortEid === shortWant && shortWant.length > 1) return entry + var execStr = String(entry.exec || "").toLowerCase() + if (execStr) { + for (var k = 0; k < wantCands.length; k++) { + var cand = wantCands[k] + if (cand.length >= 2 && execStr.indexOf(cand) >= 0) return entry + } + } } - // 4. Name / genericName match + // 4. GenericName / Substring match for (var i = 0; i < appRows.length; i++) { var entry = appRows[i] && appRows[i].entry if (!entry) continue - var name = String(entry.name || "").toLowerCase() var generic = String(entry.genericName || "").toLowerCase() - if (name === wantLower || (generic && generic === wantLower)) return entry + if (generic && wantCands.indexOf(generic) >= 0) return entry } return null } -function buildEntries(pinnedIds, toplevels, appRows, appLibrary) { +function buildEntries(pinnedIds, toplevels, appRows, appLibrary, hyprFor) { var pinned = Array.isArray(pinnedIds) ? pinnedIds : [] var list = toArray(toplevels) @@ -158,10 +252,23 @@ function buildEntries(pinnedIds, toplevels, appRows, appLibrary) { winMap[appId].push({ title: String(toplevel.title || "Window"), toplevel: toplevel, - activated: !!toplevel.activated + // Live Hyprland handle. Urgency and workspace are read off this object + // directly so the dock follows them without rebuilding the model. + hypr: hyprFor ? hyprFor(toplevel) : null }) } + function getWindowsFor(targetId) { + if (winMap[targetId] && winMap[targetId].length > 0) return winMap[targetId] + for (var k = 0; k < runningIds.length; k++) { + var rid = runningIds[k] + if (isAppMatch(targetId, rid)) { + return winMap[rid] || [] + } + } + return [] + } + function enrich(list) { for (var j = 0; j < list.length; j++) { var item = list[j] @@ -171,7 +278,19 @@ function buildEntries(pinnedIds, toplevels, appRows, appLibrary) { item.icon = appLibrary.iconSource(entry.icon) } else { item.name = item.appId - item.icon = "" + var iconFound = "" + if (appLibrary) { + var cands = getCandidates(item.appId) + for (var k = 0; k < cands.length; k++) { + var cand = cands[k] + var testSrc = appLibrary.iconSource(cand) + if (testSrc && testSrc.indexOf("application-x-executable") < 0) { + iconFound = testSrc + break + } + } + } + item.icon = iconFound } } } @@ -184,7 +303,7 @@ function buildEntries(pinnedIds, toplevels, appRows, appLibrary) { var pid = stripDesktop(pinned[j]) if (!pid || seen[pid]) continue seen[pid] = true - var wins = winMap[pid] || [] + var wins = getWindowsFor(pid) pinnedOut.push({ appId: pid, pinned: true, @@ -198,7 +317,14 @@ function buildEntries(pinnedIds, toplevels, appRows, appLibrary) { var runningOut = [] for (j = 0; j < runningIds.length; j++) { var rid = runningIds[j] - if (seen[rid]) continue + var alreadyPinned = false + for (var p = 0; p < pinned.length; p++) { + if (isAppMatch(pinned[p], rid)) { + alreadyPinned = true + break + } + } + if (alreadyPinned || seen[rid]) continue seen[rid] = true var wins = winMap[rid] || [] runningOut.push({ @@ -222,43 +348,34 @@ function activeAppId(toplevels, activeToplevel) { return "" } -function cycleAppWindow(toplevels, activeToplevel, appId, direction) { +// Which window a click or a wheel step should land on. Pure: the caller +// decides how to bring it forward. +function pickAppWindow(toplevels, activeToplevel, appId, direction) { var want = stripDesktop(appId) - if (!want) return + if (!want) return null var list = toArray(toplevels) var matching = [] for (var i = 0; i < list.length; i++) { var t = list[i] - if (t && stripDesktop(t.appId) === want) matching.push(t) + if (t && (stripDesktop(t.appId) === want || isAppMatch(t.appId, want))) matching.push(t) } - if (matching.length === 0) return - if (matching.length === 1) { - if (matching[0].activate) matching[0].activate() - return - } + if (matching.length === 0) return null + if (matching.length === 1) return matching[0] - // Multi-window: cycle in requested direction (default forward +1, backward -1) - var dir = (typeof direction === "number" && direction !== 0) ? (direction > 0 ? 1 : -1) : 1 + var dir = (typeof direction === "number" && direction < 0) ? -1 : 1 var activeIdx = -1 - if (activeToplevel) { - for (var i = 0; i < matching.length; i++) { - if (matching[i] === activeToplevel || matching[i].activated) { - activeIdx = i - break - } + for (var j = 0; j < matching.length; j++) { + if (matching[j] === activeToplevel || matching[j].activated) { + activeIdx = j + break } } - var nextIdx = (activeIdx + dir + matching.length) % matching.length - if (matching[nextIdx] && matching[nextIdx].activate) { - matching[nextIdx].activate() - } -} - -function activateApp(toplevels, activeToplevel, appId) { - cycleAppWindow(toplevels, activeToplevel, appId, 1) + // Nothing of this app is focused: enter the list from the end we came from. + if (activeIdx < 0) return matching[dir > 0 ? 0 : matching.length - 1] + return matching[(activeIdx + dir + matching.length) % matching.length] } function focusWindow(toplevel) { @@ -277,7 +394,7 @@ function closeApp(toplevels, appId) { for (var i = 0; i < list.length; i++) { var t = list[i] if (!t) continue - if (stripDesktop(t.appId) === want) { + if (stripDesktop(t.appId) === want || isAppMatch(t.appId, want)) { if (t.close) t.close() closed += 1 } diff --git a/README.md b/README.md index 9e06774..e355bf9 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@
-
+
@@ -20,7 +20,7 @@
## Overview
-**Omadock** is a lightweight, zero-CPU application dock crafted natively for Omarchy. It brings smooth autohide behaviors, intelligent window overlap detection, multi-window cycling via mouse wheel, live drag-and-drop icon reordering, and a deep hierarchical customization suite directly accessible from the dock.
+**Omadock** is a lightweight, zero-CPU application dock crafted natively for Omarchy. It brings smooth autohide behaviors, intelligent window overlap detection, layout-aware window focus, workspace hints, urgent highlights, multi-window cycling via mouse wheel, live drag-and-drop icon reordering, and a deep hierarchical customization suite directly accessible from the dock.
@@ -68,10 +68,12 @@ Right-click the leftmost Omarchy icon to open the native settings menu:
- `Intelligent Autohide` — Stays visible on empty workspaces; hides smoothly when windows overlap.
- `Auto Hide` — Standard edge-reveal dock.
- **Dock Corner Shapes**:
- - `Rounded` — Modern 14px rounded rectangle.
+ - `Auto (Theme)` — Dynamically mirrors active Hyprland / Omarchy theme rounding (`decoration:rounding`).
+ - `Rounded` — Modern rounded rectangle with proportional scaling.
- `Round` — Full capsule / pill curvature (`height / 2`).
- `Square` — Sharp minimalist edges (`0px`).
- **Background Opacity**:
+ - `Auto (Theme)` — Automatically matches the active Omarchy theme's bar/panel opacity (`Color.bar.background.a`).
- `Opaque (100%)`
- `Glass (80%)`
- `Frosted Glass (65%)`
@@ -81,11 +83,21 @@ Right-click the leftmost Omarchy icon to open the native settings menu:
- `Theme (Default)` — Automatically tracks active Omarchy desktop theme colors (`Color.bar.background`).
- `No Color` — Clean transparent base fill.
- `Preset Palette Swatches` — 10 curated colors (Pure Black, Mocha, Deep Slate, Midnight Blue, Dark Navy, Emerald Forest, Espresso, Velvet Ruby, Midnight Purple, Slate Grey).
+ - **Automatic Foreground Contrast**: A custom dock color is measured against the theme's bar foreground, and the launcher glyph, indicators, separator, and card outline flip to the readable side when the two collide — a dark card under a light theme no longer draws dark-on-dark, and the reverse holds too.
- **Icon Sizing & Spacing**:
- Sizes: `Small (28px)`, `Medium (36px)`, `Large (44px)`, `Extra Large (52px)`.
- Spacing: `Compact (2px)`, `Normal (4px)`, `Relaxed (8px)`.
- **Toggles**:
- `Show Tooltips` — Toggle hover name tooltips.
+ - `Urgent Highlights` — Toggle the pulsing attention indicator.
+ - `Click Active to Minimize` — Toggle minimize-on-click for the focused app.
+
+### 🎯 Layout-Aware Window Focus
+- **Compositor-Native Focus**: Clicks and wheel cycling go through Hyprland's own focus dispatcher instead of the plain Wayland activate request, so **scrolling layouts scroll the target column into view** rather than switching workspace and leaving the window off-screen. Falls back to Wayland activation when no Hyprland address is available, and speaks either the Lua (Hyprland 0.56+) or legacy dispatcher syntax.
+- **Workspace Hints**: When an app's windows all live on another workspace, its tooltip names that workspace (`Slack [3]`), and the right-click window list prefixes every window with the workspace it sits on (`[3] Slack — DM`). Parked and starting windows read the same way: `Files [minimized]`, `Slack [starting…]`.
+- **Urgent Highlights**: A window demanding attention pulses its indicator dot and icon ring in the theme's urgent color until you focus it.
+- **Launch Feedback**: Clicking a cold app pulses its icon until the window shows up (or the wait stops being informative), so one click does not become three.
+- **Minimize on Click** *(optional)*: Clicking the app you are already in parks its window on a hidden `special:minimized` workspace; clicking the icon once the app no longer holds focus brings it back to where it came from. A click only parks when it cannot be misread — one window, or `minimizeMode: "all"`, which takes the whole app and restores it in one click. With several windows and no rule for which one, a click cycles instead and the right-click menu parks a specific window. Off by default.
### 🪟 Multi-Window Management & Mouse-Wheel Cycling
- **Mouse-Wheel Window Cycling**: Hover over an application with multiple open windows and scroll up or down to cycle focus between instances in real time.
@@ -114,7 +126,12 @@ All settings can be toggled interactively via the right-click menu or configured
"bgColor": "theme",
"itemSpacing": 4,
"screen": "",
- "iconSize": 36
+ "iconSize": 36,
+ "hoverEffect": "zoom",
+ "clickToMinimize": false,
+ "showUrgentHint": true,
+ "revealDelay": 160,
+ "tooltipDelay": 450
}
```
@@ -122,14 +139,19 @@ All settings can be toggled interactively via the right-click menu or configured
| :--- | :--- | :--- | :--- |
| `autohide` | `boolean` | `true` | Enable autohide on hover reveal. Set `false` for always-visible dock. |
| `intelligentAutohide` | `boolean` | `true` | Hide dock only when windows on the current workspace overlap its area. |
-| `opacity` | `number` | `1.0` | Background transparency (`1.0`, `0.80`, `0.65`, `0.35`, `0.0`). |
+| `opacity` | `number \| string` | `1.0` | Background transparency (`"theme"`, `1.0`, `0.80`, `0.65`, `0.35`, `0.0`). |
| `shape` | `string` | `"rounded"` | Corner shape style (`"rounded"`, `"round"`, or `"square"`). |
-| `bgColor` | `string` | `"theme"` | Base color (`"theme"`, `"none"`, or custom hex string e.g. `"#1e1e2e"`). |
+| `bgColor` | `string` | `"theme"` | Base color (`"theme"`, `"none"`, or custom hex string e.g. `"#1e1e2e"`). A custom hex value also switches dock glyphs and indicators to whichever side reads against it. |
| `itemSpacing` | `number` | `4` | Spacing in pixels between icons (`2`, `4`, `8`). |
| `iconSize` | `number` | `0` | Icon size in pixels (`28`, `36`, `44`, `52` or `0` for auto). |
| `showAppsButton` | `boolean` | `true` | Show or hide the Omarchy apps launcher button on the left edge. |
| `showTooltips` | `boolean` | `true` | Show app name tooltips on mouse hover. |
-| `screen` | `string` | `""` | Optional monitor name to pin the dock to (defaults to focused monitor). |
+| `screen` | `string` | `""` | Optional monitor name to pin the dock to (defaults to the first monitor). |
+| `hoverEffect` | `string` | `"zoom"` | Hover growth: `"zoom"` grows only the icon under the pointer and leaves the layout still, `"wave"` runs a raised-cosine falloff across neighbours and lets the row carry the extra width, `"off"` disables it. |
+| `clickToMinimize` | `boolean` | `false` | Clicking the focused single-window app parks it on a hidden `special:minimized` workspace; clicking again restores it. |
+| `showUrgentHint` | `boolean` | `true` | Pulse the indicator and icon ring when a window demands attention. |
+| `revealDelay` | `number` | `160` | Milliseconds the pointer must dwell on the screen edge before an autohidden dock reveals. `0` reveals immediately. |
+| `tooltipDelay` | `number` | `450` | Milliseconds of hover before a tooltip appears. `0` shows it immediately. |
### Pinned Applications (`~/.config/omarchy/dock.json`)
@@ -152,11 +174,12 @@ All settings can be toggled interactively via the right-click menu or configured
| :--- | :--- | :--- |
| **Left Click** | Omarchy Icon | Opens Omarchy Application Search Menu |
| **Right Click** | Omarchy Icon | Opens **Omadock Settings** menu |
-| **Left Click** | App Icon | Launches app or switches / cycles windows |
+| **Left Click** | App Icon | Launches app, or focuses / cycles / minimizes its windows |
+| **Middle Click** | App Icon | Opens a new window of the app |
| **Mouse Wheel** | App Icon | Cycles forward/backward between open windows |
| **Right Click** | App Icon | Shows open window list, Pin/Unpin, and Close actions |
| **Drag & Drop** | App Icon | Reorders pinned application icons |
-| **Bottom Edge Hover** | Screen Bottom | Reveals autohidden dock |
+| **Bottom Edge Hover** | Screen Bottom | Reveals autohidden dock after `revealDelay` |
---
diff --git a/manifest.json b/manifest.json
index 7ffc44b..48c4386 100644
--- a/manifest.json
+++ b/manifest.json
@@ -2,7 +2,7 @@
"schemaVersion": 1,
"id": "omadock",
"name": "omadock",
- "version": "1.4.4",
+ "version": "1.5.0",
"author": "thepathless",
"description": "Clean, modern, high-performance application dock for Omarchy with zero-CPU autohide, tiling window adaptation, and deep customization",
"kinds": [