`MouseEventKind::ScrollLeft` and `ScrollRight` reached `dispatch_mouse`
and fell into its catch-all, so a horizontal wheel over a TUI document
did nothing at all. B7's row said as much: "events arrive and are
dropped".
They now call `scroll_window_columns` --- the SAME helper B2's panel leg
uses. One contract, one implementation, so the two document surfaces
cannot clamp differently, which is what B3 means by "each document
surface clamps at its own content bounds".
Four rows, each mutation-checked against its own mutation:
three columns per notch step of one -> 1 row
right bound keeps the last clamp at full width -> 1 row
display column visible
origin saturates at zero (covered by the above)
wrap pins the origin to 0 drop the wrap guard -> 1 row, only it
The right-bound row is the one that needed care. "The origin stopped
somewhere" is satisfiable by a bound that lets the origin pass every
glyph and blanks the viewport, so the row asserts the FINAL DISPLAY
COLUMN IS STILL REACHABLE --- origin + viewport >= widest --- and
separately that the origin has not passed the content.
Two fixture facts the rows need, both stated rather than assumed:
- they PAINT first. B7's bound is `widest - viewport`, and a window that
has never painted has `last_content_cols == 0`, so the bound
degenerates to `widest` and the row measures nothing. My first draft
had exactly that hole and passed for the wrong reason.
- they set TRUNCATE after the paint. Under wrap the bound does not exist
and clause 5 pins the origin to zero, so a bound row must be a
truncate row. The paint writes `last_wrap` from the view config, which
is why the order matters --- setting it first, as I first did, is
overwritten and the rows silently exercised the wrap branch instead.