Skip to content

Fix #4173, #4219: scroll the editor by lines and pages, not fixed pixels - #4252

Open
christophwille wants to merge 4 commits into
masterfrom
fix/editor-scroll-increments
Open

christophwille wants to merge 4 commits into
masterfrom
fix/editor-scroll-increments

Conversation

@christophwille

@christophwille christophwille commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

Fixes #4173, fixes #4219.

Symptoms

Thumb dragging is proportional and was never affected.

Root cause

Both come from one spot in AvaloniaEdit 12.0.0, AvaloniaEdit.Rendering.TextView:

Size ILogicalScrollable.ScrollSize => new Size(10, 50);      // wheel notch AND ScrollBar.SmallChange
Size ILogicalScrollable.PageScrollSize => new Size(10, 100); // ScrollBar.LargeChange (track click, PageUp/PageDown on the bar)

Avalonia's ScrollViewer derives every increment from these two values (verified against the shipped 12.0 binaries, not just master):

  • ScrollContentPresenter.OnPointerWheelChanged scrolls delta * ScrollSize; with Shift held it swaps the axes, so a Shift+wheel notch moved ScrollSize.Width = 10 px.
  • ScrollViewer.CalculatedPropertiesChanged copies ScrollSize into SmallChange and PageScrollSize into LargeChange. Both are read-only DirectPropertys.
  • ScrollBar.AttachToScrollViewer binds its own SmallChange/LargeChange to those at Template priority, so a track click moved 100 px vertically and 10 px horizontally, against 50 px / 10 px for an arrow click.

For comparison, WPF AvalonEdit's IScrollInfo implementation scrolled SystemParameters.WheelScrollLines * DefaultLineHeight per wheel notch, WheelScrollLines * WideSpaceWidth horizontally, and the viewport size for PageUp/PageDown/PageLeft/PageRight. ILSpy 10's ZoomScrollViewer added nothing beyond the Ctrl+wheel zoom, which the Avalonia port already has. The Avalonia docs offer no app-level knob here; ILogicalScrollable is the designated extension point.

Fix

TextArea is the ScrollViewer's content and implements ILogicalScrollable purely by forwarding to its TextView, and TextEditor has a protected TextEditor(TextArea) constructor. So:

  • New DecompilerTextArea : TextArea, ILogicalScrollable re-implements the interface. Every member still forwards to the TextView except the two step sizes:
    • ScrollSize: wheel-lines x DefaultLineHeight, used for both axes (a Shift+wheel notch moves the same distance as a vertical notch; decided over the WPF 3-character step because decompiled lines are long).
    • PageScrollSize: the TextView's viewport.
    • Wheel lines come from SystemParametersInfo(SPI_GETWHEELSCROLLLINES) on Windows (default 3), and are 3 on Linux/macOS. Windows' "one screen at a time" wheel mode (WHEEL_PAGESCROLL) maps to a viewport-sized step.
    • StyleKeyOverride => typeof(TextArea) so the subclass keeps AvaloniaEdit's TextArea template (same trap DecompilerTextEditor already documents).
  • DecompilerTextEditor is constructed with it, so the main text view and the metadata row-detail editors both pick it up.

No custom input handling and no scrollbar styling: the ScrollViewer, ScrollBar and wheel handler keep doing their jobs with correct inputs. Side effect: arrow-button clicks now move wheel-lines lines (same distance as before at the default font, now font- and zoom-relative).

