Skip to content

Arrange the panel: drag to reorder, sliders to resize, both remembered - #23

Merged
evandhoffman merged 7 commits into
mainfrom
layout-arrangement
Aug 18, 2026
Merged

Arrange the panel: drag to reorder, sliders to resize, both remembered#23
evandhoffman merged 7 commits into
mainfrom
layout-arrangement

Conversation

@evandhoffman

Copy link
Copy Markdown
Contributor

Closes #18, closes #19, closes #20, closes #21, closes #22.

Order was derived — the dashboard recomputed it from the LayoutDefaults
constants on every access. Once a tile can be dragged, order is data.
PanelArrangement in MonitorCore is where it lives, beside LayoutPreferences
and under its own preference key.

Keep the two apart: LayoutPreferences is what is drawn, PanelArrangement
is where it goes and how big it is.
Two structs and two keys, so a value this
version cannot read costs one of them and not both.

What changed

  • Drag to reorder dials on the wall and cards in the grid, with an
    insertion line in the gap the tile would land in.
  • Drag across the section rule. The performance/sensor split keeps its
    defaults but stops being a rule about what belongs where.
  • Three size sliders behind a toolbar Size button: gauges, chart width,
    chart height. Gauges are one number because the dial is square; charts are
    two, because a wide short card and a tall narrow one answer different
    questions.
  • All of it survives a relaunch.

Decisions worth reviewing

Insert-before, not swap. For adjacent tiles the two are the same gesture.
For anything further apart, swap flings an unrelated tile across the panel into
the space you just left.

The drop target is half a tile. The gap you aim at is the nearer one.
Whole-tile targets leave the last position in a row unreachable, because there
is nothing after it to drop before. The "trailing half of the last tile means
the end of the list" arithmetic is where reorder bugs live — every rightward
drag landing one place short — so it is a static function with tests rather
than something inlined in a view.

Moves take a neighbour, not an index. The caller has a drop target, and
turning it into an index means remembering that removing the dragged item
shifts everything after it.

Two arrays for the chart sections, not one array plus a section field.
Moving across the rule is then "remove from one, insert into the other", and
order and section cannot disagree.

adoptingDefaults needs no known set, unlike LayoutPreferences. In a
set of what is switched on, "off" and "never heard of" are both absence; in a
list of positions, not being in the list is what unknown means.

Every gesture writes once, when it ends. AppModel.arrangement deliberately
has no saving didSet: a checkbox changes once when clicked, but a slider fires
on every frame and a drag crosses several drop targets on the way to the one it
wants. This is the SSD-endurance rule in docs/storage.md at a smaller scale,
and there is a test asserting an uncommitted change is not written.

Traps handled

  • The preferences list follows the panel. AppModel.groupOrder feeds the
    Layout tab, and its comment has warned since before anything could be dragged
    that the list and the window it configures must agree about where things are.
  • The sensor section survives being emptied. It is drawn whenever the
    machine has sensors at all, not when it holds a card, and shows a drop zone
    when empty. Otherwise dragging the last sensor card up removes the only
    target you could drag it back to.
  • Narrowing the window still shrinks the dials so the row cannot wrap, but
    no longer persists that — it is not a request to keep smaller dials forever.
  • A dial switched off keeps its position, so switching it back on returns it
    to where it was rather than to the end of the row.

Verification

swift build, swift build -c release, 128 tests in 15 suites, and
swiftformat --lint all pass. monitorctl read still reads the machine, and
the app launches with no undeclared drag-type warning.

Not yet verified by looking at it. AGENTS.md is explicit that a UI change
is checked by running the app, and this machine has been locked with the display
off for the whole session, so every screenshot came back black. The drag
gestures, the insertion line and the popover need a human to actually drive them
before this merges.

https://claude.ai/code/session_01UPbN2SqYq8t2vcx2YWQTcs

The panel's order was derived: AppModel recomputed it from LayoutDefaults on
every access. Dragging a tile makes order data, so it needs somewhere to live.

Two ordered lists for the chart sections rather than one list plus a section
field, so order and section cannot disagree. Moves are stated as "before this
neighbour" rather than as an index, because the caller has a drop target and
not a number, and computing the number means remembering that removing the
dragged item shifts everything after it.

