Skip to content

feat(fit): the dock always fits its screen - #64

Merged
thepathless merged 43 commits into
thepathless:experimentalfrom
priard:feat/dock-fit
Oct 12, 2026
Merged

thepathless merged 43 commits into
thepathless:experimentalfrom
priard:feat/dock-fit

Conversation

@priard

@priard priard commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Nothing limits the dock's width. A floating dock grows past the screen edges, and in Panel or Both sides a row wider than the card runs past the right edge. With enough pinned apps, running apps, minimized tiles, folders and drives, items end up off screen where they cannot be reached.

What it does

The dock always fits its screen. When the row would be wider than its room, it gives width back in a fixed order, least painful first:

  1. Shorter labels. The effective label max width goes down to 80, using the existing shortening rules.
  2. Folded labels. Closed pinned apps fold first, then every app, then everything. This uses the folding from feat(labels): pinned apps can show their name only while running #63: a folded name keeps its plate around the icon.
  3. Spacing. Item and section spacing go down to their minimum.
  4. Icon size. Icons shrink smoothly down to max(24, size / 2).
  5. "»" overflow. Items leave the row from its very end: drives first, then folders and buttons, running apps, tiles, and pinned apps and groups. A "»" tile ends the dock (the right group with Both sides) and opens the hidden items in a popup like an opened group. A click acts as on the dock and a right click opens the item's own menu. There is no drag.

There is no setting. The "»" tile carries what the hidden items would show: a mark when one has windows, their urgency and their notification badges.

Cascade: as configured, labels folded, all folded, icons smaller

Overflow: items move into » from the end of the row

The » popup

How

  • DockFit.js is a pure planner, tested in node like DockLayout.js. It takes the row's items (kind, pinned/running, each label's full unshortened width) and the sizes, and returns one cascade state: label max width, fold level, spacing fraction, icon size and overflow cut.
    • Continuous steps are solved directly; discrete ones are tried in order.
    • The prediction is never narrower than the drawn row: a plate row gets one device pixel per label, and the Wave hover gets a reserve.
    • Hysteresis: a gentler plan comes back only when it fits with a margin, so a window opening and closing at the edge does not flip labels back and forth.
  • components/DockFitHost.qml gathers the inputs from the dock's own lists and the labels' width reports. It recomputes the plan a turn later (child Timer), holds the hidden item ids and the shown counts, and owns the "»" popup's life and window.
    • Dock.qml is at its line cap, so it only reads iconSize, gapWidth and sectionGap from the plan. Components reach the host through root.fitHostRef.
  • None of the inputs depends on the plan. Labels report their width from the full name and from target values, never animated ones. A hidden item keeps its last report (DockFit.retainLabels); planning it at 0 would let it back in, where it no longer fits, forever.
  • Overflowed items leave through their RowSlots, with the usual exit. Dividers, plate edges and the Both sides shift count only what stays.
  • The plan freezes during a drag on the dock, so drop zones do not move. A stack or opened group whose anchor item moves into "»" closes.

Two fixes to #59's popup rivals

Depends on #59 and #63

