Skip to content

feat(gui): MO2 parity for conflicts, dragging, and a Colony-shaped Settings - #8

Merged
MotherSphere merged 9 commits into
mainfrom
feat/mo2-ui-parity
Aug 4, 2026
Merged

feat(gui): MO2 parity for conflicts, dragging, and a Colony-shaped Settings#8
MotherSphere merged 9 commits into
mainfrom
feat/mo2-ui-parity

Conversation

@MotherSphere

Copy link
Copy Markdown
Member

Three things felt at 250 mods and at none of them at five, all found by using a real instance rather than by reading the code.

Conflict marks on the scrollbar

A tinted row says a mod conflicts; it does not say with which, and finding the other one meant scrolling the list looking for the matching tint. A strip on the scrollbar now carries the same green/red at the same fraction of the list. Runs of one colour collapse into a single container, so a mostly-untinted list costs a handful of widgets rather than one per mod. Nothing in the strip handles events, so the scrollbar underneath still takes the pointer.

Dragging

Reaching an earlier row means dragging toward the header, and that dropped the mod and cleared the selection every time: the list cancelled on pointer EXIT, because mouse_area::on_release only fires while the cursor is over its bounds. Exit cannot tell "left while still holding" from "let go out there", so the common gesture paid for the rare one.

Every release is now decided in one place, from a global listener: drop at the aimed gap, or disarm if none was aimed. Both lists lost their local catch-all as well - with both in play they raced, and the local one cancelled what the global one was about to commit. That is why a drag long enough to reach an auto-scroll band never landed.

Resting on either end of the list auto-scrolls while the drag is live, faster the closer to the edge (8px a tick at the inner lip, 75px against it). It uses scroll_by, which is relative: an earlier attempt mirrored the scroll offset in a field and wrote it back with snap_to, and any staleness in that mirror became a jump to the top of the list.

Settings, shaped like Colony

The window's name is the way in, as Colony's is. Behind it, a category rail and pages built from collapsible sections whose rows carry a title, a line saying what the option costs, and the control. The three primitives are read out of Project-Colony/Colony's src/ui/settings.rs rather than reinvented, so the two programs read the same way.

General     Startup (default game, remember the window) · Running a game
Appearance  Theme
Mod list    Dragging (auto-scroll speed) · Conflicts (scrollbar marks)
Nexus       Account · Downloads
About       Where things live · Shortcuts · Version

Three real preferences came with it - the auto-scroll speed (tuned twice by hand against a real list, which is the sign the right value belongs to the user), hiding the conflict marks, and not restoring the window size. None is a stub.

633 tests green, clippy clean under CI's exact command.

Two MO2 behaviours, both felt at 250 mods and neither at five.

**Conflict marks.** A tinted row says a mod conflicts; it does not say WHICH,
and finding the other one meant scrolling the whole list looking for the
matching tint. A strip beside the scrollbar now carries the same green/red at
the same fraction of the list, so the culprit is a glance away. MO2 draws these
on the scrollbar itself; beside it needs no custom widget. Runs of one colour
collapse into a single container, so a mostly-untinted list costs a handful of
them rather than one per mod.

**Dragging upward.** Reaching an earlier row means dragging toward the header,
and doing that dropped the mod and cleared the selection every time. The list
cancelled the drag on pointer EXIT, because `mouse_area::on_release` only fires
while the cursor is over its bounds - so a release outside would otherwise leave
a drag armed for the next click to commit. But exit cannot tell "left while
still holding" from "let go out there", and the common gesture paid for the
rare one.

The release is now caught globally: letting go anywhere drops at the aimed gap,
or disarms if none was aimed. And resting on either end of the list auto-scrolls
while the drag is live, as MO2 does - a timer that exists only while an edge is
held, so an idle drag schedules nothing.

625 tests green, clippy clean.
Two mistakes, and they compounded.

`DragStart` fires on PRESS, so keying the auto-scroll bands off "a drag exists"
put them under the pointer on every click. `mouse_area` publishes `on_enter` the
first time it is laid out beneath a stationary cursor, so pressing a row near
either edge started scrolling before the user had moved at all. The bands now
wait for `aimed` - the pointer having actually crossed an insertion point, which
no plain click does.

