From af03108af6aa5a1b7d01850e6dd0bbc7015e7312 Mon Sep 17 00:00:00 2001 From: assada Date: Thu, 20 Aug 2026 00:27:25 +0200 Subject: [PATCH 01/19] feat: layout-aware focus, workspace hints, urgent and launch feedback Clicking a dock icon went through the Wayland activate request, which only hands over keyboard focus. On Hyprland's scrolling layout that switches to the window's workspace and leaves the window itself parked off-screen, so the click reads as a no-op: with three columns on one workspace, the target sat at x=-1459 on a 1536px viewport and stayed there. Focus now goes through the compositor's own dispatcher, which moves the viewport with it (x=-1459 -> 12). The same path fixes wheel cycling, the right-click window list, and windows on special workspaces, and it degrades to the Wayland request when no Hyprland address is available. Around that: - Workspace hints. A tooltip names the workspace when the app's windows are all elsewhere (Slack [3]), and window rows in the context menu carry the same label ([3] Slack - DM). Read live off the Hyprland handles, so a window moving between workspaces needs no model rebuild. - Urgent highlights. A window asking for attention pulses its indicator and icon ring in the theme's urgent colour until it is focused. Toggle: showUrgentHint. - Launch feedback. A cold app pulses its icon until its window shows up, so one click does not become three. - Click active to minimize (opt-in, clickToMinimize). Clicking the app you are already in parks its single window on a hidden special:minimized workspace and clicking again returns it to where it came from. Multi-window apps keep cycling. The workspace name is the state, so a shell restart cannot strand a window: without a remembered origin it comes back to the current workspace. - Middle click opens a new window. - Reveal and tooltip dwell delays, so a pointer travelling to the bottom of a window no longer summons the dock and sweeping across icons no longer flashes every label. Tunable via revealDelay and tooltipDelay; set either to 0 for the old behaviour. Two rendering fixes: - Menu rows are drawn at one width. Each row used to size itself from its own label, so hover and checked fills stepped in and out down the menu (measured 232 / 225 / 273px in one menu), and a row changed width when it got checked. Rows now report an implicit width, the menu takes the widest, and every row draws at that. The mark column is always reserved, so labels share one left edge. - A custom dock colour no longer hides the dock's own chrome. bgColor 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 land on the same side of the scale. A light theme with a dark card was drawing dark on dark at roughly 1.3:1. Internals: DockModel.cycleAppWindow and activateApp are replaced by a pure pickAppWindow that only chooses a window, leaving the acting to the host. That also fixes backwards wheel cycling on an unfocused app, which used to land on the second-to-last window instead of the last. The two duplicated tooltip blocks became one HoverTooltip component. Docs: new config keys, middle click, and the screen default, which the table described as the focused monitor while the code picks the first. --- Dock.qml | 552 +++++++++++++++++++++++++++++++++++++++++++------- DockModel.js | 67 +++--- README.md | 33 ++- manifest.json | 2 +- 4 files changed, 550 insertions(+), 104 deletions(-) diff --git a/Dock.qml b/Dock.qml index 79a22ff..c135311 100644 --- a/Dock.qml +++ b/Dock.qml @@ -1,5 +1,5 @@ -// 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 Quickshell @@ -15,6 +15,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,6 +80,7 @@ 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) @@ -41,6 +94,70 @@ Item { property bool _dragJustEnded: false property real dragStartX: 0 + // 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 + + 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 + } + + // One pulse drives both attention states: urgency and a cold start. + 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 } @@ -53,7 +170,9 @@ Item { color: area.containsMouse ? (area.pressed ? Style.pressedFill : Style.hoverFill) : (item.active ? Style.selectedFill : "transparent") - border.color: area.containsMouse ? Style.hoverBorderColor : "transparent" + border.color: area.containsMouse + ? Style.hoverBorderColor + : (item.urgent ? Util.alpha(Color.urgent, 0.3 + 0.6 * item.pulse) : "transparent") border.width: Style.hoverBorderWidth Image { @@ -64,6 +183,7 @@ Item { 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 ? item.pulse : 1.0 mipmap: true smooth: true } @@ -79,10 +199,13 @@ Item { visible: item.running Rectangle { - width: item.active ? Style.space(7) : Style.space(4) - height: item.active ? Style.space(3) : Style.space(2) + width: (item.active || item.urgent) ? Style.space(7) : Style.space(4) + height: (item.active || item.urgent) ? Style.space(3) : Style.space(2) radius: height / 2 - color: item.active ? Color.bar.active : Util.alpha(Color.bar.text, 0.6) + color: item.urgent + ? Color.urgent + : (item.active ? Color.bar.active : Util.alpha(root.dockForeground, item.minimized ? 0.28 : 0.6)) + opacity: item.urgent ? (0.4 + 0.6 * item.pulse) : 1.0 } // Secondary dot for multiple open windows @@ -91,7 +214,10 @@ Item { width: Style.space(3) height: Style.space(2) radius: height / 2 - color: item.active ? Color.bar.active : Util.alpha(Color.bar.text, 0.45) + color: item.urgent + ? Color.urgent + : (item.active ? Color.bar.active : Util.alpha(root.dockForeground, item.minimized ? 0.22 : 0.45)) + opacity: item.urgent ? (0.4 + 0.6 * item.pulse) : 1.0 } } @@ -106,7 +232,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,35 +288,20 @@ 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) } } } - BorderSurface { - id: itemTooltip - visible: area.containsMouse && !item.isDragging && item.name !== "" && root.showTooltips - z: 300 - color: Color.tooltip.background - borderSpec: Border.surfaceSpec("tooltip", "border", Color.tooltip.border, 1) - radius: Style.cornerRadius - padding: Style.space(4) + HoverTooltip { + text: item.tooltipText + hovered: area.containsMouse + blocked: item.isDragging x: (item.width - width) / 2 y: -height - Style.space(8) - width: tooltipLabel.implicitWidth + contentLeftInset + contentRightInset - height: tooltipLabel.implicitHeight + contentTopInset + contentBottomInset - Text { - id: tooltipLabel - 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 - } } } @@ -199,7 +310,7 @@ 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) @@ -241,29 +352,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 +371,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 +392,7 @@ Item { } Row { + id: content anchors.left: parent.left anchors.leftMargin: Style.space(8) anchors.right: parent.right @@ -298,11 +400,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 +417,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 +463,24 @@ Item { readonly property var appLibrary: shell ? shell.appLibrary : null + // ------------------------------------------------- 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 + } + + 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 +499,32 @@ 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.pruneMinimized() } 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: ({}) + + // 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: "" @@ -409,6 +551,10 @@ Item { property string dockShape: "rounded" property string dockBgColor: "theme" property int itemSpacing: 4 + property bool clickToMinimize: false + property bool showUrgentHint: true + property int revealDelay: 160 + property int tooltipDelay: 450 property string settingsSubmenu: "" // ------------------------------------------------- autohide state @@ -424,6 +570,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 +681,7 @@ Item { // Mode 1: Always Show if (!root.autohide) { hideTimer.stop() + revealTimer.stop() root.dockVisible = true return } @@ -522,10 +691,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,11 +751,18 @@ 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() { @@ -603,6 +782,7 @@ Item { n === "changefloatingmode" || n === "fullscreen" || n === "pin" || n === "focusedmon") { debounceOverlapTimer.restart() } + if (n === "openwindow" || n === "closewindow") modelTimer.restart() } } @@ -635,6 +815,14 @@ Item { 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 + root.clickToMinimize = !!(parsed && parsed.clickToMinimize === true) + 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() { @@ -698,7 +886,165 @@ Item { } function cycleApp(appId, direction) { - DockModel.cycleAppWindow(ToplevelManager.toplevels.values, ToplevelManager.activeToplevel, appId, direction) + 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. + 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 + } + + // Drop origins for windows that are gone, so the map cannot grow forever. + function pruneMinimized() { + var origins = root.minimizedOrigins + var addresses = Object.keys(origins) + if (addresses.length === 0) return + + 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 + } + + var next = {} + var dropped = false + for (var j = 0; j < addresses.length; j++) { + if (live[addresses[j]]) next[addresses[j]] = origins[addresses[j]] + else dropped = true + } + if (dropped) root.minimizedOrigins = next + } + + // ------------------------------------------------- launch feedback + + function launchApp(appId, entry) { + if (!root.shell || !root.shell.appLibrary) return + var target = entry || root.entryForId(appId) + root.shell.appLibrary.launch(appId, target && target.name ? target.name : appId) + 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() { @@ -720,18 +1066,41 @@ Item { conf.shape = root.dockShape conf.bgColor = root.dockBgColor conf.itemSpacing = root.itemSpacing + conf.clickToMinimize = root.clickToMinimize + conf.showUrgentHint = root.showUrgentHint + conf.revealDelay = root.revealDelay + conf.tooltipDelay = root.tooltipDelay delete conf.magnification configFile.setText(JSON.stringify(conf, null, 2)) } 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) + if (!entry || !entry.running) { + root.launchApp(appId, entry) + return } + + // Clicking the app you are already in is otherwise a dead click. With one + // window there is no ambiguity about what to put away; with several, + // cycling stays the more useful answer. + var windows = entry.windowList || [] + if (root.clickToMinimize && appId === root.activeId && windows.length === 1 + && root.minimizeToplevel(windows[0].toplevel)) return + + root.focusToplevel(DockModel.pickAppWindow( + ToplevelManager.toplevels.values, ToplevelManager.activeToplevel, appId, 1)) + } + + // 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) { @@ -767,6 +1136,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 { @@ -853,7 +1239,7 @@ Item { } color: Util.alpha(effectiveBgColor, root.dockOpacity) - borderSpec: Border.flat(Util.alpha(Color.bar.text, Math.max(0.28, root.dockOpacity * 0.4)), 1) + borderSpec: Border.flat(Util.alpha(root.dockForeground, 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))) @@ -934,6 +1320,7 @@ Item { 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 +1373,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 { @@ -1001,6 +1388,7 @@ Item { 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 +1419,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 @@ -1114,6 +1506,24 @@ Item { root.saveConfig() } } + + ContextRow { + text: "Urgent Highlights" + checked: root.showUrgentHint + onTriggered: { + root.showUrgentHint = !root.showUrgentHint + root.saveConfig() + } + } + + ContextRow { + text: "Click Active to Minimize" + checked: root.clickToMinimize + onTriggered: { + root.clickToMinimize = !root.clickToMinimize + root.saveConfig() + } + } } // 2. Autohide Submenu Page @@ -1226,6 +1636,7 @@ Item { } Grid { + readonly property bool isMenuContent: true columns: 5 spacing: Style.space(3) anchors.horizontalCenter: parent.horizontalCenter @@ -1413,9 +1824,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 +1842,7 @@ 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() } } diff --git a/DockModel.js b/DockModel.js index bb20bc3..59e652f 100644 --- a/DockModel.js +++ b/DockModel.js @@ -21,6 +21,29 @@ 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 parsePinned(raw) { var text = String(raw == null ? "" : raw).trim() if (!text) return [] @@ -140,7 +163,7 @@ function entryFor(appRows, appId) { 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,7 +181,10 @@ function buildEntries(pinnedIds, toplevels, appRows, appLibrary) { winMap[appId].push({ title: String(toplevel.title || "Window"), toplevel: toplevel, - activated: !!toplevel.activated + 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 }) } @@ -222,9 +248,11 @@ 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 = [] @@ -233,32 +261,21 @@ function cycleAppWindow(toplevels, activeToplevel, appId, direction) { if (t && stripDesktop(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) { diff --git a/README.md b/README.md index 9e06774..34243d5 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

- Release + Release Omarchy Hyprland Quickshell @@ -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.