This is cut from feat/pinned-labels (#63, which is cut from #59). The diff includes both until they merge. The commits of this PR are the ones after #63's, from "feat(fit): a planner that keeps the row inside its room" on. I'll rebase onto experimental once those are in.

Testing

  • tests/unit/fit.test.mjs (19 tests):

    • each cascade step;
    • the fold order;
    • icons down to the floor;
    • overflow from the end, never cut right after a separator;
    • one item wider than the screen;
    • an empty row;
    • hysteresis;
    • the Wave reserve;
    • plate-grid snapping;
    • the Both sides gap item;
    • retained label widths;
    • the packed resting width.
  • CI-equivalent checks pass locally, including structure-check.py (Dock.qml 2410 of 2412).

  • Live, with a temporary IPC override of the room:

    • each step;
    • overflow down to "»" alone;
    • Both sides;
    • the popup and its hand-over to a menu;
    • a stack staying put on a plan change and closing when its folder overflows.

    The journal stayed clean.

  • Tested by hand: the "»" popup, clicking folders and apps in it, menus from it.

Known minors, left for later

  • A group or stack opened from "»" closes on any later change of what is hidden.
  • A context menu on an item that moves into "»" stays open.
  • A newly launched app that will overflow enters the row for one turn before it leaves.
  • Output scale or gapsOut changes do not trigger a replan until something else changes.
  • External file drags do not freeze the plan.
  • The split separator is predicted from the unsnapped gap (up to 2 device px off).

priard added 30 commits October 11, 2026 10:18
Durations had drifted across the dock (120 ms linear next to 120 ms
OutCubic, 140, 150, 160, 180) and a setting that slows or stops motion
needs a single switch. DockMotion.plan(level) gives the timings and flags
for Full, Reduced and Off; the next commits make the dock read it.
It only edits state, like the slot logic beside it; moving it frees the
root's lines for the animation setting that follows.
Motion & Effects gets one switch for how the dock moves. The value is a
behaviour preference, so it lives in omadock.json but not in presets;
Dock.motion carries the plan the components read.
A key dropped from a section stays in the model, marked leaving, for the
length of its exit, and keeps its place; a key that comes back in that
time revives instead of being rebuilt. Each row also carries its slot
among the live rows, which label slots and home centres keep using while
something leaves. Row keys for tiles, folders, buttons and drives come
from the same module.
The model now applies DockKeys rows: removals wait out exitMs (the
motion plan's base duration, 0 at Off) and every row carries its live
slot. The delegates pick the roles up in the next commits.
Folders, command buttons, drives and their dividers move verbatim into
DockRightGroup, which gets the card, its wrapper and the outer row
passed in. DockCard was at its 800-line cap and the row animations that
follow need room; the probe's item geometry is identical before and
after.
…the row

Each element of the pinned run, the running run and the Omarchy button
sits in a RowSlot, which narrows and widens with the element and scales
and fades it (Reduced: fade only, Off: at once). The model keeps a closed
app's row for its exit, so the card and the neighbours ease along
instead of snapping; label slots and home centres use the live slot, and
the drag logic and itemGeometry measure live rows only (liveSlots). A
fully tiled app leaves the running run the same way.
A delegate's model roles reach it after it completes and without a
change signal, and a required property that shares a name with one of
RowSlot's own made a second property RowSlot never read, so nothing
animated out. Delegates now mark RowSlot's own leaving and fresh as
required; the slot draws as nothing for one turn, then grows if it is
fresh. Rows that arrive while the dock starts (the first 1.5 s of the
model) are not fresh, so startup does not animate in.
Minimized tiles, the folder/button run and the drives were plain arrays
rebuilt on every change; they move to KeyedListModel (DockKeys.tileKey,
folderKey, driveKey) inside RowSlots like the app runs. Every keyed
delegate now takes its data from the lookup only while the key is there
(a Binding read the gap first, so a leaving folder lost its name and
narrowed from the wrong width), and the trailing drop gap and ghost
count live folders.
The four divider slots become one RowDivider: its width eases to nothing
and its line fades when its section goes, and it stays in the row while
it has width, so the split panel after it narrows with the items. The
right group stays visible while its rows are on their way out. The
divider sets its direction before its width changes, so the leaving
curve matches the items'.
The queue bookkeeping moves beside dropParkSlot and gets the dock's
timer passed in; the root keeps the Timer and a one-line delegator,
which frees its lines for the popup lives that follow.
The context menu, the folder stack and the opened group were destroyed
the moment they closed, and their opacity Behaviors never ran. Each now
has a PopupLife in the dock that keeps its window until the fade ends;
the window fades and rises in, and on close shows the body's last frame
(a ShaderEffectSource kept current while open), since the popup's own
state clears at once. No input and no focus grab while it fades out.
A window mark popped in at full size and vanished on the spot, and the
badge flipped visible. A mark that goes now stays one exit long while it
shrinks and fades, a new one grows from nothing, and the badge scales
and fades in and out with the last count shown while it fades; its width
eases when the count gains a digit.
Behaviors and transitions read Dock.motion (through each component's
root, or through hoverFx for HoverFx and DockIconArt): the magnifier and
press dip keep their 110 / 90 ms, fades and colours take fast, layout
and hover levels base, the reveal and the autohide slide slow, with
OutCubic in place of the stray linear and OutQuad fades. Autohide slides
only at Full and fades at Full and Reduced. A static test lists any
literal duration left; the bounce, pulse, halo, ripple, glitch and label
typing keep their own timings and are listed as such.
The launch and urgent bounce, the pulse, the drop-target halo, the glow
ripple, the glitch burst and the label typing and scrambling run only at
Full. The bounce and the pulse now finish the hop or breath in flight
before they rest (alwaysRunToEnd), so a window appearing mid-bounce no
longer snaps the icon down from 13 px.
It draws its line in Color.bar.text, so like the other files that use
Color it needs the versioned import (Qt 6.12's own Color singleton).
The folder icons moved out with the right group (DockRightGroup), which
imports it itself.
The menu, the folder stack and the opened group size themselves to 0
the moment their state clears, and a ShaderEffectSource whose source is
0x0 draws nothing: the fade-out ran on an empty window. While closing,
the body keeps its last size.
Two identical command buttons are one button (DockFolderLogic); in the
keyed model they made two rows, and unpinning one left the second row in
place for good.
A leaving row shared its slot with the live row after it, so a drag
opened two drop gaps (one in the leaving item), a file drag found the
leaving folder's gap, and a row that came back before its exit ended had
lost its label width entry to its neighbour for good, skewing home
centres and plate edges. Leaving rows now report label slot -1 and take
no drop line or folder gap; reviving re-registers the label.
The slide was gated on layout motion, which Reduced turns off, so the
card left in one frame while its opacity faded out of view. At Reduced
the card now moves off screen once the fade ends, and back before it
fades in.
The dividers and the Omarchy button follow data that arrives after the
dock is built (the pinned list, the first window list, the config), so
every shell start grew the dividers in and a hidden Omarchy button
narrowed out. Changes in their first 1.5 s land at once; the divider's
width is set by hand after its direction, like RowSlot's appear.
An easing bound to the same flag as the animated value (want, count,
wanted, going, dockVisible) could start with the old easing: on the way
out the popups, badges, row items, window marks and the autohide fade
ran OutCubic instead of InCubic. The Reduced autohide pause had the same
race, so the card could leave the screen before its fade. Each Behavior's
targetValue is set before its animation starts, so the easing and the
pause read the direction from it.
PopupLife ends a popup's life when its level gets back to 0. Opened and
closed in one turn, the level never left 0, no change came, and alive
stayed true: the popup's window (its LazyLoader) lived on, invisible.
Closing now ends the life at once when the level is still at 0.
…rinks

Every plan change restarted the timer that removes a going mark, so with
frequent changes (a window title, the focus) the mark lingered past its
exit. The timer now starts once per exit. The mark's cell kept its full
size while the mark shrank and faded, and the others jumped when it was
removed; along the row the cell now grows and shrinks with its mark (Full
only), with the mark centred in it.
During its 180 ms exit a leaving app, folder or drive could still be
clicked, hovered or dropped on. RowSlot disables its content while it is
not wanted.
RowDivider stays visible while it has width, so a split panel narrows
instead of vanishing. inPinZone and overRunningAt read that visibility,
so for the length of the exit a going divider still decided where the
pin zone and the running apps started. They now also check that it is
shown.
Reduced keeps fades and drops movement, but tooltips still lifted 6 px
as they faded in. TooltipLife exposes the motion plan's rise flag and
TooltipWindow lifts only with it.
It read only Dock.qml and components/*.qml, so a literal duration in
components/logic or components/settings went unseen. It now scans every
QML file outside tests, allows the settings search-hit highlight (not
the dock), and fails on an allowance that no longer matches a line.
With always-on labels a row of pinned apps spends most of its width on
names of apps that are not open. labelPinned "running" (Settings ->
Labels -> Pinned apps, shown for always-on labels on apps) folds a
pinned app's name away while it has no window: the icon stays, on its
own plate in a plate row, and a hover shows the tooltip with the name.

When the app starts, the name waits for the launch bounce: the hop in
the air when the window appears lands first (the animation's running
turns false at once under alwaysRunToEnd, so the tile reads the icon's
height and a ScriptAction after the fall), then the plate widens. Full
eases the width, Reduced snaps it and fades the name, Off is instant.
Closing the last window folds it again.
priard added 13 commits October 11, 2026 15:35
With side indicators on plates, a pinned app folded by "When running"
lost its faint "playing without a window" mark: the column's room and
opacity scaled with the name's progress, which is 0 while folded, and
the mark under the icon is hidden whenever side marks are on.

A foldable name now keeps the column's room and opacity on their own:
before the icon as the usual lead, after it as a tail that hands over to
the name's trail as the name opens. While folded the column holds only
the background mark; window marks still wait for the bounce to land.
A right click on an app in an opened group showed the app's menu for a
moment and then dismissed it. The group closes as the menu opens, and
with animations its window stays mapped for the fade-out; when it
unmapped about 200 ms later, Hyprland cleared the menu's focus grab and
the menu took that as a click outside. With Animations Off the group
window went at once and the menu stayed, which pinned it down.

Each popup life now knows its rivals (folder stack, opened group,
menu). One that opens cuts a rival still closing: level to 0 without
the Behavior and the window let go, so nothing unmaps under the new
popup's grab. The fade is lost only where another popup takes its
place.
Pure cascade: shorter labels, folded labels (closed pinned, apps, all), spacing, icons down to a floor, then overflow from the end of the row. Hysteresis keeps a gentler plan out until it fits with a margin.
The fit planner needs each name's unshortened width and the room around it, taken from targets so the report cannot depend on the plan it feeds. The registry lives in the new DockFitHost: Dock.qml has no room left under its line cap.
…hrinks icons to fit

DockFitHost feeds the planner from the dock's own lists and the labels' full widths and holds the plan; Dock.iconSize, gapWidth and sectionGap read it, labels take its max width and folds. Every always-on name is foldable now, so a name the plan folds keeps its plate like a sleeping pinned app's.
The planner's cut hides items from the end of the row through their RowSlots, so they leave with the usual exit; dividers, plate edges and the Both sides shift count only what stays.
A popup like an opened group lists the overflowed items in dock order; a click acts as on the dock and a right click opens the item's own menu. Its life is a rival of the stack, group and menu lives, so hand-overs keep the new popup's focus grab.
The app library's icon for a web app or a terminal app is not the one its dock tile shows (entry.icon); the popup should look like the dock it stands in for.
…dges

A notification or an urgent app in the overflow would otherwise be invisible.
With indicators beside the art on plates, a folded name kept only the background-media column: right for a pinned app that sleeps or is still starting (When running), wrong for a running app the fit folded, whose windows then showed nowhere. The tile now says why the name is folded, and the width report counts the column.
… item left

Drop zones must not move under the pointer; a stack or group anchored on an item that moved into » has nothing left to point at. README: the dock always fits its screen.
A delegate the fit hides leaves the row and drops its label report; planning it at 0 let it back in, where it reported again and no longer fit, forever (DockFit.retainLabels keeps the last report). Both sides' gap item is always in the row, one more spacing and a one-pixel floor the plan did not count. With overflow the right group's shift reads the packed resting width (plan.rest), not the prediction with its Wave reserve.
The rival cut only covered one order of a hand-over: the old popup already fading when the new one opens. When the new popup's open condition changed first (a folder clicked in a popup that closes on that change), the old one was not closing yet, so nothing was cut; it then faded, unmapped and cleared the new popup's focus grab, which dismissed it. A life that starts closing while a rival wants to be open now ends without the fade. Checked offscreen with two lives in both orders, and that a plain close still fades.
priard added a commit to priard/omadock that referenced this pull request Oct 11, 2026
@thepathless
thepathless merged commit 44921cf into thepathless:experimental Oct 12, 2026
6 checks passed
@thepathless

Copy link
Copy Markdown
Owner

🎉 Thank you so much for this contribution, @priard! Really appreciate the care you put into it. ✨ Verified on experimental — static gates, unit suites (239 node + 116 python) and the full live dock battery all green, including the marks-centering suite — and merged. Your work is credited in the changelog. 🙏🚀

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