And the step was a fixed FRACTION of the list: 2% every 60ms is five rows a tick
on a 250-mod list and a fifth of one on a 10-mod list. It is now one row a tick,
derived from the row count, so the speed does not depend on how many mods you
own. That is asserted at three list lengths, which is what the first version
would have failed.

The bands also shrank to 20px: they sit over the list while dragging, so every
pixel of them is an insertion point that cannot be aimed at.
It scrolled the list to the very top the moment a drag began.

The cause was mine and structural: the auto-scroll remembered the scroll offset
in a field and then wrote it back ABSOLUTELY via `snap_to`. Any staleness in
that field - a programmatic scroll, a scrollbar drag rather than a wheel turn -
became a jump to wherever the stale value pointed, which was usually zero. A
feature that has to mirror a widget's internal state to work is the wrong shape,
and two attempts at tuning it did not change that.

What stays is what was asked for and what is testable: a release anywhere ends a
drag, so dragging upward past the header no longer drops the mod and clears the
selection. Scrolling mid-drag is the wheel again, as before.

624 tests green.
…auto-scroll

**The marks move onto the scrollbar.** They were laid out beside it, which
pushed the whole list sideways to make room - a lot of shifted UI for what is
meant to be a hint, and not what MO2 does. They are now stacked over the
scrollbar at its own width. Nothing in the strip handles events, so the
scrollbar underneath still takes the pointer.

**The auto-scroll comes back, built the other way round.** The version that
teleported mirrored the scroll offset in a field and wrote it back with
`snap_to`, which is absolute: any staleness in the mirror became a jump to
wherever the stale value pointed. It now uses `scroll_by`, which is relative and
needs to know nothing about where the list already is. There is no mirror, so
there is nothing to go stale.

The bands still wait for `aimed` rather than for a drag to merely exist, since
`DragStart` fires on press and `mouse_area` publishes `on_enter` the first time
it is laid out under a stationary cursor.

Also fixed while testing: the release handler cleared the scroll edge only on
its last path, so a drag that ended on a gap left the timer armed. It is cleared
first now, before the branches that return early - which a test caught.

626 tests green.
One speed can creep to the row just off screen, or cross a 250-mod list without
waiting. It cannot do both, and picking either one makes the other unusable.

Speed now follows how deep into the band the pointer sits: 6px a tick at the
inner lip, 46px hard against the edge. `mouse_area::on_move` gives the position
INSIDE the band, so the depth is just that height normalised - no measuring, no
state kept about the pointer.

Entering a band starts mid-range rather than at whatever the last depth was:
`on_move` has not fired yet at that point, and a band that opened at full speed
would lurch before the user had aimed.

The band grew from 20px to 28px, because a gradient needs somewhere to happen.

626 tests green.
6-46px every 60ms crossed a 250-mod list in about seven seconds, which is a long
time to hold a mouse button.

Now 8-75px every 40ms: roughly 10 rows a second at the inner lip, 90 at the
edge, so the same list takes under three. The interval came down as well as the
step - the step is applied whole, so buying speed with a bigger jump alone is
what reads as the list lurching rather than scrolling.
The window's own name was decoration, and the only route into Settings was a
toolbar button - a long way to travel for the thing a title usually opens. It is
a button now.

Behind it, the two horizontal tabs become a vertical rail as in Colony, because
five sections do not fit across a dialog and a rail takes a sixth without
re-laying anything out:

  General     default game, lock the window while a game runs
  Appearance  theme
  Mod list    drag-scroll speed
  Nexus       account
  About       version, and where the instance and config files actually live

The mod-list speed is the one knob worth exposing: it was tuned twice by hand
against a 250-mod list, which is the sign that the right value depends on the
list rather than on the program. It scales the whole range rather than replacing
it, so the faster-toward-the-edge curve is kept. Out-of-range or unparseable
values in settings.ini fall back to the default rather than being taken at face
value - a bad number there is a list that either will not move or cannot be
aimed.