Per-desktop follow-ups (one commit each, see the research comment below)

  • Linux / KDE: the desktop-settings code moves into SystemScrollSettings (Windows SystemParametersInfo, plus a lookup of [KDE] WheelScrollLines in kdeglobals, which Qt applications honor; default 3). GNOME has no such setting; KWin's scroll-speed factor already arrives inside the wheel delta.
  • macOS: fixed 50x50 px wheel step. Avalonia.Native divides trackpad deltas by 50 px and mouse-wheel deltas by 5 AppKit line units with no precision flag, so 50 px is the one value that keeps trackpads 1:1 at every zoom level; mice scroll at the speed every other Avalonia app has on macOS.
  • Page overlap off Windows: a page is the viewport minus one line on macOS and Linux (AppKit pageScroll, GTK's 0.9 page), the full viewport on Windows as in WPF.

The headless tests pin the platform through SystemScrollSettings.IsWindows/IsMacOS, so the same assertions run on every CI runner.

Tests

ILSpy.Tests/Editor/EditorScrollIncrementTests.cs (headless, red on the old 10/50/100 px values before the fix):

  • wheel step is wheel-lines x line height on both axes;
  • page step equals the viewport, ScrollViewer.LargeChange follows it and exceeds SmallChange;
  • a Shift+wheel notch moves Offset.X by exactly one wheel step and leaves Offset.Y alone;
  • ScrollBar.PageDown() moves one viewport;
  • on macOS the step is 50x50 px regardless of font and Shift+wheel moves 50 px;
  • off Windows a page keeps one line of overlap and LargeChange follows;
  • the kdeglobals parser finds WheelScrollLines only in the [KDE] group and tolerates CRLF, missing and malformed values.

The tests use a 40 px font so line-based values cannot coincide with the old pixel constants.

Preferred long-term solution: fix it in AvaloniaEdit

This subclass is a workaround for AvaloniaEdit reporting constants where WPF AvalonEdit reported metrics. The proper fix is an upstream PR against src/AvaloniaEdit/Rendering/TextView.cs, after which DecompilerTextArea can be deleted and DecompilerTextEditor goes back to the default TextArea:

// Mirrors WPF AvalonEdit's IScrollInfo: a wheel notch scrolls WheelScrollLines lines,
// a page scrolls one viewport.
Size ILogicalScrollable.ScrollSize =>
    new Size(WheelScrollLines * WideSpaceWidth, WheelScrollLines * DefaultLineHeight);

Size ILogicalScrollable.PageScrollSize => _scrollViewport;

Points the upstream change has to settle:

  • Where WheelScrollLines comes from. Avalonia delivers raw wheel notches and does not expose the OS "lines to scroll" setting, so AvaloniaEdit needs either a constant 3 (the Windows default WPF users effectively got) or a settable property, e.g. TextEditorOptions.WheelScrollLines (default 3), that hosts can feed from SystemParametersInfo or leave alone.
  • Horizontal step. WPF used WheelScrollLines * WideSpaceWidth (3 characters). This PR uses the vertical distance instead; upstream should pick one and document it. Either is far better than 10 px.
  • Arrow buttons. In Avalonia, ScrollSize feeds both the wheel notch and ScrollBar.SmallChange, so arrow clicks move WheelScrollLines lines rather than WPF's one line. Doing better would need Avalonia to split the two, which is out of AvaloniaEdit's hands.
  • Timing. _scrollViewport is zero before the first measure; ScrollViewer re-reads PageScrollSize in CalculatedPropertiesChanged on every extent/viewport/offset change, so no extra invalidation is needed. DefaultLineHeight recomputes lazily when the font changes.
  • Tests. Port the headless tests from this PR; they only need a TextEditor in a Window and HeadlessWindowExtensions.MouseWheel(point, delta, RawInputModifiers.Shift).

Limitations

  • The wheel-lines setting is read once at startup (static initializer). Changing "lines to scroll" in Windows settings or kdeglobals takes effect after an ILSpy restart; WPF tracked WM_SETTINGCHANGE via SystemParameters.
  • GNOME uses 3 lines per notch (it has no setting; GTK itself scrolls page_size^(2/3) px, somewhat more on tall viewports). Touchpads on X11/XWayland scroll at the same speed as Qt applications.
  • macOS mice scroll at Avalonia's platform speed (10 px per AppKit line unit), slower than a Cocoa text view's one line per unit; honoring Apple's mouse rule would make trackpads 1.7x too fast because Avalonia exposes no precision flag.
  • Arrow-button clicks move wheel-lines lines, not one line (see above; inherent to Avalonia's ScrollSize dual use).
  • Keyboard PageUp/PageDown inside the editor are AvaloniaEdit caret commands and were never affected.
  • Verified with the headless tests only; no manual GUI pass in this PR.

Assisted-by: Claude:claude-fable-5-1:Claude Code

AvaloniaEdit's TextView reports a fixed 10x50 px ScrollSize and 10x100 px
PageScrollSize through ILogicalScrollable, and Avalonia's ScrollViewer
derives the wheel step, the scrollbar arrow step and the track-click step
from exactly those two values. Shift+wheel therefore crawled 10 px per
notch and a track click moved barely more than an arrow click, while the
WPF AvalonEdit view scrolled WheelScrollLines lines per notch and one
viewport per page. ScrollViewer's SmallChange/LargeChange are read-only,
so the fix lives at the framework's extension point: a TextArea subclass
re-implements ILogicalScrollable with text-metric based sizes and the
editor is constructed with it. The subclass becomes redundant once
AvaloniaEdit derives the sizes from its own metrics upstream.

Assisted-by: Claude:claude-fable-5-1:Claude Code
@christophwille

Copy link
Copy Markdown
Member Author

Cross-platform follow-up: macOS, GNOME, KDE

The first commit fixes the increments against Windows conventions. This comment records what the other desktops do, how Avalonia's backends feed us, and which follow-up commits on this branch result from it.

What Avalonia hands us

PointerWheelEventArgs.Delta has no unit or precision flag, and each backend scales differently (verified in the shipped 12.0 binaries and the Avalonia sources):

Platform Input Delta 1.0 means
Windows wheel one notch (fractional for high-resolution wheels)
macOS (AvnView.mm) mouse wheel 5 AppKit "line" units (scrollingDeltaY / 5)
macOS (AvnView.mm) trackpad 50 px of finger travel (scrollingDeltaY / 50), sent as a plain wheel event
X11 (XI2Manager) wheel one notch (valuator change / increment)
X11, native Xorg touchpad 15 libinput px (xf86-input-libinput ScrollPixelDistance)
XWayland wheel / touchpad one notch / 10 px (dispatch_scroll_motion, divisor 10)

Avalonia 12.0 ships no Wayland backend, so every Linux session runs through X11 or XWayland. 12.1 adds an opt-in experimental one that divides touchpad pixels by 10, like GTK and Qt. Avalonia's non-logical default of 50 px per notch, and AvaloniaEdit's old ScrollSize of 50, line up with the macOS trackpad divisor, which is why trackpads felt right before this PR while mice did not.

Native conventions

KDE Plasma / Qt. QAbstractSlider scrolls wheelScrollLines (default 3) times singleStep per notch; text editors use one line as the step, pageStep is the viewport. The value is read from kdeglobals, [KDE] WheelScrollLines, by both plasma-integration and Qt's built-in QKdeTheme. Qt converts XI2 valuators and Wayland value120 exactly as Avalonia does, so our touchpad speed on KDE matches Kate's. KWin's "scroll speed" factor is multiplied into the delta before any client sees it, so there is nothing to apply. Qt's horizontal modifier is Alt (transposed in the platform plugin); Shift and Ctrl mean one page. Breeze has no arrow buttons, and KStyle makes a trough left click jump to the clicked position unless [KDE] ScrollbarLeftClickNavigatesByPage=true.

GNOME / GTK. No lines-per-notch setting exists (mutter MR adding a scroll speed has been open since 2019). GtkScrolledWindow scrolls page_size^(2/3) px per notch: 71 px for a 600 px view, 93 px for 900 px, versus 51 px here at the default font. page_increment is 0.9 of the page. GtkScrolledWindow swaps axes on Shift itself, like Avalonia. On X11 and XWayland GTK tags touchpad deltas as wheel units and applies the same formula, so GTK touchpads run 1.4 to 1.8 times faster than ours. Adwaita hides steppers; a primary trough click warps the thumb to the click point (gtk-primary-button-warps-slider defaults to TRUE), Shift+click pages.

macOS / AppKit. Apple's rule for a classic wheel is "scrollingDeltaY is in lines, multiply by the line height", with the user's scroll speed and acceleration already applied by the OS; for trackpads, "scroll by the returned amount in points". Through Avalonia's divisors our step gives 10 px per AppKit line unit, about 0.6 of a text line where a Cocoa text view moves a full line, and trackpad tracking is 1:1 only at the default font because the step scales with zoom. Shift+wheel is swapped by the OS for mice; Avalonia swaps only when Delta.X is zero, so no double swap. Scrollers have had no arrows since 10.7, page scrolls keep an overlap (NSScrollView.pageScroll, 10 pt by default, the HIG suggests one line), and "Click in the scroll bar to" defaults to "jump to the next page" with Option toggling. Chromium and WebKit use a flat 40 px per Cocoa tick.

Follow-up commits on this branch

  1. Linux / KDE: move the OS-settings code (Windows SystemParametersInfo, new kdeglobals lookup, default 3) into a small SystemScrollSettings class so DecompilerTextArea keeps only the metric policy. KDE users get their own WheelScrollLines; GNOME has no such setting and stays at 3.
  2. macOS: fixed 50x50 px wheel step. Trackpads track 1:1 at every zoom level, Shift+wheel moves 50 px instead of 10, and mice scroll at the speed every other Avalonia app has. Choosing Apple's "one line per unit" for mice would make trackpads 1.7x too fast; both cannot be honored without a precision flag from Avalonia.
  3. Page overlap off Windows: page by viewport minus one line on macOS and Linux (AppKit pageScroll, GTK's 0.9 page). Windows keeps the full viewport like WPF.

Out of ILSpy's hands

  • Avalonia should expose a wheel unit or precision flag on PointerWheelEventArgs, as GTK4 (GdkScrollUnit) and Qt (pixelDelta vs angleDelta) do; without it no Avalonia app, AvaloniaEdit included, can honor both of Apple's rules.
  • Jump-to-click on GNOME and KDE, Option-toggled behavior on macOS, and hiding arrow buttons outside Windows belong in Avalonia's ScrollBar control or theme, not in an editor.
  • Alt+wheel as a horizontal modifier for Qt-trained hands is possible but not pursued here.

Assisted-by: Claude:claude-fable-5-1:Claude Code

…ext area

Plasma keeps its "wheel scroll lines" value in kdeglobals under [KDE], and
both plasma-integration and Qt's built-in KDE theme read it (default 3), so
Qt editors on KDE move that many lines per notch. Avalonia exposes no desktop
settings, so the editor reads the file itself; GNOME has no such setting and
stays at 3, and KWin's scroll-speed factor already arrives inside the wheel
delta, so nothing else is applied on Linux. With a second desktop to query,
the OS-settings code (the Windows SystemParametersInfo call and this lookup)
moves into SystemScrollSettings, leaving DecompilerTextArea as the pure
scroll-metric adapter it is meant to be.

Assisted-by: Claude:claude-fable-5-1:Claude Code
Avalonia.Native scales macOS input before it reaches the controls: trackpad
deltas are divided by 50 px and mouse-wheel deltas by 5 AppKit line units,
and the resulting wheel events carry no precision flag, so a single step size
has to serve both devices. Apple's rule for precise deltas is to scroll by
the returned amount, which only a 50 px step honors at every font size and
zoom level; a font-relative step made trackpads drift with the zoom. Mice
end up at the speed every other Avalonia application has on macOS, which is
slower than a Cocoa text view's one line per unit, but following Apple's
mouse rule instead would make trackpads 1.7x too fast. Resolving that needs
a precision flag from Avalonia. Shift+wheel, which macOS swaps to the
horizontal axis itself, moves 50 px instead of 10.

Assisted-by: Claude:claude-fable-5-1:Claude Code
A page scroll on Windows replaces the whole viewport, as WPF did. AppKit
keeps NSScrollView.pageScroll points of the previous page visible and the
macOS HIG suggests one line of overlap, and GTK's page_increment is 0.9 of
the page, so readers on those desktops expect the last visible line to still
be there after a track click. Windows keeps the full viewport.

Assisted-by: Claude:claude-fable-5-1:Claude Code

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

In disassembly, scrollbar window should be greater than scrollbar unit Shift+MouseWheel (horizontal scrolling) moves in pixel increments

1 participant