From af03108af6aa5a1b7d01850e6dd0bbc7015e7312 Mon Sep 17 00:00:00 2001 From: assada Date: Thu, 20 Aug 2026 00:27:25 +0200 Subject: [PATCH] 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": [