629 tests green.
…itches

Read out of Project-Colony/Colony `src/ui/settings.rs` rather than reinvented, so
the two programs read the same way: a category rail on the left, and each page
built from collapsible sections whose rows are a title, a line saying what the
option costs, and the control on the right.

Three primitives now live in `widgets.rs`, mirroring Colony's
`view_collapsible_section`, `view_functional_toggle` and `info_row`:
`settings_section`, `settings_toggle` (a pill switch, whole row clickable) and
`settings_info`, plus `settings_row` so a pick-list or slider reads like a
toggle.

  General     Startup (default game, remember the window) · Running a game
  Appearance  Theme
  Mod list    Dragging (auto-scroll speed) · Conflicts (scrollbar marks)
  Nexus       Account · Downloads (where nxm:// lands)
  About       Where things live · Shortcuts · Version

Two real preferences came with it, both wired rather than decorative:
`conflict_marks` hides the scrollbar strip, `remember_window` stops the size
being restored. Neither is a stub.

One section per category starts open. A settings screen whose sections are all
shut asks the user to click five times before it says anything, which is the
failure mode of this shape - asserted, along with each toggle actually reaching
disk.

632 tests green.
Dragging a block far enough to trigger the auto-scroll made the drop vanish.

Two things answered the same release. The lists still carried an
`on_release(DragCancel)` from before the global listener existed, as a catch-all
for a release that missed a drop strip. With both, they raced: the list's
cancelled the drag before the listener could commit it. Releasing over a row
already lost the drop that way; the scroll bands are never drop strips, so once
a drag was long enough to reach one it was certain.

The catch-all is gone from both lists. `Message::PointerReleased` decides
every release, and it only looks at where the drag was AIMED - which the scroll
does not change. The plugin list loses its `on_exit` cancel at the same time,
for the reason the mod list already had: leaving the bounds is the gesture that
reaches an earlier row, not a decision to drop nothing.

Disarming now goes through the cancel messages rather than clearing the fields
inline, so Escape and a release cannot drift apart about what it means.

633 tests green.
@MotherSphere
MotherSphere merged commit 9aad5f7 into main Aug 4, 2026
5 of 6 checks passed
@MotherSphere
MotherSphere deleted the feat/mo2-ui-parity branch August 4, 2026 15:16
MotherSphere added a commit that referenced this pull request Aug 4, 2026
…od (#12)

profile.rs was one 800-line impl Profile plus its satellites. The unit of the
move is the METHOD here - Rust allows inherent impls across files, so each
concern gets its own impl Profile block beside the free items it owns:

  profile.rs    91   Profile and SaveEntry themselves, the path roots
  modlist.rs   385   modlist.txt round-trip, ListTrust, lifecycle
  plugins.rs   350   plugins.txt, the locked order, snapshot + loss guard
  inis.rs      327   seeding, deploy, capture, the reversible tweaks
  saves.rs     236   enumeration, co-saves, crash-artifact detection
  tests.rs     889   the unit tests, file-backed

settings.rs deliberately untouched: PR #7 and PR #8 both edit it, and a move
now would conflict with both.

Verified at the level the cut happened: 89 functions before, 89 after, none
lost and none invented. Workspace suite green, clippy clean.
@github-actions github-actions Bot mentioned this pull request Aug 4, 2026
MotherSphere added a commit that referenced this pull request Aug 12, 2026
Brings in the six PRs merged since this branch was cut (#8 parity, #9-#12
crate splits, #13 the overwrite index) plus the 1.3.0 release.

Two conflicts, both in the Settings screen, which main rewrote into Colony's
collapsible sections while this branch was replacing the API-key field with
OAuth sign-in. Resolved by keeping main's structure and putting the sign-in
UI inside it: the Account section now holds the sign-in / sign-out pair and
the session status instead of a masked key field, and the app state keeps
`settings_expanded` while dropping `settings_api_key`, which no longer has
anything to hold.
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.

1 participant