feat(gui): MO2 parity for conflicts, dragging, and a Colony-shaped Settings - #8
Merged
Conversation
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
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.
Merged
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_releaseonly 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 withsnap_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'ssrc/ui/settings.rsrather than reinvented, so the two programs read the same way.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.