Skip to content

feat(drag): steady drop places, groups on a held hover, group changes that hand slots over - #70

Merged
thepathless merged 3 commits into
thepathless:experimentalfrom
priard:feat/group-motion
Oct 17, 2026
Merged

thepathless merged 3 commits into
thepathless:experimentalfrom
priard:feat/group-motion

Conversation

@priard

@priard priard commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

What and why

Builds on #69 (the lifted drag). Two things were still weak:

  1. Icons jumped during a drag. Each neighbour had three zones: an insert edge, a group middle (76% of it) and the other edge. Dragging across one opened a gap before it, closed it over its middle (that is "make a group") and opened it again after it, so the icons went back and forth under the copy, and a quick drag could make a group by accident.
  2. Group changes did not move well:
    • a group made on an app grew beside that app while the app shrank, on opposite curves, so the dock's width overshot;
    • an app dropped into a group faded where it was let go;
    • an app dragged out of an opened group had no copy and the old narrow gap;
    • Ungroup shrank the group while its apps grew in the same place;
    • the last app of a group it left jumped to the end of the pins.

What it does

Neighbours move once. The gap stays where it is until the pointer has gone three quarters of the way over the next element (or back over the previous one). That element then moves once, into the place the gap leaves and away from the pointer, so it does not flicker at the boundary. Pinned apps, group tiles, apps out of a group popup and folders all move this way.

Groups form on a held hover. Over the middle half of an app or a group nothing moves. Held there for half a second, it becomes the target and its halo shows; only then does a drop make or fill a group. Let go before, the element lands where its gap stands, which is what the dock shows.

Held over an app: the halo shows and nothing has moved (top); after the drop the group stands in that app's place (bottom)

Group changes hand slots over. An element that takes others' place starts as wide as the room they leave and eases to its own width, so the dock's width moves one way:

  • a group made on an app swaps in whole in that app's slot;
  • Ungroup's apps share the group's room and open out of it;
  • the last app of a group takes the group's place (also a fix: it used to go to the end of the pins, by a drag or the menu alike).

Into a group. Dropped on an app or a group, the copy flies into the group's tile, shrinking to a member's size, and the tile pulses as it takes it.

Out of a group. Dragged out of an opened group, the popup fades but stays as the drag's surface until the button is let go. The app's icon follows the pointer, drawn in the dock and over the popup at the same place, so it crosses the edge between the two surfaces whole. The gap opens one tile wide, and on the drop the room it holds goes to the app's new slot. Let go over its own group, the icon flies back in.

How

  • Hand-over (DockLift.handOff(fromKeys, room) / handOn(toKeys), RowSlot): a slot leaving under handOff adds its width to the open batch and snaps out; the slots named by handOn stand at an equal share of that room until they settle, then ease to their own width (_base). A single one swaps in whole, without the fade and scale. Batches nest (an app dragged out of a group that dissolves), and each slot takes its own share once it has read it. DockGroupsLogic.handOver(root, keys, change) wraps group creation, Ungroup and the dissolve.
  • One curve. The empty room a dropped element leaves closes on OutCubic, the curve the slots taking its room open on; with opposite curves the width overshot. Without layout motion (Reduced) it goes at once.
  • Sticky drop places: DockDragLogic.stickyIndex keeps the insert index until the pointer passes 75% of the next element or 25% of the previous one. Held hover: DockLift.dwell(key) sets dropTargetAppId / dropTargetGroupId after 500 ms on the same candidate. While a target is set the gap stays where it was (DockCard.dropLineRow).
  • Flying in: DockLift.settle(key, fade, into) heads the copy's icon for the group's icon and scales it around its centre; absorbed(key) makes the slot pulse.
  • Out of a popup: DockLift.beginIcon draws the app's icon (LiftIcon, the dock's DockIconArt settings) instead of a ShaderEffectSource. AppGroupPopup mirrors it on the popup's surface through DockPopupWindow.fromDockWindow (the inverse of toDockWindow). The popup's close frame follows the faded body, so it does not flash back.

Depends on #69