Omadock Desktop Preview @@ -81,11 +81,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. +- **Click Active to Minimize** *(optional)*: Clicking the app you are already in parks its window on a hidden `special:minimized` workspace; clicking the icon again brings it back to where it came from. Multi-window apps keep cycling instead — that stays the more useful answer. 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 +124,11 @@ All settings can be toggled interactively via the right-click menu or configured "bgColor": "theme", "itemSpacing": 4, "screen": "", - "iconSize": 36 + "iconSize": 36, + "clickToMinimize": false, + "showUrgentHint": true, + "revealDelay": 160, + "tooltipDelay": 450 } ``` @@ -124,12 +138,16 @@ All settings can be toggled interactively via the right-click menu or configured | `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`). | | `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). | +| `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 +170,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": [ From 51a52f2067d185ba8c714f9076dc1888fbf4c596 Mon Sep 17 00:00:00 2001 From: NothingManTR Date: Thu, 20 Aug 2026 02:19:44 +0300 Subject: [PATCH 02/19] feat: smart PWA/Flutter app icon matching, magnification, launch bounce & theme radius --- Dock.qml | 236 +++++++++++++++++++++++++++++++++++++++------------ DockModel.js | 141 +++++++++++++++++++++++++----- 2 files changed, 304 insertions(+), 73 deletions(-) diff --git a/Dock.qml b/Dock.qml index 79a22ff..e3210fa 100644 --- a/Dock.qml +++ b/Dock.qml @@ -40,43 +40,80 @@ Item { property bool isDragging: false property bool _dragJustEnded: false property real dragStartX: 0 + property real bounceY: 0 + readonly property bool isHovered: area.containsMouse && !item.isDragging 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 { + id: bounceAnim + running: false + alwaysRunToEnd: true + NumberAnimation { target: item; property: "bounceY"; to: -Style.space(14); duration: 130; easing.type: Easing.OutQuad } + NumberAnimation { target: item; property: "bounceY"; to: 0; duration: 130; easing.type: Easing.InQuad } + NumberAnimation { target: item; property: "bounceY"; to: -Style.space(7); duration: 90; easing.type: Easing.OutQuad } + NumberAnimation { target: item; property: "bounceY"; to: 0; duration: 90; easing.type: Easing.InQuad } + } - Image { - id: iconImg - anchors.centerIn: parent - width: root.iconSize - Style.space(10) - height: width - source: item.icon !== "" ? item.icon : Quickshell.iconPath("application-x-executable", true) - sourceSize: Qt.size(width * Screen.devicePixelRatio, height * Screen.devicePixelRatio) - visible: source !== "" - mipmap: true - smooth: true + // 1. Icon Box: Only the icon scales on hover and bounces on click + Item { + id: iconBox + anchors.fill: parent + anchors.bottomMargin: item.running ? Style.space(5) : 0 + + scale: root.magnification && item.isHovered ? 1.20 : 1.0 + property real hoverLift: root.magnification && item.isHovered ? -Style.space(6) : 0 + y: hoverLift + + Behavior on scale { + NumberAnimation { duration: 130; easing.type: Easing.OutQuad } + } + Behavior on hoverLift { + NumberAnimation { duration: 130; easing.type: Easing.OutQuad } + } + + transform: Translate { + y: item.bounceY + } + + Rectangle { + anchors.fill: parent + anchors.margins: Style.space(2) + radius: (root.dockShape === "round" || root.dockShape === "pill") + ? width / 2 + : (root.dockShape === "square" ? 0 : ((root.dockShape === "theme" || root.dockShape === "auto") ? Math.max(4, Math.round(Style.cornerRadius * 0.6)) : 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 + + Image { + id: iconImg + anchors.centerIn: parent + width: root.iconSize - Style.space(10) + height: width + source: item.icon !== "" ? item.icon : Quickshell.iconPath("application-x-executable", true) + sourceSize: Qt.size(width * Screen.devicePixelRatio, height * Screen.devicePixelRatio) + visible: source !== "" + mipmap: true + smooth: true + } } } - // Running / multi-window indicator + // 2. Running Indicator Dots: Fixed at slot bottom, never scaled or pushed out of 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) @@ -95,12 +132,6 @@ Item { } } - Item { - id: iconBg - width: root.iconSlot - height: root.iconSlot - } - MouseArea { id: area anchors.fill: parent @@ -163,6 +194,7 @@ Item { var gx = dockCard.x + (pt ? pt.x : (item.x + item.width / 2)) item.menuRequested(item.appId, gx, 0) } else if (mouse.button === Qt.LeftButton) { + if (root.launchBounce) bounceAnim.restart() item.activateRequested(item.appId) } } @@ -170,26 +202,58 @@ Item { BorderSurface { id: itemTooltip - visible: area.containsMouse && !item.isDragging && item.name !== "" && root.showTooltips + visible: area.containsMouse && !item.isDragging && item.name !== "" && 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) + 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 + + 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.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, 3) : 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.windowList[index] && item.windowList[index].activated) ? Color.bar.active : Util.alpha(Color.tooltip.text, 0.4) + } + Text { + text: { + var t = item.windowList[index] ? String(item.windowList[index].title || "") : "" + return t.length > 30 ? t.slice(0, 28) + "…" : t + } + color: (item.windowList[index] && item.windowList[index].activated) ? 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 + } + } + } } } } @@ -210,7 +274,9 @@ Item { Rectangle { anchors.fill: parent anchors.margins: Style.space(2) - radius: (root.dockShape === "round" || root.dockShape === "pill") ? width / 2 : (root.dockShape === "square" ? 0 : 8) + radius: (root.dockShape === "round" || root.dockShape === "pill") + ? width / 2 + : (root.dockShape === "square" ? 0 : ((root.dockShape === "theme" || root.dockShape === "auto") ? Math.max(4, Math.round(Style.cornerRadius * 0.6)) : 8)) color: area.containsMouse ? (area.pressed ? Style.pressedFill : Style.hoverFill) : "transparent" border.color: area.containsMouse ? Style.hoverBorderColor : "transparent" border.width: Style.hoverBorderWidth @@ -221,6 +287,10 @@ Item { font.family: "omarchy" font.pixelSize: btn.glyphSize color: btn.glyphColor + scale: root.magnification && area.containsMouse ? 1.15 : 1.0 + Behavior on scale { + NumberAnimation { duration: 120; easing.type: Easing.OutQuad } + } } } @@ -247,7 +317,7 @@ Item { z: 300 color: Color.tooltip.background borderSpec: Border.surfaceSpec("tooltip", "border", Color.tooltip.border, 1) - radius: Style.cornerRadius + radius: Style.cornerRadius > 0 ? Style.cornerRadius : 8 padding: Style.space(4) x: (btn.width - width) / 2 y: -height - Style.space(8) @@ -405,6 +475,9 @@ Item { property bool intelligentAutohide: true property bool showAppsButton: true property bool showTooltips: true + property bool magnification: true + property bool launchBounce: true + property bool advancedTooltips: true property real dockOpacity: 1.0 property string dockShape: "rounded" property string dockBgColor: "theme" @@ -629,6 +702,9 @@ Item { root.intelligentAutohide = parsed && parsed.intelligentAutohide !== false root.showAppsButton = parsed && parsed.showAppsButton !== false root.showTooltips = parsed && parsed.showTooltips !== false + root.magnification = parsed && parsed.magnification !== false + 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 @@ -713,6 +789,9 @@ Item { conf.intelligentAutohide = root.intelligentAutohide conf.showAppsButton = root.showAppsButton conf.showTooltips = root.showTooltips + conf.magnification = root.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 @@ -720,7 +799,6 @@ Item { conf.shape = root.dockShape conf.bgColor = root.dockBgColor conf.itemSpacing = root.itemSpacing - delete conf.magnification configFile.setText(JSON.stringify(conf, null, 2)) } @@ -730,7 +808,21 @@ Item { if (entry && entry.running) { DockModel.activateApp(ToplevelManager.toplevels.values, ToplevelManager.activeToplevel, appId) } else { - root.shell.appLibrary.launch(appId, entry ? entry.name : appId) + var deskEntry = DockModel.entryFor(root.appRows, appId) + var targetId = (deskEntry && deskEntry.id) ? deskEntry.id : appId + var targetName = (deskEntry && deskEntry.name) ? deskEntry.name : (entry ? entry.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) + } + } } } @@ -757,7 +849,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 @@ -856,7 +950,7 @@ Item { 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))) + : (root.dockShape === "square" ? 0 : ((root.dockShape === "theme" || root.dockShape === "auto") ? (Style.cornerRadius > 0 ? Style.cornerRadius : Math.max(14, Style.space(14))) : Math.max(14, Style.space(14)))) padding: Style.space(4) z: 1 @@ -1076,7 +1170,7 @@ Item { } ContextRow { - text: "Shape: " + (root.dockShape === "round" || root.dockShape === "pill" ? "Round" : (root.dockShape === "square" ? "Square" : "Rounded")) + " ›" + 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" } @@ -1106,6 +1200,33 @@ Item { color: Util.alpha(Color.menu.border, 0.4) } + ContextRow { + text: "Magnification (Zoom)" + checked: root.magnification + onTriggered: { + root.magnification = !root.magnification + root.saveConfig() + } + } + + 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 { text: "Show Tooltips" checked: root.showTooltips @@ -1167,6 +1288,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 +1301,7 @@ Item { } ContextRow { - text: "Round" + text: "Round (Pill)" checked: root.dockShape === "round" || root.dockShape === "pill" onTriggered: root.setDockShape("round") } @@ -1431,8 +1558,7 @@ 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.activate(root.contextAppId) root.closeContext() } } @@ -1440,7 +1566,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..80257f3 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) @@ -21,6 +29,65 @@ function normalizeId(id) { return stripDesktop(id) } +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) { var text = String(raw == null ? "" : raw).trim() if (!text) return [] @@ -105,36 +172,42 @@ 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 @@ -162,6 +235,17 @@ function buildEntries(pinnedIds, toplevels, appRows, appLibrary) { }) } + 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 +255,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 +280,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 +294,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({ @@ -230,7 +333,7 @@ function cycleAppWindow(toplevels, activeToplevel, appId, direction) { 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 @@ -277,7 +380,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 } From 90d9291520c7c95f3ff27d0e95bc951d7ad68467 Mon Sep 17 00:00:00 2001 From: NothingManTR Date: Thu, 20 Aug 2026 02:34:09 +0300 Subject: [PATCH 03/19] style: categorize settings into clean submenus and fix menu top border clipping --- Dock.qml | 154 ++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 118 insertions(+), 36 deletions(-) diff --git a/Dock.qml b/Dock.qml index fbbcba2..bb7ab68 100644 --- a/Dock.qml +++ b/Dock.qml @@ -1297,7 +1297,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 @@ -1582,7 +1582,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 === "" @@ -1593,8 +1593,40 @@ 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: "‹ Back" + textColor: Color.bar.active + onTriggered: root.settingsSubmenu = "" + } + + ContextRow { + text: "Appearance" + isHeader: true } ContextRow { @@ -1611,21 +1643,62 @@ 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: "‹ Back" + textColor: Color.bar.active + onTriggered: root.settingsSubmenu = "" + } ContextRow { - text: "Icon Size: " + root.iconSize + "px ›" - onTriggered: root.settingsSubmenu = "size" + text: "Behavior & Windows" + isHeader: true } ContextRow { - text: "Spacing: " + (root.itemSpacing <= 2 ? "Compact" : (root.itemSpacing <= 5 ? "Normal" : "Relaxed")) + " ›" - onTriggered: root.settingsSubmenu = "spacing" + text: "Autohide: " + (root.autohide ? (root.intelligentAutohide ? "Intelligent" : "Auto Hide") : "Always Show") + " ›" + onTriggered: root.settingsSubmenu = "autohide" } - Rectangle { - width: parent.width - height: 1 - color: Util.alpha(Color.menu.border, 0.4) + ContextRow { + text: "Click Active to Minimize" + checked: root.clickToMinimize + onTriggered: { + root.clickToMinimize = !root.clickToMinimize + root.saveConfig() + } + } + + 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 { @@ -1663,27 +1736,36 @@ Item { root.saveConfig() } } + } + + // 5. Size & Spacing Category Page + Column { + spacing: Style.space(1) + visible: root.settingsSubmenu === "size_spacing" ContextRow { - text: "Urgent Highlights" - checked: root.showUrgentHint - onTriggered: { - root.showUrgentHint = !root.showUrgentHint - root.saveConfig() - } + text: "‹ Back" + textColor: Color.bar.active + onTriggered: root.settingsSubmenu = "" } ContextRow { - text: "Click Active to Minimize" - checked: root.clickToMinimize - onTriggered: { - root.clickToMinimize = !root.clickToMinimize - root.saveConfig() - } + 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" } } - // 2. Autohide Submenu Page + // 6. Autohide Submenu Page Column { spacing: Style.space(1) visible: root.settingsSubmenu === "autohide" @@ -1691,7 +1773,7 @@ Item { ContextRow { text: "‹ Back" textColor: Color.bar.active - onTriggered: root.settingsSubmenu = "" + onTriggered: root.settingsSubmenu = "behavior" } ContextRow { @@ -1718,7 +1800,7 @@ Item { } } - // 3. Shape Submenu Page + // 7. Shape Submenu Page Column { spacing: Style.space(1) visible: root.settingsSubmenu === "shape" @@ -1726,7 +1808,7 @@ Item { ContextRow { text: "‹ Back" textColor: Color.bar.active - onTriggered: root.settingsSubmenu = "" + onTriggered: root.settingsSubmenu = "appearance" } ContextRow { @@ -1759,7 +1841,7 @@ Item { } } - // 4. Background Color Submenu Page + // 8. Background Color Submenu Page Column { spacing: Style.space(1) visible: root.settingsSubmenu === "color" @@ -1767,7 +1849,7 @@ Item { ContextRow { text: "‹ Back" textColor: Color.bar.active - onTriggered: root.settingsSubmenu = "" + onTriggered: root.settingsSubmenu = "appearance" } ContextRow { @@ -1845,7 +1927,7 @@ Item { } } - // 4. Background Opacity Submenu Page + // 9. Background Opacity Submenu Page Column { spacing: Style.space(1) visible: root.settingsSubmenu === "opacity" @@ -1853,7 +1935,7 @@ Item { ContextRow { text: "‹ Back" textColor: Color.bar.active - onTriggered: root.settingsSubmenu = "" + onTriggered: root.settingsSubmenu = "appearance" } ContextRow { @@ -1892,7 +1974,7 @@ Item { } } - // 5. Icon Size Submenu Page + // 10. Icon Size Submenu Page Column { spacing: Style.space(1) visible: root.settingsSubmenu === "size" @@ -1900,7 +1982,7 @@ Item { ContextRow { text: "‹ Back" textColor: Color.bar.active - onTriggered: root.settingsSubmenu = "" + onTriggered: root.settingsSubmenu = "size_spacing" } ContextRow { @@ -1933,7 +2015,7 @@ Item { } } - // 6. Icon Spacing Submenu Page + // 11. Icon Spacing Submenu Page Column { spacing: Style.space(1) visible: root.settingsSubmenu === "spacing" @@ -1941,7 +2023,7 @@ Item { ContextRow { text: "‹ Back" textColor: Color.bar.active - onTriggered: root.settingsSubmenu = "" + onTriggered: root.settingsSubmenu = "size_spacing" } ContextRow { From 2b05a4fce45de7853c9b77190e8037ed6aa25167 Mon Sep 17 00:00:00 2001 From: NothingManTR Date: Thu, 20 Aug 2026 02:37:31 +0300 Subject: [PATCH 04/19] feat: add configurable minimize modes (active window vs all windows) --- Dock.qml | 139 +++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 120 insertions(+), 19 deletions(-) diff --git a/Dock.qml b/Dock.qml index bb7ab68..cf3e3a3 100644 --- a/Dock.qml +++ b/Dock.qml @@ -660,7 +660,8 @@ Item { property string dockShape: "rounded" property string dockBgColor: "theme" property int itemSpacing: 4 - property bool clickToMinimize: false + property string minimizeMode: "off" + readonly property bool clickToMinimize: root.minimizeMode !== "off" property bool showUrgentHint: true property int revealDelay: 160 property int tooltipDelay: 450 @@ -927,7 +928,13 @@ Item { 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 - root.clickToMinimize = !!(parsed && parsed.clickToMinimize === true) + 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))) @@ -1098,6 +1105,41 @@ Item { return true } + function minimizeApp(entry) { + if (!entry) return false + var windows = entry.windowList || [] + if (windows.length === 0) return false + + if (root.minimizeMode === "all") { + var anyMin = false + for (var i = 0; i < windows.length; i++) { + var w = windows[i] + if (w && w.toplevel) { + var handle = root.hyprToplevelFor(w.toplevel) + var ws = handle ? handle.workspace : null + if (ws && ws.name !== root.minimizedWorkspace) { + root.minimizeToplevel(w.toplevel) + anyMin = true + } + } + } + return anyMin + } else { + var targetWin = null + for (var j = 0; j < windows.length; j++) { + if (windows[j] && windows[j].activated) { + targetWin = windows[j] + break + } + } + if (!targetWin && windows.length > 0) targetWin = windows[0] + if (targetWin && targetWin.toplevel) { + return root.minimizeToplevel(targetWin.toplevel) + } + return false + } + } + // Drop origins for windows that are gone, so the map cannot grow forever. function pruneMinimized() { var origins = root.minimizedOrigins @@ -1195,7 +1237,8 @@ Item { conf.shape = root.dockShape conf.bgColor = root.dockBgColor conf.itemSpacing = root.itemSpacing - conf.clickToMinimize = root.clickToMinimize + conf.minimizeMode = root.minimizeMode + conf.clickToMinimize = root.minimizeMode !== "off" conf.showUrgentHint = root.showUrgentHint conf.revealDelay = root.revealDelay conf.tooltipDelay = root.tooltipDelay @@ -1211,12 +1254,30 @@ Item { return } - // Clicking the app you are already in is otherwise a dead click. With one - // window there is no ambiguity about what to put away; with several, - // cycling stays the more useful answer. var windows = entry.windowList || [] - if (root.clickToMinimize && appId === root.activeId && windows.length === 1 - && root.minimizeToplevel(windows[0].toplevel)) return + + // If all windows of this app are currently minimized, clicking restores them + var allMinimized = windows.length > 0 + for (var k = 0; k < windows.length; k++) { + var whandle = root.hyprToplevelFor(windows[k].toplevel) + var wws = whandle ? whandle.workspace : null + if (!wws || wws.name !== root.minimizedWorkspace) { + allMinimized = false + break + } + } + if (allMinimized) { + for (var r = 0; r < windows.length; r++) { + var rhandle = root.hyprToplevelFor(windows[r].toplevel) + if (rhandle) root.restoreWindow(rhandle) + } + return + } + + // If minimizeMode is enabled and the app is currently active, minimize + if (root.minimizeMode !== "off" && appId === root.activeId) { + if (root.minimizeApp(entry)) return + } root.focusToplevel(DockModel.pickAppWindow( ToplevelManager.toplevels.values, ToplevelManager.activeToplevel, appId, 1)) @@ -1667,12 +1728,8 @@ Item { } ContextRow { - text: "Click Active to Minimize" - checked: root.clickToMinimize - onTriggered: { - root.clickToMinimize = !root.clickToMinimize - root.saveConfig() - } + text: "Minimize On Click: " + (root.minimizeMode === "all" ? "All Windows" : (root.minimizeMode === "active" ? "Active Window" : "Disabled")) + " ›" + onTriggered: root.settingsSubmenu = "minimize" } ContextRow { @@ -1800,7 +1857,51 @@ Item { } } - // 7. 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" @@ -1841,7 +1942,7 @@ Item { } } - // 8. Background Color Submenu Page + // 9. Background Color Submenu Page Column { spacing: Style.space(1) visible: root.settingsSubmenu === "color" @@ -1927,7 +2028,7 @@ Item { } } - // 9. Background Opacity Submenu Page + // 10. Background Opacity Submenu Page Column { spacing: Style.space(1) visible: root.settingsSubmenu === "opacity" @@ -1974,7 +2075,7 @@ Item { } } - // 10. Icon Size Submenu Page + // 11. Icon Size Submenu Page Column { spacing: Style.space(1) visible: root.settingsSubmenu === "size" @@ -2015,7 +2116,7 @@ Item { } } - // 11. Icon Spacing Submenu Page + // 12. Icon Spacing Submenu Page Column { spacing: Style.space(1) visible: root.settingsSubmenu === "spacing" From b0ea687f9d38f1f32ce6ee4991cfc6d30e05b405 Mon Sep 17 00:00:00 2001 From: NothingManTR Date: Thu, 20 Aug 2026 02:42:06 +0300 Subject: [PATCH 05/19] fix: remember pre-dock active app and most recently used window for reliable multi-window minimize --- Dock.qml | 120 +++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 107 insertions(+), 13 deletions(-) diff --git a/Dock.qml b/Dock.qml index cf3e3a3..aea58d8 100644 --- a/Dock.qml +++ b/Dock.qml @@ -626,6 +626,9 @@ Item { // means the window comes back to wherever you are. readonly property string minimizedWorkspace: "special:minimized" property var minimizedOrigins: ({}) + property var appLastActiveWindow: ({}) + property string lastPreDockActiveApp: "" + property var lastPreDockActiveToplevel: null // Apps whose launch has been asked for but whose window has not shown up yet. property var launchPending: ({}) @@ -876,6 +879,19 @@ Item { Connections { target: ToplevelManager function onActiveToplevelChanged() { + var top = ToplevelManager.activeToplevel + if (top) { + var aid = DockModel.normalizeId(top.appId) + if (aid) { + var map = root.appLastActiveWindow + map[aid] = top + root.appLastActiveWindow = map + } + if (!(cardHover && cardHover.hovered) && !(revealHover && revealHover.hovered) && root.contextAppId === "") { + root.lastPreDockActiveApp = aid + root.lastPreDockActiveToplevel = top + } + } debounceOverlapTimer.restart() } } @@ -1126,13 +1142,42 @@ Item { return anyMin } else { var targetWin = null - for (var j = 0; j < windows.length; j++) { - if (windows[j] && windows[j].activated) { - targetWin = windows[j] - break + + // Check remembered last active window for this app + var remembered = root.appLastActiveWindow[entry.appId] + if (remembered) { + for (var k = 0; k < windows.length; k++) { + if (windows[k] && windows[k].toplevel === remembered) { + var rhandle = root.hyprToplevelFor(remembered) + var rws = rhandle ? rhandle.workspace : null + if (rws && rws.name !== root.minimizedWorkspace) { + targetWin = windows[k] + break + } + } } } - if (!targetWin && windows.length > 0) targetWin = windows[0] + + if (!targetWin) { + for (var j = 0; j < windows.length; j++) { + if (windows[j] && windows[j].activated) { + targetWin = windows[j] + break + } + } + } + + if (!targetWin) { + for (var m = 0; m < windows.length; m++) { + var mhandle = root.hyprToplevelFor(windows[m].toplevel) + var mws = mhandle ? mhandle.workspace : null + if (mws && mws.name !== root.minimizedWorkspace) { + targetWin = windows[m] + break + } + } + } + if (targetWin && targetWin.toplevel) { return root.minimizeToplevel(targetWin.toplevel) } @@ -1255,9 +1300,13 @@ Item { } var windows = entry.windowList || [] + if (windows.length === 0) { + root.launchApp(appId, entry) + return + } // If all windows of this app are currently minimized, clicking restores them - var allMinimized = windows.length > 0 + var allMinimized = true for (var k = 0; k < windows.length; k++) { var whandle = root.hyprToplevelFor(windows[k].toplevel) var wws = whandle ? whandle.workspace : null @@ -1274,13 +1323,46 @@ Item { return } - // If minimizeMode is enabled and the app is currently active, minimize - if (root.minimizeMode !== "off" && appId === root.activeId) { - if (root.minimizeApp(entry)) return + // Check if this app is currently active OR was active before cursor entered dock + var isAppActive = (appId === root.activeId) || + (root.lastPreDockActiveApp !== "" && DockModel.isAppMatch(appId, root.lastPreDockActiveApp)) + + for (var w = 0; w < windows.length; w++) { + if (windows[w] && (windows[w].activated || (ToplevelManager.activeToplevel && windows[w].toplevel === ToplevelManager.activeToplevel))) { + isAppActive = true + break + } } - root.focusToplevel(DockModel.pickAppWindow( - ToplevelManager.toplevels.values, ToplevelManager.activeToplevel, appId, 1)) + // If minimizeMode is enabled and the app is active, minimize! + if (root.minimizeMode !== "off" && isAppActive) { + if (root.minimizeApp(entry)) { + root.lastPreDockActiveApp = "" + root.lastPreDockActiveToplevel = null + return + } + } + + // Otherwise: bring forward / focus the app (most recent window) + var targetToplevel = null + var remembered = root.appLastActiveWindow[appId] + if (remembered) { + for (var j = 0; j < windows.length; j++) { + if (windows[j].toplevel === remembered) { + targetToplevel = remembered + break + } + } + } + + if (!targetToplevel) { + targetToplevel = DockModel.pickAppWindow( + ToplevelManager.toplevels.values, ToplevelManager.activeToplevel, appId, 1) + } + + root.focusToplevel(targetToplevel) + root.lastPreDockActiveApp = appId + root.lastPreDockActiveToplevel = targetToplevel } // Menu rows name the workspace a window sits on, including the parked ones. @@ -1379,7 +1461,13 @@ Item { HoverHandler { id: revealHover - onHoveredChanged: root.syncVisibility() + onHoveredChanged: { + if (revealHover.hovered && ToplevelManager.activeToplevel) { + root.lastPreDockActiveApp = DockModel.normalizeId(ToplevelManager.activeToplevel.appId) + root.lastPreDockActiveToplevel = ToplevelManager.activeToplevel + } + root.syncVisibility() + } } Rectangle { @@ -1439,7 +1527,13 @@ Item { HoverHandler { id: cardHover - onHoveredChanged: root.syncVisibility() + onHoveredChanged: { + if (cardHover.hovered && ToplevelManager.activeToplevel) { + root.lastPreDockActiveApp = DockModel.normalizeId(ToplevelManager.activeToplevel.appId) + root.lastPreDockActiveToplevel = ToplevelManager.activeToplevel + } + root.syncVisibility() + } } anchors.horizontalCenter: parent.horizontalCenter From f4fe5df4ef1f7b38c30052ed20dcda1e986ac930 Mon Sep 17 00:00:00 2001 From: NothingManTR Date: Thu, 20 Aug 2026 02:50:14 +0300 Subject: [PATCH 06/19] feat: implement LIFO toggle minimize/restore for intuitive multi-window workflows --- Dock.qml | 42 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/Dock.qml b/Dock.qml index aea58d8..69a0c82 100644 --- a/Dock.qml +++ b/Dock.qml @@ -626,6 +626,7 @@ Item { // means the window comes back to wherever you are. readonly property string minimizedWorkspace: "special:minimized" property var minimizedOrigins: ({}) + property var appLastMinimizedWindow: ({}) property var appLastActiveWindow: ({}) property string lastPreDockActiveApp: "" property var lastPreDockActiveToplevel: null @@ -1094,6 +1095,13 @@ Item { origins[address] = origin root.minimizedOrigins = origins + var aid = DockModel.normalizeId(toplevel.appId) + if (aid) { + var minMap = root.appLastMinimizedWindow + minMap[aid] = address + root.appLastMinimizedWindow = minMap + } + root.hyprDispatch( 'hl.dsp.window.move({ window = "address:' + address + '", workspace = "' + root.luaString(root.minimizedWorkspace) + '", follow = false })', @@ -1112,6 +1120,15 @@ Item { delete origins[address] root.minimizedOrigins = origins + if (handle && handle.wayland) { + var aid = DockModel.normalizeId(handle.wayland.appId) + if (aid && root.appLastMinimizedWindow[aid] === address) { + var minMap = root.appLastMinimizedWindow + delete minMap[aid] + root.appLastMinimizedWindow = minMap + } + } + root.hyprDispatch( 'hl.dsp.window.move({ window = "address:' + address + '", workspace = "' + root.luaString(target) + '", follow = true })', @@ -1343,14 +1360,35 @@ Item { } } + // If app is NOT active: First check if there is a recently minimized window of this app to restore (LIFO Undo) + var lastMinAddress = root.appLastMinimizedWindow[appId] + if (lastMinAddress) { + for (var m = 0; m < windows.length; m++) { + var mHandle = root.hyprToplevelFor(windows[m].toplevel) + if (mHandle && root.windowAddress(mHandle) === lastMinAddress) { + var mWs = mHandle.workspace + if (mWs && mWs.name === root.minimizedWorkspace) { + root.restoreWindow(mHandle) + root.lastPreDockActiveApp = appId + root.lastPreDockActiveToplevel = windows[m].toplevel + return + } + } + } + } + // Otherwise: bring forward / focus the app (most recent window) var targetToplevel = null var remembered = root.appLastActiveWindow[appId] if (remembered) { for (var j = 0; j < windows.length; j++) { if (windows[j].toplevel === remembered) { - targetToplevel = remembered - break + var remHandle = root.hyprToplevelFor(remembered) + var remWs = remHandle ? remHandle.workspace : null + if (remWs && remWs.name !== root.minimizedWorkspace) { + targetToplevel = remembered + break + } } } } From 8468d9188b2387d6753c2ab4d97e68e3d0266d05 Mon Sep 17 00:00:00 2001 From: NothingManTR Date: Thu, 20 Aug 2026 02:53:35 +0300 Subject: [PATCH 07/19] fix: prevent mouse cursor warping when cycling windows via scroll on dock --- Dock.qml | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/Dock.qml b/Dock.qml index 69a0c82..ea7faa9 100644 --- a/Dock.qml +++ b/Dock.qml @@ -1022,8 +1022,10 @@ Item { } function cycleApp(appId, direction) { - root.focusToplevel(DockModel.pickAppWindow( - ToplevelManager.toplevels.values, ToplevelManager.activeToplevel, appId, direction)) + var nextTop = DockModel.pickAppWindow( + ToplevelManager.toplevels.values, ToplevelManager.activeToplevel, appId, direction) + if (!nextTop) return + root.focusToplevel(nextTop, true /* noWarp */) } // ------------------------------------------------- window plumbing @@ -1059,11 +1061,9 @@ Item { 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. - function focusToplevel(toplevel) { + // Brings a window forward. When noWarp is true (e.g. during mouse wheel cycling), + // Wayland activation gives window focus without pulling the mouse cursor away from the dock. + function focusToplevel(toplevel, noWarp) { if (!toplevel) return var handle = root.hyprToplevelFor(toplevel) var workspace = handle ? handle.workspace : null @@ -1073,12 +1073,27 @@ Item { return } - var address = root.windowAddress(handle) - if (!address) { - DockModel.focusWindow(toplevel) + DockModel.focusWindow(toplevel) + + if (noWarp) { + var addr = root.windowAddress(handle) + if (addr) { + root.hyprDispatch('hl.dsp.window.alterzorder({ window = "address:' + addr + '", zorder = "top" })', + "alterzorder top,address:" + addr) + } + if (workspace && Hyprland.focusedWorkspace && workspace.id !== Hyprland.focusedWorkspace.id) { + var targetWs = root.workspaceTarget(workspace) + if (targetWs) { + root.hyprDispatch('hl.dsp.workspace({ name = "' + root.luaString(targetWs) + '" })', + "workspace " + targetWs) + } + } return } + var address = root.windowAddress(handle) + if (!address) return + root.hyprDispatch('hl.dsp.focus({ window = "address:' + address + '" })', "focuswindow address:" + address) } From 9dc0767211eb66bcb96ccd9a301b241bd24ff5cd Mon Sep 17 00:00:00 2001 From: NothingManTR Date: Thu, 20 Aug 2026 02:55:18 +0300 Subject: [PATCH 08/19] fix(focus): ensure seamless window focus dispatch with compositor no_warps --- Dock.qml | 35 +++++++++++++---------------------- 1 file changed, 13 insertions(+), 22 deletions(-) diff --git a/Dock.qml b/Dock.qml index ea7faa9..2a045b7 100644 --- a/Dock.qml +++ b/Dock.qml @@ -1061,9 +1061,8 @@ Item { return name !== "" ? name : String(workspace.id) } - // Brings a window forward. When noWarp is true (e.g. during mouse wheel cycling), - // Wayland activation gives window focus without pulling the mouse cursor away from the dock. - function focusToplevel(toplevel, noWarp) { + // Brings a window forward and focuses it. + function focusToplevel(toplevel) { if (!toplevel) return var handle = root.hyprToplevelFor(toplevel) var workspace = handle ? handle.workspace : null @@ -1075,27 +1074,19 @@ Item { DockModel.focusWindow(toplevel) - if (noWarp) { - var addr = root.windowAddress(handle) - if (addr) { - root.hyprDispatch('hl.dsp.window.alterzorder({ window = "address:' + addr + '", zorder = "top" })', - "alterzorder top,address:" + addr) - } - if (workspace && Hyprland.focusedWorkspace && workspace.id !== Hyprland.focusedWorkspace.id) { - var targetWs = root.workspaceTarget(workspace) - if (targetWs) { - root.hyprDispatch('hl.dsp.workspace({ name = "' + root.luaString(targetWs) + '" })', - "workspace " + targetWs) - } - } - return - } - var address = root.windowAddress(handle) - if (!address) return + if (address) { + root.hyprDispatch('hl.dsp.focus({ window = "address:' + address + '" })', + "focuswindow address:" + address) + } - root.hyprDispatch('hl.dsp.focus({ window = "address:' + address + '" })', - "focuswindow address:" + address) + if (workspace && Hyprland.focusedWorkspace && workspace.id !== Hyprland.focusedWorkspace.id) { + var targetWs = root.workspaceTarget(workspace) + if (targetWs) { + root.hyprDispatch('hl.dsp.workspace({ name = "' + root.luaString(targetWs) + '" })', + "workspace " + targetWs) + } + } } function minimizeToplevel(toplevel) { From d060f2e2d88d2c5e6561af20bd76f7a57db1ead1 Mon Sep 17 00:00:00 2001 From: assada Date: Thu, 20 Aug 2026 19:53:03 +0200 Subject: [PATCH 09/19] fix: repair window focus, remembered-window state and stuck tooltips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up on the merge of PR #2. The features stay; these are the defects underneath them. Focus dispatch: - Dropped hl.dsp.workspace({ name = ... }). It does not exist — hl.dsp.workspace is a namespace (hl.dsp.workspace.toggle_special), so Hyprland 0.56 answers "attempt to call a table value (field 'workspace')" and the call was a silent no-op. Nothing was lost: hl.dsp.focus already switches to the window's workspace on its way. - The Wayland activate request is a fallback again instead of firing on every focus. It was landing before the Hyprland dispatch on every click, so each click asked for focus twice. - cycleApp no longer passes a second argument to focusToplevel, which has taken one parameter since the noWarp branch was removed. Remembered windows: - appLastActiveWindow held live Toplevel objects keyed by app, and was never pruned, so a closed window left a dangling handle behind. Both maps now hold addresses; addresses cannot dangle, and a stale one is simply dropped on the next prune. - Both maps were written by mutating the object in place and assigning it back to the same property. QML compares by reference, sees no change, and emits no signal. They go through DockModel.copyMap now. - lastPreDockActiveToplevel was written in six places and read in none, holding a live window handle for nothing. Removed; lastPreDockActiveApp carries what is actually used. - pruneMinimized became pruneWindowState: one pass over the live windows now trims every address-keyed map, not just the parked-origin one. Hyprland's own focusHistoryID would have replaced this bookkeeping entirely, but Quickshell only refreshes lastIpcObject when a window opens or closes, so the value is stale the moment focus moves — measured: the focused window kept reporting focusHistoryID 4 while a background window reported 0. Tooltips: - Visibility follows hover declaratively. Driving `shown` only from the containsMouse change signal leaves the bubble on screen whenever an item stops being hovered without that signal arriving — a model rebuild under the pointer, or the card sliding out from under it as the dock reveals. Seen in practice: a tooltip left hanging over an icon with the pointer at the screen edge. Hover and click feedback, per review notes: - The hover frame is gone. An icon grows and lifts on hover and dips on press; the plate, its border and the active-app fill were doing the same job three times over and the frame bounced along with the icon. Urgency, which does need to reach past the icon art, now rings the slot instead. - The launch bounce is tied to the app actually starting, which is what a dock bounce means: opening something that was closed, or middle-clicking a new window. Clicking an app that is already running moves focus and no longer throws its icon around. The fade stays as the launch cue when the bounce is switched off. - The apps button follows the same rule: it grows, it does not draw a frame. Co-authored-by: NothingManTR --- Dock.qml | 328 +++++++++++++++++++++++++------------------------------ 1 file changed, 148 insertions(+), 180 deletions(-) diff --git a/Dock.qml b/Dock.qml index 2a045b7..96ea8dc 100644 --- a/Dock.qml +++ b/Dock.qml @@ -148,9 +148,10 @@ Item { return item.name } - // One pulse drives both attention states: urgency and a cold start. + // The pulse carries urgency. A cold start bounces instead, and only falls + // back to fading when the bounce is off. property real pulse: 1.0 - readonly property bool pulsing: item.urgent || item.starting + readonly property bool pulsing: item.urgent || (item.starting && !root.launchBounce) onPulsingChanged: if (!item.pulsing) item.pulse = 1.0 SequentialAnimation on pulse { @@ -165,62 +166,55 @@ Item { NumberAnimation { duration: 120 } } - SequentialAnimation { - id: bounceAnim - running: false - alwaysRunToEnd: true - NumberAnimation { target: item; property: "bounceY"; to: -Style.space(14); duration: 130; easing.type: Easing.OutQuad } - NumberAnimation { target: item; property: "bounceY"; to: 0; duration: 130; easing.type: Easing.InQuad } - NumberAnimation { target: item; property: "bounceY"; to: -Style.space(7); duration: 90; easing.type: Easing.OutQuad } - NumberAnimation { target: item; property: "bounceY"; to: 0; duration: 90; easing.type: Easing.InQuad } + 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 - // 1. Icon Box: Only the icon scales on hover and bounces on click + // 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: root.magnification && item.isHovered ? 1.20 : 1.0 - property real hoverLift: root.magnification && item.isHovered ? -Style.space(6) : 0 - y: hoverLift + scale: (root.magnification && item.isHovered ? 1.22 : 1.0) * (area.pressed ? 0.92 : 1.0) + y: root.magnification && item.isHovered ? -Style.space(6) : 0 - Behavior on scale { - NumberAnimation { duration: 130; easing.type: Easing.OutQuad } - } - Behavior on hoverLift { - NumberAnimation { duration: 130; easing.type: Easing.OutQuad } - } + Behavior on scale { NumberAnimation { duration: 130; easing.type: Easing.OutQuad } } + Behavior on y { NumberAnimation { duration: 130; easing.type: Easing.OutQuad } } transform: Translate { y: item.bounceY } - Rectangle { - anchors.fill: parent - anchors.margins: Style.space(2) - radius: (root.dockShape === "round" || root.dockShape === "pill") - ? width / 2 - : (root.dockShape === "square" ? 0 : ((root.dockShape === "theme" || root.dockShape === "auto") ? Math.max(4, Math.round(Style.cornerRadius * 0.6)) : 8)) - color: area.containsMouse - ? (area.pressed ? Style.pressedFill : Style.hoverFill) - : (item.active ? Style.selectedFill : "transparent") - border.color: area.containsMouse - ? Style.hoverBorderColor - : (item.urgent ? Util.alpha(Color.urgent, 0.3 + 0.6 * item.pulse) : "transparent") - border.width: Style.hoverBorderWidth - - Image { - id: iconImg + Image { + id: iconImg + anchors.centerIn: parent + width: root.iconSize - Style.space(10) + 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.pulsing && item.starting ? 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: root.iconSize - Style.space(10) + width: parent.width + Style.space(8) 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 ? item.pulse : 1.0 - mipmap: true - smooth: true + 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) } } } @@ -322,7 +316,6 @@ Item { } else if (mouse.button === Qt.MiddleButton) { item.newWindowRequested(item.appId) } else if (mouse.button === Qt.LeftButton) { - if (root.launchBounce) bounceAnim.restart() item.activateRequested(item.appId) } } @@ -331,7 +324,9 @@ Item { BorderSurface { id: itemTooltip property bool shown: false - visible: itemTooltip.shown && item.name !== "" && root.showTooltips && !item.isDragging && root.contextAppId === "" + 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) @@ -342,14 +337,11 @@ Item { width: tooltipContent.implicitWidth + contentLeftInset + contentRightInset height: tooltipContent.implicitHeight + contentTopInset + contentBottomInset - Connections { - target: area - function onContainsMouseChanged() { - if (area.containsMouse && !item.isDragging) tooltipDwell.restart() - else { - tooltipDwell.stop() - itemTooltip.shown = false - } + onWantedChanged: { + if (itemTooltip.wanted) tooltipDwell.restart() + else { + tooltipDwell.stop() + itemTooltip.shown = false } } @@ -418,27 +410,16 @@ Item { width: root.iconSlot height: root.iconSlot - Rectangle { - anchors.fill: parent - anchors.margins: Style.space(2) - radius: (root.dockShape === "round" || root.dockShape === "pill") - ? width / 2 - : (root.dockShape === "square" ? 0 : ((root.dockShape === "theme" || root.dockShape === "auto") ? Math.max(4, Math.round(Style.cornerRadius * 0.6)) : 8)) - color: area.containsMouse ? (area.pressed ? Style.pressedFill : Style.hoverFill) : "transparent" - border.color: area.containsMouse ? Style.hoverBorderColor : "transparent" - border.width: Style.hoverBorderWidth - - Text { - anchors.centerIn: parent - text: btn.glyph - font.family: "omarchy" - font.pixelSize: btn.glyphSize - color: btn.glyphColor - scale: root.magnification && area.containsMouse ? 1.15 : 1.0 - Behavior on scale { - NumberAnimation { duration: 120; easing.type: Easing.OutQuad } - } - } + Text { + anchors.centerIn: parent + text: btn.glyph + font.family: "omarchy" + font.pixelSize: btn.glyphSize + color: btn.glyphColor + scale: (root.magnification && area.containsMouse ? 1.22 : 1.0) * (area.pressed ? 0.92 : 1.0) + y: root.magnification && area.containsMouse ? -Style.space(6) : 0 + Behavior on scale { NumberAnimation { duration: 130; easing.type: Easing.OutQuad } } + Behavior on y { NumberAnimation { duration: 130; easing.type: Easing.OutQuad } } } MouseArea { @@ -609,7 +590,7 @@ Item { root.shell.appLibrary, root.hyprToplevelFor) : { pinned: [], running: [] } root.pruneLaunching() - root.pruneMinimized() + root.pruneWindowState() } readonly property string activeId: ToplevelManager.activeToplevel @@ -626,10 +607,16 @@ Item { // means the window comes back to wherever you are. readonly property string minimizedWorkspace: "special:minimized" property var minimizedOrigins: ({}) - property var appLastMinimizedWindow: ({}) - property var appLastActiveWindow: ({}) + + // 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 appParkedWindow: ({}) + property var appRecentWindow: ({}) property string lastPreDockActiveApp: "" - property var lastPreDockActiveToplevel: null // Apps whose launch has been asked for but whose window has not shown up yet. property var launchPending: ({}) @@ -883,14 +870,14 @@ Item { var top = ToplevelManager.activeToplevel if (top) { var aid = DockModel.normalizeId(top.appId) - if (aid) { - var map = root.appLastActiveWindow - map[aid] = top - root.appLastActiveWindow = map + var address = root.windowAddress(root.hyprToplevelFor(top)) + if (aid && address) { + var recent = DockModel.copyMap(root.appRecentWindow) + recent[aid] = address + root.appRecentWindow = recent } if (!(cardHover && cardHover.hovered) && !(revealHover && revealHover.hovered) && root.contextAppId === "") { root.lastPreDockActiveApp = aid - root.lastPreDockActiveToplevel = top } } debounceOverlapTimer.restart() @@ -1022,10 +1009,8 @@ Item { } function cycleApp(appId, direction) { - var nextTop = DockModel.pickAppWindow( - ToplevelManager.toplevels.values, ToplevelManager.activeToplevel, appId, direction) - if (!nextTop) return - root.focusToplevel(nextTop, true /* noWarp */) + root.focusToplevel(DockModel.pickAppWindow( + ToplevelManager.toplevels.values, ToplevelManager.activeToplevel, appId, direction)) } // ------------------------------------------------- window plumbing @@ -1061,7 +1046,11 @@ Item { return name !== "" ? name : String(workspace.id) } - // Brings a window forward and focuses it. + // 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) @@ -1072,21 +1061,14 @@ Item { return } - DockModel.focusWindow(toplevel) - var address = root.windowAddress(handle) - if (address) { - root.hyprDispatch('hl.dsp.focus({ window = "address:' + address + '" })', - "focuswindow address:" + address) + if (!address) { + DockModel.focusWindow(toplevel) + return } - if (workspace && Hyprland.focusedWorkspace && workspace.id !== Hyprland.focusedWorkspace.id) { - var targetWs = root.workspaceTarget(workspace) - if (targetWs) { - root.hyprDispatch('hl.dsp.workspace({ name = "' + root.luaString(targetWs) + '" })', - "workspace " + targetWs) - } - } + root.hyprDispatch('hl.dsp.focus({ window = "address:' + address + '" })', + "focuswindow address:" + address) } function minimizeToplevel(toplevel) { @@ -1103,9 +1085,9 @@ Item { var aid = DockModel.normalizeId(toplevel.appId) if (aid) { - var minMap = root.appLastMinimizedWindow - minMap[aid] = address - root.appLastMinimizedWindow = minMap + var parked = DockModel.copyMap(root.appParkedWindow) + parked[aid] = address + root.appParkedWindow = parked } root.hyprDispatch( @@ -1128,10 +1110,10 @@ Item { if (handle && handle.wayland) { var aid = DockModel.normalizeId(handle.wayland.appId) - if (aid && root.appLastMinimizedWindow[aid] === address) { - var minMap = root.appLastMinimizedWindow - delete minMap[aid] - root.appLastMinimizedWindow = minMap + if (aid && root.appParkedWindow[aid] === address) { + var parked = DockModel.copyMap(root.appParkedWindow) + delete parked[aid] + root.appParkedWindow = parked } } @@ -1144,6 +1126,25 @@ Item { 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 + } + + function recentWindow(appId, windows) { + return root.windowByAddress(windows, root.appRecentWindow[appId]) + } + function minimizeApp(entry) { if (!entry) return false var windows = entry.windowList || [] @@ -1164,22 +1165,7 @@ Item { } return anyMin } else { - var targetWin = null - - // Check remembered last active window for this app - var remembered = root.appLastActiveWindow[entry.appId] - if (remembered) { - for (var k = 0; k < windows.length; k++) { - if (windows[k] && windows[k].toplevel === remembered) { - var rhandle = root.hyprToplevelFor(remembered) - var rws = rhandle ? rhandle.workspace : null - if (rws && rws.name !== root.minimizedWorkspace) { - targetWin = windows[k] - break - } - } - } - } + var targetWin = root.recentWindow(entry.appId, windows) if (!targetWin) { for (var j = 0; j < windows.length; j++) { @@ -1208,12 +1194,9 @@ Item { } } - // Drop origins for windows that are gone, so the map cannot grow forever. - function pruneMinimized() { - var origins = root.minimizedOrigins - var addresses = Object.keys(origins) - if (addresses.length === 0) return - + // 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++) { @@ -1221,13 +1204,24 @@ Item { if (address) live[address] = true } + root.minimizedOrigins = root.keepLive(root.minimizedOrigins, live, false) + root.appParkedWindow = root.keepLive(root.appParkedWindow, live, true) + 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 j = 0; j < addresses.length; j++) { - if (live[addresses[j]]) next[addresses[j]] = origins[addresses[j]] + 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 } - if (dropped) root.minimizedOrigins = next + return dropped ? next : map } // ------------------------------------------------- launch feedback @@ -1357,56 +1351,34 @@ Item { } } - // If minimizeMode is enabled and the app is active, minimize! - if (root.minimizeMode !== "off" && isAppActive) { - if (root.minimizeApp(entry)) { - root.lastPreDockActiveApp = "" - root.lastPreDockActiveToplevel = null - return - } + // Clicking the app you are already in puts it away. + if (root.minimizeMode !== "off" && isAppActive && root.minimizeApp(entry)) { + root.lastPreDockActiveApp = "" + return } - // If app is NOT active: First check if there is a recently minimized window of this app to restore (LIFO Undo) - var lastMinAddress = root.appLastMinimizedWindow[appId] - if (lastMinAddress) { + // Otherwise the click brings something back: the window this app parked + // last, so a second click undoes a minimize. + var parkedAddress = root.appParkedWindow[appId] + if (parkedAddress) { for (var m = 0; m < windows.length; m++) { - var mHandle = root.hyprToplevelFor(windows[m].toplevel) - if (mHandle && root.windowAddress(mHandle) === lastMinAddress) { - var mWs = mHandle.workspace - if (mWs && mWs.name === root.minimizedWorkspace) { - root.restoreWindow(mHandle) - root.lastPreDockActiveApp = appId - root.lastPreDockActiveToplevel = windows[m].toplevel - return - } - } - } - } - - // Otherwise: bring forward / focus the app (most recent window) - var targetToplevel = null - var remembered = root.appLastActiveWindow[appId] - if (remembered) { - for (var j = 0; j < windows.length; j++) { - if (windows[j].toplevel === remembered) { - var remHandle = root.hyprToplevelFor(remembered) - var remWs = remHandle ? remHandle.workspace : null - if (remWs && remWs.name !== root.minimizedWorkspace) { - targetToplevel = remembered - break - } + var parkedHandle = root.hyprToplevelFor(windows[m].toplevel) + var parkedWs = parkedHandle ? parkedHandle.workspace : null + if (root.windowAddress(parkedHandle) === parkedAddress + && parkedWs && parkedWs.name === root.minimizedWorkspace) { + root.restoreWindow(parkedHandle) + root.lastPreDockActiveApp = appId + return } } } - if (!targetToplevel) { - targetToplevel = DockModel.pickAppWindow( - ToplevelManager.toplevels.values, ToplevelManager.activeToplevel, appId, 1) - } - - root.focusToplevel(targetToplevel) + // Failing that, the window it was last focused in, else wherever the cycle + // order lands. + var target = root.recentWindow(appId, windows) + root.focusToplevel(target ? target.toplevel : DockModel.pickAppWindow( + ToplevelManager.toplevels.values, ToplevelManager.activeToplevel, appId, 1)) root.lastPreDockActiveApp = appId - root.lastPreDockActiveToplevel = targetToplevel } // Menu rows name the workspace a window sits on, including the parked ones. @@ -1506,10 +1478,8 @@ Item { HoverHandler { id: revealHover onHoveredChanged: { - if (revealHover.hovered && ToplevelManager.activeToplevel) { + if (revealHover.hovered && ToplevelManager.activeToplevel) root.lastPreDockActiveApp = DockModel.normalizeId(ToplevelManager.activeToplevel.appId) - root.lastPreDockActiveToplevel = ToplevelManager.activeToplevel - } root.syncVisibility() } } @@ -1572,10 +1542,8 @@ Item { HoverHandler { id: cardHover onHoveredChanged: { - if (cardHover.hovered && ToplevelManager.activeToplevel) { + if (cardHover.hovered && ToplevelManager.activeToplevel) root.lastPreDockActiveApp = DockModel.normalizeId(ToplevelManager.activeToplevel.appId) - root.lastPreDockActiveToplevel = ToplevelManager.activeToplevel - } root.syncVisibility() } } From 895c8cfdc6e3b653f4c1e58f08ad7be6cc370679 Mon Sep 17 00:00:00 2001 From: assada Date: Thu, 20 Aug 2026 19:54:26 +0200 Subject: [PATCH 10/19] style: dock proportions closer to a real dock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not chasing macOS materials — no liquid glass here. This takes the proportions and the reading order that a dock of that shape gets right. - The icon nearly fills its slot (iconSize - 4px instead of - 10px). With the hover plate gone there is nothing for the old inset to make room for, and the art is what the eye is looking for. - One round dot per running app. The second dot for "more than one window" was unreadable — two windows and seven looked identical — and the tooltip already lists them by name. - The card's corner radius tracks its own height for the "rounded" shape, so the panel keeps the same softness whether icons are 28px or 52px, instead of a fixed 14px that reads sharp at large sizes. Padding up one step, border a shade lighter. The nested ternary that computed the radius in three places is now one cardRadius() function. - The card casts a soft shadow, blurred in its own oversized layer because a blur inside the card's bounds gets clipped at the edge. It is bound to the card's own opacity so it leaves with it, and skipped entirely when the background is transparent, where a shadow with nothing above it looks wrong. Deliberately not done: neighbour falloff on hover, where icons either side of the pointer scale down with distance. That one needs the row to grow its spacing as it magnifies, otherwise neighbours overlap — a layout change rather than a visual one, and worth its own pass. --- Dock.qml | 65 +++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 43 insertions(+), 22 deletions(-) diff --git a/Dock.qml b/Dock.qml index 96ea8dc..a2a9e75 100644 --- a/Dock.qml +++ b/Dock.qml @@ -2,6 +2,7 @@ // management, workspace hints, and intelligent scale-aware autohide. import QtQuick +import QtQuick.Effects import Quickshell import Quickshell.Io import Quickshell.Wayland @@ -196,7 +197,7 @@ Item { Image { id: iconImg anchors.centerIn: parent - width: root.iconSize - Style.space(10) + width: root.iconSize - Style.space(4) height: width source: item.icon !== "" ? item.icon : Quickshell.iconPath("application-x-executable", true) sourceSize: Qt.size(width * Screen.devicePixelRatio, height * Screen.devicePixelRatio) @@ -230,24 +231,12 @@ Item { z: 2 Rectangle { - width: (item.active || item.urgent) ? Style.space(7) : Style.space(4) - height: (item.active || item.urgent) ? Style.space(3) : Style.space(2) - radius: height / 2 - color: item.urgent - ? Color.urgent - : (item.active ? Color.bar.active : Util.alpha(root.dockForeground, item.minimized ? 0.28 : 0.6)) - opacity: item.urgent ? (0.4 + 0.6 * item.pulse) : 1.0 - } - - // Secondary dot for multiple open windows - Rectangle { - visible: item.windows > 1 - width: Style.space(3) - height: Style.space(2) - radius: height / 2 + width: Style.space(4) + height: width + radius: width / 2 color: item.urgent ? Color.urgent - : (item.active ? Color.bar.active : Util.alpha(root.dockForeground, item.minimized ? 0.22 : 0.45)) + : (item.active ? Color.bar.active : Util.alpha(root.dockForeground, item.minimized ? 0.3 : 0.65)) opacity: item.urgent ? (0.4 + 0.6 * item.pulse) : 1.0 } } @@ -559,6 +548,16 @@ Item { 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. + function cardRadius(height) { + if (root.dockShape === "round" || root.dockShape === "pill") return Math.round(height / 2) + if (root.dockShape === "square") return 0 + if (root.dockShape === "theme" || root.dockShape === "auto") + return Style.cornerRadius > 0 ? Style.cornerRadius : Math.max(14, Style.space(14)) + return Math.max(Style.space(14), Math.min(Style.space(28), Math.round(height * 0.26))) + } + readonly property color dockForeground: { var custom = String(root.dockBgColor || "") if (custom.charAt(0) !== "#") return Color.bar.text @@ -1522,6 +1521,30 @@ Item { // ------------------------------------------------------------ dock card + Item { + id: cardShadow + visible: root.dockBgColor !== "none" && root.dockOpacity > 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 @@ -1532,11 +1555,9 @@ Item { } color: Util.alpha(effectiveBgColor, root.dockOpacity) - borderSpec: Border.flat(Util.alpha(root.dockForeground, Math.max(0.28, root.dockOpacity * 0.4)), 1) - radius: (root.dockShape === "round" || root.dockShape === "pill") - ? Math.round(height / 2) - : (root.dockShape === "square" ? 0 : ((root.dockShape === "theme" || root.dockShape === "auto") ? (Style.cornerRadius > 0 ? Style.cornerRadius : Math.max(14, Style.space(14))) : Math.max(14, Style.space(14)))) - padding: Style.space(4) + borderSpec: Border.flat(Util.alpha(root.dockForeground, Math.max(0.22, root.dockOpacity * 0.32)), 1) + radius: root.cardRadius(height) + padding: Style.space(5) z: 1 HoverHandler { From 9f560d2d28158df7e3f702c5a24402dd75559fff Mon Sep 17 00:00:00 2001 From: assada Date: Thu, 20 Aug 2026 20:04:45 +0200 Subject: [PATCH 11/19] feat: dock magnification with a proper falloff curve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hover used to be a binary 1.22x on the icon under the pointer. Now the whole neighbourhood responds, which is what makes a dock feel like one. The curve is the raised cosine Juan Pablo Zamora derived for this effect: size = min + ((1 - cos t) / 2) * (max - min) t = ((iconX - (cursorX - effectWidth / 2)) / effectWidth) * 2*pi, capped to [0, 2pi] which is 0.5 * (1 + cos(pi * d / R)) for a distance d and half-range R. It is 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 — the two artefacts a linear ramp is known for, and the reason his first attempt looked shaky. Measured against a linear ramp of the same range: identical slope at the peak, 24x gentler at the outer edge. Distances are taken from each slot's *unmagnified* centre, which the article is explicit about and which is the load-bearing detail. Slot widths never change, so growing an icon cannot move itself and alter its own distance to the cursor. Nothing can oscillate. The dock keeps its width; icons lean away from the pointer to make room. That matches how the widely copied dock recipes model it (fixed container, icons displaced, rather than a container that grows), and it avoids the same feedback problem from the other direction: a card that widens on hover slides its own contents under the cursor. - effect spans ~2.2 slots either side, peak 1.45x - icons scale from their bottom edge, so they grow up out of the dock and never cover the running dot underneath - the dot travels sideways with its icon but is not scaled or lifted - the tooltip clears the magnified icon - the apps button rides the same curve - 70ms easing: long enough to smooth the gaps between motion events, short enough that the icons still track the pointer Sources: - https://juankproblog.wordpress.com/2011/02/02/the-magnifying-effect-in-the-mac-os-x-dock/ - https://buildui.com/recipes/magnified-dock --- Dock.qml | 75 ++++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 65 insertions(+), 10 deletions(-) diff --git a/Dock.qml b/Dock.qml index a2a9e75..733fc6d 100644 --- a/Dock.qml +++ b/Dock.qml @@ -95,7 +95,10 @@ Item { property bool _dragJustEnded: false property real dragStartX: 0 property real bounceY: 0 - readonly property bool isHovered: area.containsMouse && !item.isDragging + readonly property real slotCenter: row.x + item.x + item.width / 2 + readonly property real magnify: root.magnifyAt(item.slotCenter) + readonly property real magnifyShift: root.magnifyShiftAt(item.slotCenter, item.magnify) + readonly property real magnifyLift: (root.magnifyPeak - 1) * item.magnify * item.height // Live window state, read straight off the Hyprland handles carried in the // model, so urgency and workspace moves land without a model rebuild. @@ -184,14 +187,19 @@ Item { anchors.fill: parent anchors.bottomMargin: item.running ? Style.space(5) : 0 - scale: (root.magnification && item.isHovered ? 1.22 : 1.0) * (area.pressed ? 0.92 : 1.0) - y: root.magnification && item.isHovered ? -Style.space(6) : 0 + // Anchored at the bottom, so an icon grows up out of the dock instead of + // spilling over the running dot underneath it. + transformOrigin: Item.Bottom + scale: (1 + (root.magnifyPeak - 1) * item.magnify) * (area.pressed ? 0.92 : 1.0) - Behavior on scale { NumberAnimation { duration: 130; easing.type: Easing.OutQuad } } - Behavior on y { NumberAnimation { duration: 130; easing.type: Easing.OutQuad } } + // Short enough to track the pointer, long enough to smooth the steps + // between motion events and to ramp in and out at the dock's edges. + Behavior on scale { NumberAnimation { duration: 70; easing.type: Easing.OutQuad } } transform: Translate { + x: item.magnifyShift y: item.bounceY + Behavior on x { NumberAnimation { duration: 70; easing.type: Easing.OutQuad } } } Image { @@ -230,6 +238,11 @@ Item { visible: item.running z: 2 + transform: Translate { + x: item.magnifyShift + Behavior on x { NumberAnimation { duration: 70; easing.type: Easing.OutQuad } } + } + Rectangle { width: Style.space(4) height: width @@ -322,7 +335,7 @@ Item { radius: Style.cornerRadius > 0 ? Style.cornerRadius : 8 padding: Style.space(6) x: (item.width - width) / 2 - y: -height - Style.space(10) + y: -height - Style.space(10) - item.magnifyLift width: tooltipContent.implicitWidth + contentLeftInset + contentRightInset height: tooltipContent.implicitHeight + contentTopInset + contentBottomInset @@ -399,16 +412,24 @@ Item { width: root.iconSlot height: root.iconSlot + readonly property real slotCenter: row.x + btn.x + btn.width / 2 + readonly property real magnify: root.magnifyAt(btn.slotCenter) + readonly property real magnifyShift: root.magnifyShiftAt(btn.slotCenter, btn.magnify) + Text { anchors.centerIn: parent text: btn.glyph font.family: "omarchy" font.pixelSize: btn.glyphSize color: btn.glyphColor - scale: (root.magnification && area.containsMouse ? 1.22 : 1.0) * (area.pressed ? 0.92 : 1.0) - y: root.magnification && area.containsMouse ? -Style.space(6) : 0 - Behavior on scale { NumberAnimation { duration: 130; easing.type: Easing.OutQuad } } - Behavior on y { NumberAnimation { duration: 130; easing.type: Easing.OutQuad } } + transformOrigin: Item.Bottom + scale: (1 + (root.magnifyPeak - 1) * btn.magnify) * (area.pressed ? 0.92 : 1.0) + Behavior on scale { NumberAnimation { duration: 70; easing.type: Easing.OutQuad } } + + transform: Translate { + x: btn.magnifyShift + Behavior on x { NumberAnimation { duration: 70; easing.type: Easing.OutQuad } } + } } MouseArea { @@ -539,6 +560,40 @@ Item { readonly property var appLibrary: shell ? shell.appLibrary : null + // ------------------------------------------------- magnification + + // Raised cosine falloff, the curve Juan Pablo Zamora worked out for this + // effect: size = min + ((1 - cos t) / 2) * (max - min), where t walks an + // effectWidth-wide window centred on the cursor. Smooth at the peak and at + // both edges, so icons neither snap at the apex nor pop where the effect + // starts — the two artefacts a linear ramp is known for. + // + // The distances are measured against each slot's *unmagnified* centre. That + // is the whole trick: the layout never moves, so growing an icon cannot + // change its own distance to the cursor and set off a feedback wobble. The + // dock keeps its width and icons are nudged apart to make room instead. + readonly property real pointerX: cardHover.hovered ? cardHover.point.position.x : -1e6 + readonly property real magnifyPeak: 1.45 + readonly property real magnifyRange: root.iconSlot * 2.2 + // Enough spread that the art of neighbouring icons keeps breathing room from + // the magnified one. Icon boxes still overlap slightly at this value, but the + // art sits ~7px inside its box, so what you see stays apart. Raise it to + // ~1.4 slots for boxes that never touch, at the cost of a wider wave. + readonly property real magnifyNudge: root.iconSlot + + function magnifyAt(slotCenter) { + if (!root.magnification) return 0 + var distance = root.pointerX - slotCenter + if (Math.abs(distance) >= root.magnifyRange) return 0 + return 0.5 * (1 + Math.cos(Math.PI * distance / root.magnifyRange)) + } + + // Icons lean away from the cursor so the magnified one has room to grow into. + function magnifyShiftAt(slotCenter, falloff) { + if (falloff <= 0) return 0 + return -((root.pointerX - slotCenter) / root.magnifyRange) * root.magnifyNudge * falloff + } + // ------------------------------------------------- contrast // The bar foreground is tuned for the bar's own background. A custom dock From c5afbbdda7bc74d0a8885d7515df326f01f790f6 Mon Sep 17 00:00:00 2001 From: assada Date: Thu, 20 Aug 2026 20:08:58 +0200 Subject: [PATCH 12/19] Revert "feat: dock magnification with a proper falloff curve" This reverts commit 9f560d2d28158df7e3f702c5a24402dd75559fff. --- Dock.qml | 75 ++++++++------------------------------------------------ 1 file changed, 10 insertions(+), 65 deletions(-) diff --git a/Dock.qml b/Dock.qml index 733fc6d..a2a9e75 100644 --- a/Dock.qml +++ b/Dock.qml @@ -95,10 +95,7 @@ Item { property bool _dragJustEnded: false property real dragStartX: 0 property real bounceY: 0 - readonly property real slotCenter: row.x + item.x + item.width / 2 - readonly property real magnify: root.magnifyAt(item.slotCenter) - readonly property real magnifyShift: root.magnifyShiftAt(item.slotCenter, item.magnify) - readonly property real magnifyLift: (root.magnifyPeak - 1) * item.magnify * item.height + readonly property bool isHovered: area.containsMouse && !item.isDragging // Live window state, read straight off the Hyprland handles carried in the // model, so urgency and workspace moves land without a model rebuild. @@ -187,19 +184,14 @@ Item { anchors.fill: parent anchors.bottomMargin: item.running ? Style.space(5) : 0 - // Anchored at the bottom, so an icon grows up out of the dock instead of - // spilling over the running dot underneath it. - transformOrigin: Item.Bottom - scale: (1 + (root.magnifyPeak - 1) * item.magnify) * (area.pressed ? 0.92 : 1.0) + scale: (root.magnification && item.isHovered ? 1.22 : 1.0) * (area.pressed ? 0.92 : 1.0) + y: root.magnification && item.isHovered ? -Style.space(6) : 0 - // Short enough to track the pointer, long enough to smooth the steps - // between motion events and to ramp in and out at the dock's edges. - Behavior on scale { NumberAnimation { duration: 70; easing.type: Easing.OutQuad } } + Behavior on scale { NumberAnimation { duration: 130; easing.type: Easing.OutQuad } } + Behavior on y { NumberAnimation { duration: 130; easing.type: Easing.OutQuad } } transform: Translate { - x: item.magnifyShift y: item.bounceY - Behavior on x { NumberAnimation { duration: 70; easing.type: Easing.OutQuad } } } Image { @@ -238,11 +230,6 @@ Item { visible: item.running z: 2 - transform: Translate { - x: item.magnifyShift - Behavior on x { NumberAnimation { duration: 70; easing.type: Easing.OutQuad } } - } - Rectangle { width: Style.space(4) height: width @@ -335,7 +322,7 @@ Item { radius: Style.cornerRadius > 0 ? Style.cornerRadius : 8 padding: Style.space(6) x: (item.width - width) / 2 - y: -height - Style.space(10) - item.magnifyLift + y: -height - Style.space(10) width: tooltipContent.implicitWidth + contentLeftInset + contentRightInset height: tooltipContent.implicitHeight + contentTopInset + contentBottomInset @@ -412,24 +399,16 @@ Item { width: root.iconSlot height: root.iconSlot - readonly property real slotCenter: row.x + btn.x + btn.width / 2 - readonly property real magnify: root.magnifyAt(btn.slotCenter) - readonly property real magnifyShift: root.magnifyShiftAt(btn.slotCenter, btn.magnify) - Text { anchors.centerIn: parent text: btn.glyph font.family: "omarchy" font.pixelSize: btn.glyphSize color: btn.glyphColor - transformOrigin: Item.Bottom - scale: (1 + (root.magnifyPeak - 1) * btn.magnify) * (area.pressed ? 0.92 : 1.0) - Behavior on scale { NumberAnimation { duration: 70; easing.type: Easing.OutQuad } } - - transform: Translate { - x: btn.magnifyShift - Behavior on x { NumberAnimation { duration: 70; easing.type: Easing.OutQuad } } - } + scale: (root.magnification && area.containsMouse ? 1.22 : 1.0) * (area.pressed ? 0.92 : 1.0) + y: root.magnification && area.containsMouse ? -Style.space(6) : 0 + Behavior on scale { NumberAnimation { duration: 130; easing.type: Easing.OutQuad } } + Behavior on y { NumberAnimation { duration: 130; easing.type: Easing.OutQuad } } } MouseArea { @@ -560,40 +539,6 @@ Item { readonly property var appLibrary: shell ? shell.appLibrary : null - // ------------------------------------------------- magnification - - // Raised cosine falloff, the curve Juan Pablo Zamora worked out for this - // effect: size = min + ((1 - cos t) / 2) * (max - min), where t walks an - // effectWidth-wide window centred on the cursor. Smooth at the peak and at - // both edges, so icons neither snap at the apex nor pop where the effect - // starts — the two artefacts a linear ramp is known for. - // - // The distances are measured against each slot's *unmagnified* centre. That - // is the whole trick: the layout never moves, so growing an icon cannot - // change its own distance to the cursor and set off a feedback wobble. The - // dock keeps its width and icons are nudged apart to make room instead. - readonly property real pointerX: cardHover.hovered ? cardHover.point.position.x : -1e6 - readonly property real magnifyPeak: 1.45 - readonly property real magnifyRange: root.iconSlot * 2.2 - // Enough spread that the art of neighbouring icons keeps breathing room from - // the magnified one. Icon boxes still overlap slightly at this value, but the - // art sits ~7px inside its box, so what you see stays apart. Raise it to - // ~1.4 slots for boxes that never touch, at the cost of a wider wave. - readonly property real magnifyNudge: root.iconSlot - - function magnifyAt(slotCenter) { - if (!root.magnification) return 0 - var distance = root.pointerX - slotCenter - if (Math.abs(distance) >= root.magnifyRange) return 0 - return 0.5 * (1 + Math.cos(Math.PI * distance / root.magnifyRange)) - } - - // Icons lean away from the cursor so the magnified one has room to grow into. - function magnifyShiftAt(slotCenter, falloff) { - if (falloff <= 0) return 0 - return -((root.pointerX - slotCenter) / root.magnifyRange) * root.magnifyNudge * falloff - } - // ------------------------------------------------- contrast // The bar foreground is tuned for the bar's own background. A custom dock From 85eab6423dc2875cb590942b3f7b2a28875091f2 Mon Sep 17 00:00:00 2001 From: assada Date: Thu, 20 Aug 2026 21:26:27 +0200 Subject: [PATCH 13/19] feat: hover growth as a mode, with a dock wave that keeps its gaps even MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hover was a fixed 1.22x on the icon under the pointer. This adds the falloff across neighbours as an option and lets the old behaviour stay the default, because plenty of people prefer it. hoverEffect: "zoom" (default) | "wave" | "off" "zoom" is what the dock shipped with: only the hovered icon grows, the layout does not move. "wave" is the dock effect proper. "off" is no growth at all. One rendering path serves all three. The mode decides what drives the scale factor and whether a slot is allowed to grow; the icon is bottom-anchored in either case, so it rises out of the dock rather than spilling over the running dot, and the character of the animation is the same at either peak (1.22 vs 1.4). The old boolean is gone rather than kept alongside the mode — two spellings of one setting is the bug this codebase already had with clickToMinimize and minimizeMode. An existing magnification:false is read as "off" and the key is dropped on the next write. The wave itself: - Raised cosine falloff, the curve Juan Pablo Zamora derived for this effect: size = min + ((1 - cos t) / 2) * (max - min) across 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 carries the extra width. This 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 — about 30px per side here. A fixed-width card has nowhere to put that, and nudging icons by a hand-picked amount instead leaves a hole next to the pointer while crowding everything further out. Measured on a first attempt at exactly that: the hovered slot's neighbour ended up displaced 15px while the hovered icon grew only 5px, a 10px gap where there should have been none. Letting the row grow is what keeps every gap even. Measured: the card goes 407px to 455px at the peak, against ~55px predicted by the integral. - Distances are measured from each slot's unmagnified home centre, in window coordinates, computed from the base row width and the window width. Nothing magnification changes feeds back into those numbers, so the wave cannot chase itself. - One animated factor drives both the slot width and the art width. Animating the slot alone is what made the first version look instant: the art was bound straight to the scale and snapped while the layout eased around it. Sources: - https://juankproblog.wordpress.com/2011/02/02/the-magnifying-effect-in-the-mac-os-x-dock/ - https://buildui.com/recipes/magnified-dock --- Dock.qml | 189 +++++++++++++++++++++++++++++++++++++++++++++++------- README.md | 2 + 2 files changed, 167 insertions(+), 24 deletions(-) diff --git a/Dock.qml b/Dock.qml index a2a9e75..d87d327 100644 --- a/Dock.qml +++ b/Dock.qml @@ -88,14 +88,25 @@ Item { 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 - readonly property bool isHovered: area.containsMouse && !item.isDragging + 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. @@ -184,20 +195,22 @@ Item { anchors.fill: parent anchors.bottomMargin: item.running ? Style.space(5) : 0 - scale: (root.magnification && item.isHovered ? 1.22 : 1.0) * (area.pressed ? 0.92 : 1.0) - y: root.magnification && item.isHovered ? -Style.space(6) : 0 - - Behavior on scale { NumberAnimation { duration: 130; easing.type: Easing.OutQuad } } - Behavior on y { NumberAnimation { duration: 130; easing.type: Easing.OutQuad } } + 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(4) + 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) @@ -396,19 +409,29 @@ Item { signal pressed() signal menuRequested(real x, real y) - width: 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 + } + + Behavior on magnifyScale { + NumberAnimation { duration: 110; easing.type: Easing.OutQuad } + } + + width: root.iconSlot * (root.waveHover ? btn.magnifyScale : 1) height: root.iconSlot Text { - anchors.centerIn: parent + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: parent.verticalCenter text: btn.glyph font.family: "omarchy" font.pixelSize: btn.glyphSize color: btn.glyphColor - scale: (root.magnification && area.containsMouse ? 1.22 : 1.0) * (area.pressed ? 0.92 : 1.0) - y: root.magnification && area.containsMouse ? -Style.space(6) : 0 - Behavior on scale { NumberAnimation { duration: 130; easing.type: Easing.OutQuad } } - Behavior on y { NumberAnimation { duration: 130; easing.type: Easing.OutQuad } } + transformOrigin: Item.Bottom + scale: btn.magnifyScale * (area.pressed ? 0.92 : 1.0) } MouseArea { @@ -539,6 +562,73 @@ 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 @@ -643,7 +733,12 @@ Item { property bool intelligentAutohide: true property bool showAppsButton: true property bool showTooltips: true - property bool magnification: 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 @@ -922,7 +1017,10 @@ Item { root.intelligentAutohide = parsed && parsed.intelligentAutohide !== false root.showAppsButton = parsed && parsed.showAppsButton !== false root.showTooltips = parsed && parsed.showTooltips !== false - root.magnification = parsed && parsed.magnification !== 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 : "" @@ -987,6 +1085,11 @@ Item { root.saveConfig() } + function setHoverEffect(mode) { + root.hoverEffect = mode + root.saveConfig() + } + function setDockShape(shape) { root.dockShape = shape root.saveConfig() @@ -1288,7 +1391,8 @@ Item { conf.intelligentAutohide = root.intelligentAutohide conf.showAppsButton = root.showAppsButton conf.showTooltips = root.showTooltips - conf.magnification = root.magnification + conf.hoverEffect = root.hoverEffect + delete conf.magnification conf.launchBounce = root.launchBounce conf.advancedTooltips = root.advancedTooltips if (root.screenName) conf.screen = root.screenName @@ -1617,6 +1721,7 @@ Item { DockIconButton { visible: root.showAppsButton + homeCenter: root.slotHomeCenter(0, 0, false) glyph: "\ue900" tooltip: "Apps" onPressed: root.toggleAppsMenu() @@ -1635,6 +1740,7 @@ 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) } @@ -1703,6 +1809,10 @@ 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) } @@ -1886,12 +1996,8 @@ Item { } ContextRow { - text: "Magnification (Zoom)" - checked: root.magnification - onTriggered: { - root.magnification = !root.magnification - root.saveConfig() - } + text: "Hover: " + (root.hoverEffect === "wave" ? "Wave" : (root.hoverEffect === "off" ? "None" : "Zoom")) + " ›" + onTriggered: root.settingsSubmenu = "hover" } ContextRow { @@ -1922,6 +2028,41 @@ Item { } } + // Hover Effect Page + Column { + spacing: Style.space(1) + 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) diff --git a/README.md b/README.md index 34243d5..caa798f 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,7 @@ All settings can be toggled interactively via the right-click menu or configured "itemSpacing": 4, "screen": "", "iconSize": 36, + "hoverEffect": "zoom", "clickToMinimize": false, "showUrgentHint": true, "revealDelay": 160, @@ -144,6 +145,7 @@ All settings can be toggled interactively via the right-click menu or configured | `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 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. | From e1da6a7da86377262be0bd0b2a0bcb50363c0d18 Mon Sep 17 00:00:00 2001 From: assada Date: Thu, 20 Aug 2026 21:26:27 +0200 Subject: [PATCH 14/19] fix: a launching icon fades as well as bounces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fade was written as the fallback for when the bounce was switched off, so turning the bounce on took it away. They belong together: the bounce says a click landed, the breathing says the app is still coming up. Kept shallower than the urgency pulse — opacity 1.0 to 0.61 against urgency's 1.0 to 0.35 — so "starting" does not read as "look at me". The two cycles are deliberately different lengths (740ms bounce, 650ms breath) rather than locked together. --- Dock.qml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Dock.qml b/Dock.qml index d87d327..88b1697 100644 --- a/Dock.qml +++ b/Dock.qml @@ -160,10 +160,10 @@ Item { return item.name } - // The pulse carries urgency. A cold start bounces instead, and only falls - // back to fading when the bounce is off. + // 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 && !root.launchBounce) + readonly property bool pulsing: item.urgent || item.starting onPulsingChanged: if (!item.pulsing) item.pulse = 1.0 SequentialAnimation on pulse { @@ -215,7 +215,7 @@ Item { source: item.icon !== "" ? item.icon : Quickshell.iconPath("application-x-executable", true) sourceSize: Qt.size(width * Screen.devicePixelRatio, height * Screen.devicePixelRatio) visible: source !== "" - opacity: item.pulsing && item.starting ? item.pulse : 1.0 + opacity: item.starting ? (0.4 + 0.6 * item.pulse) : 1.0 mipmap: true smooth: true From f209883cfa181442b8e239330cdd8a3a7adc946a Mon Sep 17 00:00:00 2001 From: assada Date: Thu, 20 Aug 2026 21:32:54 +0200 Subject: [PATCH 15/19] fix: read window focus live instead of from a stale snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildEntries copied each window's `activated` flag into the model. The model is only rebuilt when a window opens or closes, so that copy froze on whoever held focus at that moment and never moved again. Visible as the tooltip marking the wrong window: with three terminal windows open, the accent dot sat on a window on another workspace while the focused one read as inactive. The flag is gone from the model. Focus is a single fact the manager already holds, so the four places that needed it now compare against ToplevelManager.activeToplevel directly — the tooltip's two colours, the minimize target, and the is-this-app-active test in activate(). All of them are live and cannot drift apart. pickAppWindow keeps reading `.activated`, which is correct there: it walks the manager's own toplevels, which are live objects rather than model rows. --- Dock.qml | 16 ++++++++++++---- DockModel.js | 1 - 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/Dock.qml b/Dock.qml index 88b1697..06d7369 100644 --- a/Dock.qml +++ b/Dock.qml @@ -152,6 +152,13 @@ Item { 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…]" @@ -379,7 +386,7 @@ Item { height: Style.space(4) radius: width / 2 anchors.verticalCenter: parent.verticalCenter - color: (item.windowList[index] && item.windowList[index].activated) ? Color.bar.active : Util.alpha(Color.tooltip.text, 0.4) + color: item.windowFocused(item.windowList[index]) ? Color.bar.active : Util.alpha(Color.tooltip.text, 0.4) } Text { text: { @@ -387,7 +394,7 @@ Item { var t = w ? root.windowRowLabel(w) : "" return t.length > 30 ? t.slice(0, 28) + "…" : t } - color: (item.windowList[index] && item.windowList[index].activated) ? Color.tooltip.text : Util.alpha(Color.tooltip.text, 0.75) + 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 @@ -395,6 +402,7 @@ Item { } } } + } } } @@ -1271,7 +1279,7 @@ Item { if (!targetWin) { for (var j = 0; j < windows.length; j++) { - if (windows[j] && windows[j].activated) { + if (windows[j] && windows[j].toplevel === ToplevelManager.activeToplevel) { targetWin = windows[j] break } @@ -1448,7 +1456,7 @@ Item { (root.lastPreDockActiveApp !== "" && DockModel.isAppMatch(appId, root.lastPreDockActiveApp)) for (var w = 0; w < windows.length; w++) { - if (windows[w] && (windows[w].activated || (ToplevelManager.activeToplevel && windows[w].toplevel === ToplevelManager.activeToplevel))) { + if (windows[w] && windows[w].toplevel === ToplevelManager.activeToplevel) { isAppActive = true break } diff --git a/DockModel.js b/DockModel.js index 55bb971..79e1e6f 100644 --- a/DockModel.js +++ b/DockModel.js @@ -252,7 +252,6 @@ function buildEntries(pinnedIds, toplevels, appRows, appLibrary, hyprFor) { 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 From d4046489436664c854d98a3ea67a29454f4dfaee Mon Sep 17 00:00:00 2001 From: assada Date: Thu, 20 Aug 2026 21:33:04 +0200 Subject: [PATCH 16/19] style: running dots count the windows again The upstream indicator carried two facts: a pill that grew and took the accent colour when the app held focus, plus a second, smaller pill when the app had more than one window. Reducing it to one round dot dropped both, which left no way to tell a single window from four. Restoring the idea, minus the part that was not true: the old pair said "one or many", never how many. Two windows and nine drew identically. Now one dot per window, and the third widens into a pill once there are more than three, because past three nobody counts dots at 4px. Colour carries state (urgent, focused, parked), size carries focus (4px to 5px), which is the language the dock already spoke. The tooltip stops hiding windows too: it listed at most three with no hint that more existed, so it now shows four and says "+N more" for the rest. --- Dock.qml | 59 +++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 50 insertions(+), 9 deletions(-) diff --git a/Dock.qml b/Dock.qml index 06d7369..3318836 100644 --- a/Dock.qml +++ b/Dock.qml @@ -240,7 +240,19 @@ Item { } } - // 2. Running Indicator Dots: Fixed at slot bottom, never scaled or pushed out of dock + // 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: parent.horizontalCenter @@ -251,13 +263,33 @@ Item { z: 2 Rectangle { - width: Style.space(4) - height: width - radius: width / 2 - color: item.urgent - ? Color.urgent - : (item.active ? Color.bar.active : Util.alpha(root.dockForeground, item.minimized ? 0.3 : 0.65)) - opacity: item.urgent ? (0.4 + 0.6 * item.pulse) : 1.0 + width: item.dotSize + height: item.dotSize + radius: height / 2 + color: item.indicatorColor + opacity: item.indicatorOpacity + Behavior on width { NumberAnimation { duration: 120 } } + } + + Rectangle { + visible: item.windows > 1 + width: item.dotSize + height: item.dotSize + radius: height / 2 + color: item.indicatorColor + opacity: item.indicatorOpacity + Behavior on width { NumberAnimation { duration: 120 } } + } + + // 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 } } } } @@ -377,7 +409,8 @@ Item { } Repeater { - model: (root.advancedTooltips && item.windowList && item.windowList.length > 0) ? Math.min(item.windowList.length, 3) : 0 + 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 @@ -403,6 +436,14 @@ Item { } } + 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) + } } } } From 012b1f06001ecf86e643d2d7482394910dfd56df Mon Sep 17 00:00:00 2001 From: assada Date: Thu, 20 Aug 2026 22:35:16 +0200 Subject: [PATCH 17/19] fix: rebuild what a dock click means as one decision list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The minimise-on-click behaviour that arrived with PR #2 had no rule for which window a click meant, and three defects fell out of that one gap. appParkedWindow held a single address per app, so parking a second window overwrote the first and restore could never find it again — four terminal windows with one parked, and that one had no way home through the icon at all. Parking ran before restoring, and parking one of several windows hands focus straight to a sibling, so the app never stopped being active: every further click parked another and nothing came back until all of them were gone. Putting restore first only moved the problem — the icon became a toggle on one window and a second could never be put away. The is-this-app-active test also consulted a remembered "app that held focus before the pointer reached the dock", a value that outlived what it described. So the behaviour is one ordered list now, decided from live state only — which windows exist, which are parked, whether focus is already inside the app. Nothing is remembered, so nothing can 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 rules carry the weight. Preferring a window on the current workspace stops 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, and it is what breaks the deadlock above: with focus bouncing to a sibling, park-first parks forever and restore-first toggles forever, while stepping has no such corner. A specific window can still be parked from the context menu, which grows a "Minimize Window" row for exactly that, or the whole app in one click with mode "all". Both parking cases stay symmetric: "all" parks in one click and restores in one, "active" restores newest first. Supporting sets are small and read straight off the compositor — visibleWindows, parkedWindows, focusedIndex, windowHere, stepWindow — and the wheel steps the same set, so scrolling can no longer wake a parked window. Parked windows are found by the workspace they sit on rather than remembered, which is what makes losing one impossible. --- Dock.qml | 287 ++++++++++++++++++++++++++++++------------------------ README.md | 2 +- 2 files changed, 161 insertions(+), 128 deletions(-) diff --git a/Dock.qml b/Dock.qml index 3318836..24de6bc 100644 --- a/Dock.qml +++ b/Dock.qml @@ -752,9 +752,7 @@ Item { // 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 appParkedWindow: ({}) property var appRecentWindow: ({}) - property string lastPreDockActiveApp: "" // Apps whose launch has been asked for but whose window has not shown up yet. property var launchPending: ({}) @@ -1019,9 +1017,6 @@ Item { recent[aid] = address root.appRecentWindow = recent } - if (!(cardHover && cardHover.hovered) && !(revealHover && revealHover.hovered) && root.contextAppId === "") { - root.lastPreDockActiveApp = aid - } } debounceOverlapTimer.restart() } @@ -1160,6 +1155,13 @@ Item { } function cycleApp(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)) } @@ -1234,13 +1236,6 @@ Item { origins[address] = origin root.minimizedOrigins = origins - var aid = DockModel.normalizeId(toplevel.appId) - if (aid) { - var parked = DockModel.copyMap(root.appParkedWindow) - parked[aid] = address - root.appParkedWindow = parked - } - root.hyprDispatch( 'hl.dsp.window.move({ window = "address:' + address + '", workspace = "' + root.luaString(root.minimizedWorkspace) + '", follow = false })', @@ -1259,15 +1254,6 @@ Item { delete origins[address] root.minimizedOrigins = origins - if (handle && handle.wayland) { - var aid = DockModel.normalizeId(handle.wayland.appId) - if (aid && root.appParkedWindow[aid] === address) { - var parked = DockModel.copyMap(root.appParkedWindow) - delete parked[aid] - root.appParkedWindow = parked - } - } - root.hyprDispatch( 'hl.dsp.window.move({ window = "address:' + address + '", workspace = "' + root.luaString(target) + '", follow = true })', @@ -1292,57 +1278,112 @@ Item { 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 minimizeApp(entry) { - if (!entry) return false - var windows = entry.windowList || [] - if (windows.length === 0) return false + 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 + } - if (root.minimizeMode === "all") { - var anyMin = false - for (var i = 0; i < windows.length; i++) { - var w = windows[i] - if (w && w.toplevel) { - var handle = root.hyprToplevelFor(w.toplevel) - var ws = handle ? handle.workspace : null - if (ws && ws.name !== root.minimizedWorkspace) { - root.minimizeToplevel(w.toplevel) - anyMin = true - } - } - } - return anyMin - } else { - var targetWin = root.recentWindow(entry.appId, windows) + // 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 - if (!targetWin) { - for (var j = 0; j < windows.length; j++) { - if (windows[j] && windows[j].toplevel === ToplevelManager.activeToplevel) { - targetWin = windows[j] - break - } - } + for (var i = 0; i < windows.length; i++) { + if (windows[i] && windows[i].toplevel === ToplevelManager.activeToplevel) { + target = windows[i] + break } - - if (!targetWin) { - for (var m = 0; m < windows.length; m++) { - var mhandle = root.hyprToplevelFor(windows[m].toplevel) - var mws = mhandle ? mhandle.workspace : null - if (mws && mws.name !== root.minimizedWorkspace) { - targetWin = windows[m] - 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 } } - - if (targetWin && targetWin.toplevel) { - return root.minimizeToplevel(targetWin.toplevel) - } - return false } + + 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 @@ -1356,7 +1397,6 @@ Item { } root.minimizedOrigins = root.keepLive(root.minimizedOrigins, live, false) - root.appParkedWindow = root.keepLive(root.appParkedWindow, live, true) root.appRecentWindow = root.keepLive(root.appRecentWindow, live, true) } @@ -1459,78 +1499,70 @@ Item { 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) { + var windows = entry ? (entry.windowList || []) : [] + if (!entry || !entry.running || windows.length === 0) { root.launchApp(appId, entry) return } - var windows = entry.windowList || [] - if (windows.length === 0) { - root.launchApp(appId, entry) - return - } + var visible = root.visibleWindows(windows) + var parked = root.parkedWindows(windows) - // If all windows of this app are currently minimized, clicking restores them - var allMinimized = true - for (var k = 0; k < windows.length; k++) { - var whandle = root.hyprToplevelFor(windows[k].toplevel) - var wws = whandle ? whandle.workspace : null - if (!wws || wws.name !== root.minimizedWorkspace) { - allMinimized = false - break - } - } - if (allMinimized) { - for (var r = 0; r < windows.length; r++) { - var rhandle = root.hyprToplevelFor(windows[r].toplevel) - if (rhandle) root.restoreWindow(rhandle) + 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 } - return - } - - // Check if this app is currently active OR was active before cursor entered dock - var isAppActive = (appId === root.activeId) || - (root.lastPreDockActiveApp !== "" && DockModel.isAppMatch(appId, root.lastPreDockActiveApp)) - for (var w = 0; w < windows.length; w++) { - if (windows[w] && windows[w].toplevel === ToplevelManager.activeToplevel) { - isAppActive = true - break - } + var target = root.windowHere(visible) || root.recentWindow(appId, visible) || visible[0] + if (target) root.focusToplevel(target.toplevel) + return } - // Clicking the app you are already in puts it away. - if (root.minimizeMode !== "off" && isAppActive && root.minimizeApp(entry)) { - root.lastPreDockActiveApp = "" + if (root.minimizeMode === "all") { + root.minimizeAllWindows(entry) return } - // Otherwise the click brings something back: the window this app parked - // last, so a second click undoes a minimize. - var parkedAddress = root.appParkedWindow[appId] - if (parkedAddress) { - for (var m = 0; m < windows.length; m++) { - var parkedHandle = root.hyprToplevelFor(windows[m].toplevel) - var parkedWs = parkedHandle ? parkedHandle.workspace : null - if (root.windowAddress(parkedHandle) === parkedAddress - && parkedWs && parkedWs.name === root.minimizedWorkspace) { - root.restoreWindow(parkedHandle) - root.lastPreDockActiveApp = appId - return - } - } + if (visible.length > 1) { + var next = root.stepWindow(visible, 1) + if (next) root.focusToplevel(next.toplevel) + return } - // Failing that, the window it was last focused in, else wherever the cycle - // order lands. - var target = root.recentWindow(appId, windows) - root.focusToplevel(target ? target.toplevel : DockModel.pickAppWindow( - ToplevelManager.toplevels.values, ToplevelManager.activeToplevel, appId, 1)) - root.lastPreDockActiveApp = appId + if (root.minimizeMode === "active") root.minimizeOneWindow(entry) } // Menu rows name the workspace a window sits on, including the parked ones. @@ -1629,11 +1661,7 @@ Item { HoverHandler { id: revealHover - onHoveredChanged: { - if (revealHover.hovered && ToplevelManager.activeToplevel) - root.lastPreDockActiveApp = DockModel.normalizeId(ToplevelManager.activeToplevel.appId) - root.syncVisibility() - } + onHoveredChanged: root.syncVisibility() } Rectangle { @@ -1715,11 +1743,7 @@ Item { HoverHandler { id: cardHover - onHoveredChanged: { - if (cardHover.hovered && ToplevelManager.activeToplevel) - root.lastPreDockActiveApp = DockModel.normalizeId(ToplevelManager.activeToplevel.appId) - root.syncVisibility() - } + onHoveredChanged: root.syncVisibility() } anchors.horizontalCenter: parent.horizontalCenter @@ -2510,6 +2534,15 @@ Item { } } + ContextRow { + text: "Minimize Window" + visible: root.minimizeMode !== "off" && root.contextWindows > 1 + onTriggered: { + root.minimizeOneWindow(root.entryForId(root.contextAppId)) + root.closeContext() + } + } + ContextRow { text: root.contextPinned ? "Unpin from Dock" : "Pin to Dock" onTriggered: { diff --git a/README.md b/README.md index caa798f..136ebab 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ Right-click the leftmost Omarchy icon to open the native settings menu: - **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. -- **Click Active to Minimize** *(optional)*: Clicking the app you are already in parks its window on a hidden `special:minimized` workspace; clicking the icon again brings it back to where it came from. Multi-window apps keep cycling instead — that stays the more useful answer. Off by default. +- **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. From 6d6e11e1df238377c22b8c19391bf8f5c935d713 Mon Sep 17 00:00:00 2001 From: Taha Can <127539166+NothingManTR@users.noreply.github.com> Date: Fri, 21 Aug 2026 04:27:46 +0300 Subject: [PATCH 18/19] fix(shape): ensure reactive card radius and correct support for 0px square theme radius --- Dock.qml | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/Dock.qml b/Dock.qml index 24de6bc..bc8c787 100644 --- a/Dock.qml +++ b/Dock.qml @@ -689,12 +689,19 @@ Item { // 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. - function cardRadius(height) { - if (root.dockShape === "round" || root.dockShape === "pill") return Math.round(height / 2) + 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") - return Style.cornerRadius > 0 ? Style.cornerRadius : Math.max(14, Style.space(14)) - return Math.max(Style.space(14), Math.min(Style.space(28), Math.round(height * 0.26))) + 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: { From c123f6004019b08594d6350e2c3504a0d378f162 Mon Sep 17 00:00:00 2001 From: Taha Can <127539166+NothingManTR@users.noreply.github.com> Date: Fri, 21 Aug 2026 04:30:57 +0300 Subject: [PATCH 19/19] feat: add Auto (Theme) background opacity mode syncing with active theme alpha --- Dock.qml | 33 ++++++++++++++++++++++++++------- README.md | 6 ++++-- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/Dock.qml b/Dock.qml index bc8c787..98639ad 100644 --- a/Dock.qml +++ b/Dock.qml @@ -796,6 +796,13 @@ Item { 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 @@ -1076,7 +1083,13 @@ Item { 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 @@ -1494,7 +1507,7 @@ Item { 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 @@ -1711,7 +1724,7 @@ Item { Item { id: cardShadow - visible: root.dockBgColor !== "none" && root.dockOpacity > 0.05 + 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 @@ -1742,8 +1755,8 @@ Item { return root.dockBgColor } - color: Util.alpha(effectiveBgColor, root.dockOpacity) - borderSpec: Border.flat(Util.alpha(root.dockForeground, Math.max(0.22, root.dockOpacity * 0.32)), 1) + 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 @@ -2013,7 +2026,7 @@ Item { } 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" } @@ -2392,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 @@ -2418,7 +2437,7 @@ Item { ContextRow { text: "Transparent (0%)" - checked: root.dockOpacity < 0.20 + checked: root.dockOpacity >= 0.0 && root.dockOpacity < 0.20 onTriggered: root.setDockOpacity(0.0) } } diff --git a/README.md b/README.md index 136ebab..e355bf9 100644 --- a/README.md +++ b/README.md @@ -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%)` @@ -137,7 +139,7 @@ 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"`). 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`). |