adoptingDefaults needs no 'known' set, unlike LayoutPreferences: in a list of
positions, not being in the list is what unknown means.

Refs #18
AppModel's gaugeMetrics, groupOrder, performanceGroups and sensorGroups now
read PanelArrangement instead of the LayoutDefaults constants. Membership is
still LayoutPreferences: the arrangement says where a tile goes, the layout
says whether it is drawn. extraGroupNames goes with it — an unrecognised group
gets a position from adoptingDefaults now, rather than being special-cased into
the gap before the rule.

The arrangement deliberately has no saving didSet, unlike layout beside it.
A checkbox changes once when clicked; a drag changes on every frame. Callers
mutate freely and call commitArrangement() when the gesture ends.

groupOrder feeds the preferences list, so it reads the arrangement too — that
comment has warned since before anything could be dragged that the list and the
window it configures must agree about where things are.

Refs #19
Gauges are one number, because the dial is square. Charts are two: a wide short
card and a tall narrow one answer different questions, and neither is a scaled
copy of the other. Theme.Layout's four constants become forwards to PanelSize
in MonitorCore, since a stored size has to be clamped by the type that holds it.

Every slider writes on release, never during the drag. The binding updates the
panel live because it has to, but the write waits for onEditingChanged to go
false — a slider dragged across its travel fires on every frame, and persisting
each one turns a single decision into a few hundred writes. The drag rule under
the wall now commits on gesture end for the same reason.

Narrowing the window still shrinks the dials so the row cannot wrap, but no
longer persists that: it is not a request to keep smaller dials forever.

Refs #20
One mechanism for both grids, with a tile type that distinguishes a dial from a
card so a drop target can refuse the wrong one — dragging a dial onto a chart
is not a move anybody can make sense of.

Insert-before, not swap. For adjacent tiles the two are the same gesture; for
anything further apart swap flings an unrelated tile into the space you just
left. The drop target is half a tile, so the gap you are aiming at is the
nearer one: whole-tile targets leave the last position in a row unreachable.

The neighbour arithmetic is static and tested. 'Trailing half of the last tile'
must mean the end of the list, and getting it wrong makes every rightward drag
land one place short — invisible in a screenshot, so it is pinned in a test.

The sensor section is now drawn whenever the machine has sensors at all rather
than when the section holds a card, and shows a drop zone when empty. Drag the
last sensor card up and the section has to stay, or there is nothing left to
drag it back to.

Refs #21, #22
LayoutDefaults is the seed for the order now, not the order — its own doc
comment said the opposite. docs/ui.md gains a section on dragging, the section
rule, the sliders, and what is written when. AGENTS.md gains the two rules most
likely to be broken by a later change: keep LayoutPreferences and
PanelArrangement apart, and never save the arrangement on change.

Refs #18, #19, #20, #21, #22
@evandhoffman evandhoffman added enhancement New feature or request release:minor Merging this bumps the minor version labels Aug 18, 2026
Two defects, both invisible to the test suite and to a screenshot of a static
panel. Found by driving a real drag and watching nothing happen.

The drop targets were two Color.clear halves in an .overlay with a
contentShape, to tell leading from trailing. An overlay sits above the content,
so it swallowed the mouse-down and .draggable never saw a press — nothing on
the panel could be picked up. There is now one DropDelegate over the whole tile
that asks DropInfo.location which half it is in, with the width measured from a
background GeometryReader, which is not hit-testable.

The payload was a private UTType via UTType(exportedAs:), which needs an
Info.plist declaration. 'swift run monitor' has no bundle, so the type went
unregistered and no drop destination ever matched it: dragging did nothing in
exactly the build the development loop uses. A type that only works when
packaged is a type that gets broken between packages. It is plain text now,
behind a prefix nothing else produces and parsed strictly, so foreign text
dropped on the panel is still refused — which is what the custom type was for.

That also reverts the Info.plist declaration added to make-app.sh, which is no
longer needed. Both traps are in docs/ui.md and AGENTS.md Troubleshooting.

Refs #21, #22
@evandhoffman
evandhoffman merged commit 37921d4 into main Aug 18, 2026
2 checks passed
@evandhoffman
evandhoffman deleted the layout-arrangement branch August 18, 2026 14:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request release:minor Merging this bumps the minor version

Projects

None yet

1 participant