This is cut from feat/drag-lift (#69); the diff includes it until it merges. The commits of this PR are the two after it, from "feat(drag): group changes move as one" on. I'll rebase onto experimental once #69 is in.

Verification

  • node --check of the model files and node --test tests/unit/*.test.mjs tests/unit/*.test.js (252 pass)
  • python3 -m unittest discover -s tests/unit -p 'test_*.py'
  • qmllint (no new error kinds)
  • security-grep.py, structure-check.py, shaders-in-sync.sh
  • ./tests/manifest-check.sh . and omarchy plugin validate .
  • ./tests/smoke-test.sh against a running dock
  • Live, with a temporary IPC that drives the drag handlers and samples the card's width and each slot's width every ~16 ms:
    • group made on an app: the width moves one way (it overshot by ~22 px with labels before);
    • Ungroup: one smooth move, no frame with a hole;
    • an app out of a group popup into the run, the group dissolving: the width stays constant on the drop;
    • into an existing group, reorder: one way / constant;
    • Reduced and Off: the widths change at once;
    • a pointer swept slowly across the neighbours: each moves once, at 75%, the index holds on the way back until 25%, and the halo shows only after the hold.

…ds its width

A drag inside the dock used to leave the element in its slot at a third
of its opacity and open a narrow gap with an accent line where it would
land, so the dock grew while dragging; on the drop the element popped
out of one place and into the other, a running app pinned in the run
left with its exit animation and came back with its entrance, and the
card widened and narrowed as the two raced. A centred card that changes
width also moves every tile under a pointer that stands still, so the
drop target flickered and the faded tile lagged behind the hand.

Now the element is picked up: a copy of it (a ShaderEffectSource with
hideSource, label and plate included) follows the pointer at full
opacity, and the drop gap opens as wide as the element, without the
line. The held element's slot gives up exactly the room the open drop
gaps take (DockLift.gapSum, which each DropGap reports, a spacer's row
spacing included), down to nothing with the spacing after it (a
negative width in the Row). The neighbours slide to fill the place it
left while the row parts where it would land, and the card keeps its
width throughout: over another app (to group), over the running apps or
pulled off the dock no gap opens and the place stays empty.

On the drop the row takes its final shape in one step (DockLift.snapping
turns off the gaps' easing): a slot entering the pinned run stands open
from its first frame (at least the width the gap held, as its label
settles a frame later) and names and side marks do not ease while a
drag holds or settles; a held slot leaving its run (into the pinned
run, a group or off the dock) eases shut from the room it had left. The
copy settles into the element's new place before the element shows
there, or fades into a group or off the dock. A drag that ends without
a drop eases back: the gap closes, the place reopens by the same
amount, and the copy returns home. Motion Off does all of it at once.

Apps, app groups and folders lift; drags out of a group popup keep the
narrow gap, as nothing in the row stands in for them.
…, dragging out

Making, filling, emptying and dissolving a group were the weak spots of
the lifted drag. A new group grew beside the app it replaced while that
app shrank (opposite curves, so the dock's width overshot), an app dropped
into a group faded where it was let go, an app dragged out of an opened
group had no copy and the dock's old narrow gap, and the last app of a
group it left jumped to the end of the pins.

- Slot hand-over (DockLift.handOff/handOn, RowSlot): an element that takes
  others' place starts as wide as the room they leave and eases to its own
  width, so the dock moves one way. A group made on an app swaps in whole
  in that app's slot; Ungroup's apps share the group's room and open out
  of it. Hand-overs nest, and each new slot takes its own once it settles.
- The empty room a dropped element leaves closes on the same curve the
  slots taking its room open on (and at once without layout motion).
- Dropped on an app or a group, the copy flies into the group's tile,
  shrinking to a member's size, and the tile pulses as it takes it.
- Dragged out of an opened group, the popup fades but stays as the drag's
  surface; the app's icon follows the pointer (LiftIcon), drawn in the
  dock and over the popup at the same place, the gap opens one tile wide
  and the room it holds goes to the app's new slot on the drop. Let go
  over its own group, the icon flies back in.
- The last app of a group dissolves into the group's place, not the end
  of the pins (removeAppFromGroup, a drag or the menu alike).
… held hover

Each neighbour had three zones (an insert edge, a 76% group middle, the other edge), so dragging across one opened a gap before it, closed it over its middle and opened it again after it: the icons jumped back and forth under the copy.

The gap now stays where it is until the pointer has gone three quarters of the way over the next element (or back over the previous one); that element then moves once, into the place the gap leaves and away from the pointer, which also keeps it from flickering at the boundary. Folders and group tiles move the same way.

Over the middle half of an app or a group nothing moves. Held there for half a second (DockLift.dwell) it becomes the target and its halo shows; only then does a drop make or fill a group. Let go before, the element lands where its gap stands, which is what the dock shows.
priard added a commit to priard/omadock that referenced this pull request Oct 15, 2026
Hidden dividers, the lifted drag and group motion are open upstream; CLAUDE.md's open-PR list was from before v4.1.0.
@thepathless
thepathless merged commit 826a508 into thepathless:experimental Oct 17, 2026
6 checks passed
@thepathless

Copy link
Copy Markdown
Owner

Thank you @priard — merged into v4.7.0! The three-quarter steady-drop threshold plus the 500ms single-shot dwell halo (groups form on held hover, never by accident) resolves the back-and-forth icon dance elegantly, and both timers are single-shot so the zero-CPU invariant holds. Verified on experimental: 254 Node + 116 Python tests, qmllint 0 errors, structure-check PASSED.

priard added a commit to priard/omadock that referenced this pull request Oct 18, 2026
Upstream v4.7.0 ships the hidden dividers, the drag lift and the group
motion; the fork keeps its own version suffix on top.
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