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