Skip to content

feat: smart PWA/Flutter app icon matching, magnification, launch bounce & theme radius - #2

Open
NothingManTR wants to merge 20 commits into
thepathless:mainfrom
NothingManTR:feat/smart-app-icons-and-animations
Open

feat: smart PWA/Flutter app icon matching, magnification, launch bounce & theme radius#2
NothingManTR wants to merge 20 commits into
thepathless:mainfrom
NothingManTR:feat/smart-app-icons-and-animations

Conversation

@NothingManTR

@NothingManTR NothingManTR commented Aug 19, 2026

Copy link
Copy Markdown

Summary of Changes

This PR includes both the Smart PWA/Flutter Icon & Animation System and integrates the improvements from PR #1 (Hyprland window focus, middle click, delays & auto-contrast) into a unified, conflict-free package.

🔍 1. Smart App & PWA Icon Matching (DockModel.js & Dock.qml)

  • Added multi-token and reverse-domain parsing (getCandidates, isAppMatch) to accurately resolve:
    • Flutter / non-standard Wayland window classes (e.g. org.localsend.localsend_app -> localsend.desktop).
    • Chromium / Chrome PWAs and web apps (e.g. chrome-x.com__-Default, chrome-youtube.com__-Default -> X.desktop, YouTube.desktop).
  • Added URL domain cross-matching with omarchy-launch-webapp desktop entries.
  • Fixed launching / activation for pinned web apps directly from the dock.

🎨 2. Visuals & Animation Enhancements (Dock.qml)

  • Hover Magnification: Added smooth hover zoom scaling and lift effect.
  • Launch Bounce Feedback: Added independent transform bounce animation when launching applications without Behavior conflicts.
  • Fixed Indicator Dots: Separated running window dots from scaled icon container so they remain cleanly fixed inside the dock slot without overflowing.
  • Window List Preview Tooltips: Added open window titles and active state list inside hover tooltips with workspace tags.

⚙️ 3. Theme Radius & Settings Integration

  • Added Auto (Theme) dock shape option dynamically syncing with Omarchy / Hyprland Style.cornerRadius.
  • Added toggles in the Omadock settings context menu for Magnification (Zoom), Launch Bounce, Window Previews, Urgent Highlights, Click Active to Minimize, and Auto (Theme) Shape.

