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 @@
-
+
@@ -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.
@@ -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": [