🪟 4. Hyprland Integration & UX (from PR #1)

  • Focus windows through native Hyprland dispatch so scrolling and multi-workspace layouts follow properly.
  • Middle-click to launch new application window.
  • Configurable dwell delays (revealDelay / tooltipDelay).
  • Automatic contrast calculation for custom dock background colors.
  • Uniform context menu row widths.

assada and others added 18 commits August 20, 2026 00:27
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.
Follow-up on the merge of PR thepathless#2. The features stay; these are the defects
underneath them.

Focus dispatch:

- Dropped hl.dsp.workspace({ name = ... }). It does not exist — hl.dsp.workspace
  is a namespace (hl.dsp.workspace.toggle_special), so Hyprland 0.56 answers
  "attempt to call a table value (field 'workspace')" and the call was a silent
  no-op. Nothing was lost: hl.dsp.focus already switches to the window's
  workspace on its way.
- The Wayland activate request is a fallback again instead of firing on every
  focus. It was landing before the Hyprland dispatch on every click, so each
  click asked for focus twice.
- cycleApp no longer passes a second argument to focusToplevel, which has taken
  one parameter since the noWarp branch was removed.

Remembered windows:

- appLastActiveWindow held live Toplevel objects keyed by app, and was never
  pruned, so a closed window left a dangling handle behind. Both maps now hold
  addresses; addresses cannot dangle, and a stale one is simply dropped on the
  next prune.
- Both maps were written by mutating the object in place and assigning it back
  to the same property. QML compares by reference, sees no change, and emits no
  signal. They go through DockModel.copyMap now.
- lastPreDockActiveToplevel was written in six places and read in none, holding
  a live window handle for nothing. Removed; lastPreDockActiveApp carries what
  is actually used.
- pruneMinimized became pruneWindowState: one pass over the live windows now
  trims every address-keyed map, not just the parked-origin one.

Hyprland's own focusHistoryID would have replaced this bookkeeping entirely, but
Quickshell only refreshes lastIpcObject when a window opens or closes, so the
value is stale the moment focus moves — measured: the focused window kept
reporting focusHistoryID 4 while a background window reported 0.

Tooltips:

- Visibility follows hover declaratively. Driving `shown` only from the
  containsMouse change signal leaves the bubble on screen whenever an item stops
  being hovered without that signal arriving — a model rebuild under the
  pointer, or the card sliding out from under it as the dock reveals. Seen in
  practice: a tooltip left hanging over an icon with the pointer at the screen
  edge.

Hover and click feedback, per review notes:

- The hover frame is gone. An icon grows and lifts on hover and dips on press;
  the plate, its border and the active-app fill were doing the same job three
  times over and the frame bounced along with the icon. Urgency, which does need
  to reach past the icon art, now rings the slot instead.
- The launch bounce is tied to the app actually starting, which is what a dock
  bounce means: opening something that was closed, or middle-clicking a new
  window. Clicking an app that is already running moves focus and no longer
  throws its icon around. The fade stays as the launch cue when the bounce is
  switched off.
- The apps button follows the same rule: it grows, it does not draw a frame.

Co-authored-by: NothingManTR <NothingManTR@users.noreply.github.com>
Not chasing macOS materials — no liquid glass here. This takes the proportions
and the reading order that a dock of that shape gets right.

- The icon nearly fills its slot (iconSize - 4px instead of - 10px). With the
  hover plate gone there is nothing for the old inset to make room for, and the
  art is what the eye is looking for.
- One round dot per running app. The second dot for "more than one window" was
  unreadable — two windows and seven looked identical — and the tooltip already
  lists them by name.
- The card's corner radius tracks its own height for the "rounded" shape, so the
  panel keeps the same softness whether icons are 28px or 52px, instead of a
  fixed 14px that reads sharp at large sizes. Padding up one step, border a
  shade lighter. The nested ternary that computed the radius in three places is
  now one cardRadius() function.
- The card casts a soft shadow, blurred in its own oversized layer because a
  blur inside the card's bounds gets clipped at the edge. It is bound to the
  card's own opacity so it leaves with it, and skipped entirely when the
  background is transparent, where a shadow with nothing above it looks wrong.

Deliberately not done: neighbour falloff on hover, where icons either side of
the pointer scale down with distance. That one needs the row to grow its spacing
as it magnifies, otherwise neighbours overlap — a layout change rather than a
visual one, and worth its own pass.
Hover used to be a binary 1.22x on the icon under the pointer. Now the whole
neighbourhood responds, which is what makes a dock feel like one.

The curve is the raised cosine Juan Pablo Zamora derived for this effect:

  size  = min + ((1 - cos t) / 2) * (max - min)
  t     = ((iconX - (cursorX - effectWidth / 2)) / effectWidth) * 2*pi, capped to [0, 2pi]

which is 0.5 * (1 + cos(pi * d / R)) for a distance d and half-range R. It is
flat at the peak and flat where the effect ends, so icons neither snap at the
apex nor pop into motion at the edge of the range — the two artefacts a linear
ramp is known for, and the reason his first attempt looked shaky. Measured
against a linear ramp of the same range: identical slope at the peak, 24x
gentler at the outer edge.

Distances are taken from each slot's *unmagnified* centre, which the article is
explicit about and which is the load-bearing detail. Slot widths never change,
so growing an icon cannot move itself and alter its own distance to the cursor.
Nothing can oscillate.

The dock keeps its width; icons lean away from the pointer to make room. That
matches how the widely copied dock recipes model it (fixed container, icons
displaced, rather than a container that grows), and it avoids the same feedback
problem from the other direction: a card that widens on hover slides its own
contents under the cursor.

- effect spans ~2.2 slots either side, peak 1.45x
- icons scale from their bottom edge, so they grow up out of the dock and never
  cover the running dot underneath
- the dot travels sideways with its icon but is not scaled or lifted
- the tooltip clears the magnified icon
- the apps button rides the same curve
- 70ms easing: long enough to smooth the gaps between motion events, short
  enough that the icons still track the pointer

Sources:
- https://juankproblog.wordpress.com/2011/02/02/the-magnifying-effect-in-the-mac-os-x-dock/
- https://buildui.com/recipes/magnified-dock
Hover was a fixed 1.22x on the icon under the pointer. This adds the falloff
across neighbours as an option and lets the old behaviour stay the default,
because plenty of people prefer it.

  hoverEffect: "zoom" (default) | "wave" | "off"

"zoom" is what the dock shipped with: only the hovered icon grows, the layout
does not move. "wave" is the dock effect proper. "off" is no growth at all.

One rendering path serves all three. The mode decides what drives the scale
factor and whether a slot is allowed to grow; the icon is bottom-anchored in
either case, so it rises out of the dock rather than spilling over the running
dot, and the character of the animation is the same at either peak (1.22 vs
1.4).

The old boolean is gone rather than kept alongside the mode — two spellings of
one setting is the bug this codebase already had with clickToMinimize and
minimizeMode. An existing magnification:false is read as "off" and the key is
dropped on the next write.

The wave itself:

- Raised cosine falloff, the curve Juan Pablo Zamora derived for this effect:
  size = min + ((1 - cos t) / 2) * (max - min) across an effectWidth-wide window
  centred on the cursor, which is 0.5 * (1 + cos(pi * d / R)) for a distance d
  and half-range R. Flat at the peak and flat where the effect ends, so icons
  neither snap at the apex nor pop into motion at the edge of the range.
- Slots grow and the row carries the extra width. This is not a stylistic
  choice. The displacement an icon needs is the accumulated growth between it
  and the cursor, which integrates to (peak - 1) * R / 2 — about 30px per side
  here. A fixed-width card has nowhere to put that, and nudging icons by a
  hand-picked amount instead leaves a hole next to the pointer while crowding
  everything further out. Measured on a first attempt at exactly that: the
  hovered slot's neighbour ended up displaced 15px while the hovered icon grew
  only 5px, a 10px gap where there should have been none. Letting the row grow
  is what keeps every gap even. Measured: the card goes 407px to 455px at the
  peak, against ~55px predicted by the integral.
- Distances are measured from each slot's unmagnified home centre, in window
  coordinates, computed from the base row width and the window width. Nothing
  magnification changes feeds back into those numbers, so the wave cannot chase
  itself.
- One animated factor drives both the slot width and the art width. Animating
  the slot alone is what made the first version look instant: the art was bound
  straight to the scale and snapped while the layout eased around it.

Sources:
- https://juankproblog.wordpress.com/2011/02/02/the-magnifying-effect-in-the-mac-os-x-dock/
- https://buildui.com/recipes/magnified-dock
The fade was written as the fallback for when the bounce was switched off, so
turning the bounce on took it away. They belong together: the bounce says a
click landed, the breathing says the app is still coming up.

Kept shallower than the urgency pulse — opacity 1.0 to 0.61 against urgency's
1.0 to 0.35 — so "starting" does not read as "look at me". The two cycles are
deliberately different lengths (740ms bounce, 650ms breath) rather than locked
together.
buildEntries copied each window's `activated` flag into the model. The model is
only rebuilt when a window opens or closes, so that copy froze on whoever held
focus at that moment and never moved again.

Visible as the tooltip marking the wrong window: with three terminal windows
open, the accent dot sat on a window on another workspace while the focused one
read as inactive.

The flag is gone from the model. Focus is a single fact the manager already
holds, so the four places that needed it now compare against
ToplevelManager.activeToplevel directly — the tooltip's two colours, the
minimize target, and the is-this-app-active test in activate(). All of them are
live and cannot drift apart.

pickAppWindow keeps reading `.activated`, which is correct there: it walks the
manager's own toplevels, which are live objects rather than model rows.
The upstream indicator carried two facts: a pill that grew and took the accent
colour when the app held focus, plus a second, smaller pill when the app had
more than one window. Reducing it to one round dot dropped both, which left no
way to tell a single window from four.

Restoring the idea, minus the part that was not true: the old pair said
"one or many", never how many. Two windows and nine drew identically.

Now one dot per window, and the third widens into a pill once there are more
than three, because past three nobody counts dots at 4px. Colour carries state
(urgent, focused, parked), size carries focus (4px to 5px), which is the
language the dock already spoke.

The tooltip stops hiding windows too: it listed at most three with no hint that
more existed, so it now shows four and says "+N more" for the rest.
The minimise-on-click behaviour that arrived with PR thepathless#2 had no rule for which
window a click meant, and three defects fell out of that one gap.

appParkedWindow held a single address per app, so parking a second window
overwrote the first and restore could never find it again — four terminal
windows with one parked, and that one had no way home through the icon at all.

Parking ran before restoring, and parking one of several windows hands focus
straight to a sibling, so the app never stopped being active: every further
click parked another and nothing came back until all of them were gone. Putting
restore first only moved the problem — the icon became a toggle on one window
and a second could never be put away.

The is-this-app-active test also consulted a remembered "app that held focus
before the pointer reached the dock", a value that outlived what it described.

So the behaviour is one ordered list now, decided from live state only — which
windows exist, which are parked, whether focus is already inside the app.
Nothing is remembered, so nothing can go stale:

  no windows                         launch it
  focus elsewhere, something parked  bring the parked one back
  focus elsewhere                   focus it, preferring this workspace
  focus inside, mode "all"           park the whole app
  focus inside, several open         step to the app's next window
  focus inside, one open             park it, when parking is on

Two rules carry the weight.

Preferring a window on the current workspace stops a click from teleporting you
while the app is already in front of you.

Stepping through windows is what makes every click on a multi-window app do
something, and it is what breaks the deadlock above: with focus bouncing to a
sibling, park-first parks forever and restore-first toggles forever, while
stepping has no such corner. A specific window can still be parked from the
context menu, which grows a "Minimize Window" row for exactly that, or the whole
app in one click with mode "all".

Both parking cases stay symmetric: "all" parks in one click and restores in one,
"active" restores newest first.

Supporting sets are small and read straight off the compositor —
visibleWindows, parkedWindows, focusedIndex, windowHere, stepWindow — and the
wheel steps the same set, so scrolling can no longer wake a parked window.
Parked windows are found by the workspace they sit on rather than remembered,
which is what makes losing one impossible.
@NothingManTR

Copy link
Copy Markdown
Author

Huge thanks @assada for the incredible follow-up commits and thorough fixes! The live focus synchronization, deterministic click/minimize decision tree, multi-window dot indicators, and the raised-cosine dock wave effect take Omadock to a whole new level. 🚀

All your latest changes have been merged into this PR and tested live on Omarchy. Really appreciate the fantastic collaboration!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants