diff --git a/ILSpy.Tests/Navigation/ViewStateRoundTripTests.cs b/ILSpy.Tests/Navigation/ViewStateRoundTripTests.cs index 55956d46e..580238224 100644 --- a/ILSpy.Tests/Navigation/ViewStateRoundTripTests.cs +++ b/ILSpy.Tests/Navigation/ViewStateRoundTripTests.cs @@ -64,21 +64,18 @@ public class ViewStateRoundTripTests await dockWorkspace.WaitForDecompiledTextAsync(); var tab = dockWorkspace.ActiveDecompilerTab!; - // Simulate the user moving the caret on node A. The view's PositionChanged handler - // is wired to push LastKnownCaretOffset onto the model, but headless tests don't - // fire that event by simulating user clicks — write the LastKnown values directly - // to model the post-interaction state. - tab.LastKnownCaretOffset = 500; - tab.LastKnownVerticalOffset = 120.5; - tab.LastKnownHorizontalOffset = 7.25; - - // Navigate to a different node — captures A's state into the history's current + // State is pulled from the editor on demand (CaptureViewState) when DockWorkspace records + // a navigation away -- not pushed per caret/scroll event. Headless has no laid-out editor, + // so override the pull delegate to report the position the user "left" on node A. + tab.CaptureViewState = () => new DecompilerTextViewState(500, 120.5, 7.25, null); + + // Navigate to a different node — DockWorkspace pulls A's state into the current history // entry and records B as the new current. vm.AssemblyTreeModel.SelectNode(stringNode); await dockWorkspace.WaitForDecompiledTextAsync(); - // Verify the capture: the back stack's most recent entry should be A's, with - // the caret + scroll values we set. + // Verify the capture: the back stack's most recent entry should be A's, with the pulled + // caret + scroll values. var backEntries = dockWorkspace.BackHistory.OfType().ToList(); backEntries.Should().NotBeEmpty("Select(B) must push A onto the back stack"); var captured = backEntries.Last(); @@ -88,34 +85,31 @@ public class ViewStateRoundTripTests captured.VerticalOffset.Should().Be(120.5); captured.HorizontalOffset.Should().Be(7.25); - // Navigate Back. ApplyNavigationTarget synchronously sets PendingVerticalOffset - // from the recorded entry before the async decompile runs; the editor view - // consumes it in ApplyDocument once Text lands. Assert the scroll-restore intent - // here, before the await lets the view consume and null it. We can't assert the - // applied result: the headless editor has no rendered viewport (extent 0), so - // ScrollToVerticalOffset(120.5) clamps to 0 and LastKnownVerticalOffset reads back - // 0. Applying the scroll is the view's job, covered by the real UI, not this test. + // Navigate Back. ApplyNavigationTarget synchronously stashes the recorded state on the tab + // as PendingViewState before the async decompile runs; the editor consumes it in + // ApplyDocument once Text lands. Assert the restore intent here, before the await lets the + // view consume it -- applying caret/scroll to the live editor is the view's job (covered by + // the real UI; headless has no rendered viewport). dockWorkspace.NavigateBackCommand.Execute(null); - tab.PendingVerticalOffset.Should().Be(120.5, - "Back must schedule a restore of the vertical scroll offset the user left on A"); + tab.PendingViewState.Should().NotBeNull("Back must stash the recorded view state for the editor to apply"); + tab.PendingViewState!.Value.CaretOffset.Should().Be(500, + "Back must restore the caret to where the user left it before navigating to B"); + tab.PendingViewState!.Value.VerticalOffset.Should().Be(120.5, + "Back must restore the vertical scroll offset the user left on A"); await dockWorkspace.WaitForDecompiledTextAsync(); - // The caret is a document position, so it round-trips even headless: the view's - // caret-change handler writes LastKnownCaretOffset when it programmatically moves - // the caret in ApplyDocument. - tab.LastKnownCaretOffset.Should().Be(500, - "Back must restore the caret to where the user left it before navigating to B"); + // The editor consumes and clears PendingViewState once the document lands. + tab.PendingViewState.Should().BeNull("the editor must consume the pending state after applying it"); } [AvaloniaTest] public async Task Back_Carries_Expanded_Foldings_Snapshot_From_Capture_Through_To_Pending() { // Pins the foldings half of the view-state round trip end-to-end through DockWorkspace. - // The headless editor doesn't actually build foldings, so we can't observe a real - // FoldingManager state. Instead seed LastKnownFoldings directly on the tab (mimicking - // what the view's CaptureFoldingsState delegate would push), exercise the navigation - // pipeline, and assert the snapshot survives the capture → entry → pending hops. + // The headless editor doesn't build real foldings, so override the pull delegate to report + // a deterministic snapshot, exercise the navigation pipeline, and assert the snapshot + // survives the capture -> entry -> pending hops. var window = AppComposition.Current.GetExport(); window.Show(); @@ -131,23 +125,19 @@ public class ViewStateRoundTripTests await dockWorkspace.WaitForDecompiledTextAsync(); var tab = dockWorkspace.ActiveDecompilerTab!; - // Seed a deterministic foldings snapshot — two expanded regions over a four-folding - // layout. Compute it via the helper to keep the checksum honest. The view normally - // re-snapshots its live foldings via the CaptureFoldingsState delegate at navigate- - // away time, which would clobber the seed — disable it for this isolation test so we - // observe just the entry → pending plumbing inside DockWorkspace. (The snapshot - // itself is independently exercised in FoldingsViewStateTests.) + // Deterministic foldings snapshot — two expanded regions over a four-folding layout. + // Compute via the helper to keep the checksum honest. (The snapshot itself is exercised + // independently in FoldingsViewStateTests.) var seeded = FoldingsViewState.Capture(new[] { (Start: 10, End: 50, IsFolded: false), (Start: 60, End: 100, IsFolded: true), (Start: 110, End: 200, IsFolded: false), (Start: 210, End: 250, IsFolded: true), }); - tab.LastKnownFoldings = seeded; - tab.CaptureFoldingsState = null; + tab.CaptureViewState = () => new DecompilerTextViewState(0, 0, 0, seeded); - // Navigate to a different node — DockWorkspace.CaptureCurrentViewState reads - // LastKnownFoldings and stamps it onto the OUTGOING entry on the back stack. + // Navigate to a different node — DockWorkspace pulls the state and stamps the foldings + // onto the OUTGOING entry on the back stack. vm.AssemblyTreeModel.SelectNode(stringNode); await dockWorkspace.WaitForDecompiledTextAsync(); @@ -159,23 +149,13 @@ public class ViewStateRoundTripTests captured.Foldings!.Value.Checksum.Should().Be(seeded.Checksum); captured.Foldings.Value.Expanded.Should().Equal(seeded.Expanded); - // Clear the pending slot on the tab so we can observe the Back-driven set. - tab.PendingFoldings = null; - - // Navigate Back. ApplyNavigationTarget writes the recorded snapshot into - // PendingFoldings so the view consumes it on the next ApplyDocument. The view's - // consume-and-null happens after the decompile completes, but the assignment from - // DockWorkspace is synchronous, so we can observe it without waiting. + // Navigate Back. ApplyNavigationTarget stashes the recorded snapshot into PendingViewState + // for the view to consume on the next ApplyDocument. The assignment is synchronous, so we + // can observe it before the await lets the editor consume it. dockWorkspace.NavigateBackCommand.Execute(null); - - // Read PendingFoldings before the editor consumes it. In headless mode the editor - // may not run ApplyDocument at all (no Editor surface), but the assignment from - // DockWorkspace.ApplyNavigationTarget already happened. Either we still see it - // pending, OR we see a fresh capture write LastKnownFoldings from the view's hook - // — both are valid "the snapshot made it through" outcomes for this assertion. - var observed = tab.PendingFoldings ?? tab.LastKnownFoldings; - observed.Should().NotBeNull("Back must propagate the captured snapshot to the destination tab"); - observed!.Value.Checksum.Should().Be(seeded.Checksum); + tab.PendingViewState.Should().NotBeNull("Back must propagate the captured state to the destination tab"); + tab.PendingViewState!.Value.Foldings.Should().NotBeNull(); + tab.PendingViewState!.Value.Foldings!.Value.Checksum.Should().Be(seeded.Checksum); } [AvaloniaTest] diff --git a/ILSpy/Docking/DockWorkspace.cs b/ILSpy/Docking/DockWorkspace.cs index e7e971f19..31e57cde1 100644 --- a/ILSpy/Docking/DockWorkspace.cs +++ b/ILSpy/Docking/DockWorkspace.cs @@ -508,17 +508,16 @@ namespace ILSpy.Docking { if (history.Current is not TreeNodeEntry current) return; - var decompTab = UnwrapDecompilerTab(current.Tab); - if (decompTab == null) + // Pull the editor's live view state (caret + scroll + foldings) on demand. Reading it + // only at this point -- when we record the navigation away -- avoids the poisoned + // captures a per-event push suffered (AvaloniaEdit jumps the caret to the end on a + // text replace). Null when no view is attached / nothing to capture. + if (UnwrapDecompilerTab(current.Tab)?.CaptureViewState?.Invoke() is not { } state) return; - // Foldings have no model-side property-change event we can mirror onto, so the - // text view's snapshot delegate has to be invoked synchronously here. Caret and - // scroll values are kept fresh by per-event push from the view; foldings aren't. - decompTab.CaptureFoldingsState?.Invoke(); - current.CaretOffset = decompTab.LastKnownCaretOffset; - current.VerticalOffset = decompTab.LastKnownVerticalOffset; - current.HorizontalOffset = decompTab.LastKnownHorizontalOffset; - current.Foldings = decompTab.LastKnownFoldings; + current.CaretOffset = state.CaretOffset; + current.VerticalOffset = state.VerticalOffset; + current.HorizontalOffset = state.HorizontalOffset; + current.Foldings = state.Foldings; } // The recorded TabPageModel in TreeNodeEntry can be either a DecompilerTabPageModel @@ -567,10 +566,11 @@ namespace ILSpy.Docking // the Pending* fields right after Text lands and restores caret + scroll. if (UnwrapDecompilerTab(treeNode.Tab) is { } decompTab) { - decompTab.PendingCaretOffset = treeNode.CaretOffset; - decompTab.PendingVerticalOffset = treeNode.VerticalOffset; - decompTab.PendingHorizontalOffset = treeNode.HorizontalOffset; - decompTab.PendingFoldings = treeNode.Foldings; + decompTab.PendingViewState = new TextView.DecompilerTextViewState( + treeNode.CaretOffset ?? 0, + treeNode.VerticalOffset ?? 0, + treeNode.HorizontalOffset ?? 0, + treeNode.Foldings); } assemblyTreeModel.SelectedItem = treeNode.Node; } diff --git a/ILSpy/TextView/DecompilerTabPageModel.cs b/ILSpy/TextView/DecompilerTabPageModel.cs index 51331ed9f..6210212ad 100644 --- a/ILSpy/TextView/DecompilerTabPageModel.cs +++ b/ILSpy/TextView/DecompilerTabPageModel.cs @@ -145,60 +145,22 @@ namespace ILSpy.TextView private object? highlightedReference; /// - /// Last caret offset the editor view reported. The text view writes this on every - /// caret-position change so can read the current - /// position when recording a navigation away from this tab. Zero until the view - /// reports its first caret position; that's a safe default because a freshly-shown - /// document also has caret at zero. + /// Pulls the editor's current view state (caret, scroll, expanded foldings) on demand. + /// Set by the text view when it binds to this model; invoked by + /// when it records a navigation away from this tab. + /// Null until a view is attached. Reading on demand -- rather than mirroring every caret / + /// scroll event onto the model -- avoids capturing the programmatic caret-to-end that + /// AvaloniaEdit performs when the document text is replaced. /// - public int LastKnownCaretOffset { get; set; } - - /// Last vertical scroll offset the editor view reported. See . - public double LastKnownVerticalOffset { get; set; } - - /// Last horizontal scroll offset the editor view reported. See . - public double LastKnownHorizontalOffset { get; set; } - - /// - /// Caret offset the editor view should restore on the next document apply. Set when - /// Back/Forward navigation lands on an entry that carries a captured caret position - /// (see ); cleared by the view - /// after it reads the value. The pattern mirrors — - /// the editor consumes this in its ApplyDocument path so the value survives - /// the async gap between Back firing and the decompile finishing. - /// - public int? PendingCaretOffset { get; set; } - - /// Vertical scroll offset to restore alongside . - public double? PendingVerticalOffset { get; set; } - - /// Horizontal scroll offset to restore alongside . - public double? PendingHorizontalOffset { get; set; } - - /// - /// Snapshot of the editor's currently expanded foldings + a layout checksum, kept - /// up to date by . Read by - /// 's capture-on-navigate hook so the outgoing - /// history entry records which regions the user had open. - /// - public FoldingsViewState.Snapshot? LastKnownFoldings { get; set; } - - /// - /// Foldings snapshot to apply on the next document apply. Set by - /// when Back/Forward lands on an entry that - /// carries a captured foldings snapshot. The text view consumes this in its - /// ApplyDocument path right before installing the new FoldingManager. - /// - public FoldingsViewState.Snapshot? PendingFoldings { get; set; } + public System.Func? CaptureViewState { get; set; } /// - /// Invoked by just before recording a navigation - /// entry, so the text view can push its current foldings into - /// synchronously. AvaloniaEdit's FoldingManager has - /// no foldings-changed event we can subscribe to, so we snapshot on demand instead - /// of mirroring the per-event caret/scroll pattern. + /// View state to restore on the next document apply. Set by + /// when Back/Forward/GoTo lands on an entry that carries a captured state; consumed (and + /// cleared) by the text view in its ApplyDocument path so the value survives the + /// async gap between the navigation firing and the decompile finishing. /// - public System.Action? CaptureFoldingsState { get; set; } + public DecompilerTextViewState? PendingViewState { get; set; } /// /// Fired when the user clicks a cross-document reference. The host (DockWorkspace) diff --git a/ILSpy/TextView/DecompilerTextView.axaml.cs b/ILSpy/TextView/DecompilerTextView.axaml.cs index 6414f65b9..ff44fcdb7 100644 --- a/ILSpy/TextView/DecompilerTextView.axaml.cs +++ b/ILSpy/TextView/DecompilerTextView.axaml.cs @@ -31,6 +31,7 @@ using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Media; using Avalonia.Threading; +using Avalonia.VisualTree; using AvaloniaEdit.Folding; using AvaloniaEdit.Highlighting; @@ -78,6 +79,11 @@ namespace ILSpy.TextView // the previous DataContext to the event handler, and Dock recycles the view across // tabs (see App.axaml ControlRecyclingKey). DecompilerTabPageModel? boundModel; + // The editor's template ScrollViewer. AvaloniaEdit's TextEditor.ScrollViewer is internal, + // and its ScrollToVerticalOffset/ScrollToHorizontalOffset are no-ops in 12.0.0 + // (AvaloniaUI/AvaloniaEdit#594) -- so we reach the ScrollViewer ourselves and set Offset. + // TODO: drop this field and use Editor.ScrollToVerticalOffset once #594 ships and we bump the package. + ScrollViewer? editorScrollViewer; DisplaySettings? currentDisplaySettings; ReferenceSegment? lastTooltipSegment; ReferenceSegment? lastRightClickedSegment; @@ -179,12 +185,12 @@ namespace ILSpy.TextView WireUpDisplaySettings(); - // Push caret + scroll positions onto the bound tab model on every change so - // DockWorkspace can read the live state when recording a navigation away. Cheap - // — both events fire only on actual user motion and the model write is two - // integer / double assignments. + // Caret-position changes drive the bracket-pair highlight. View state (caret + scroll + // + foldings) is NOT pushed onto the model here -- DockWorkspace pulls it on demand via + // the model's CaptureViewState delegate when it records a navigation away, so a + // programmatic caret move (AvaloniaEdit jumps the caret to the end on a text replace) + // is never mistaken for the user's position. Editor.TextArea.Caret.PositionChanged += OnCaretPositionChanged; - Editor.TextArea.TextView.ScrollOffsetChanged += OnScrollOffsetChanged; // Ctrl+Wheel zoom (matches WPF's ZoomScrollViewer.OnPreviewMouseWheel). Tunnel // routing so we see it before AvaloniaEdit's scroll handler; Handled=true on @@ -283,8 +289,6 @@ namespace ILSpy.TextView void OnCaretPositionChanged(object? sender, EventArgs e) { - if (DataContext is DecompilerTabPageModel m) - m.LastKnownCaretOffset = Editor.TextArea.Caret.Offset; UpdateBracketHighlight(); } @@ -303,15 +307,6 @@ namespace ILSpy.TextView bracketHighlightRenderer.SetHighlight(result); } - void OnScrollOffsetChanged(object? sender, EventArgs e) - { - if (DataContext is DecompilerTabPageModel m) - { - m.LastKnownVerticalOffset = Editor.VerticalOffset; - m.LastKnownHorizontalOffset = Editor.HorizontalOffset; - } - } - /// /// Mirrors the live into the editor on construction and on /// every PropertyChanged after that. Lets the Options Display panel produce @@ -440,46 +435,40 @@ namespace ILSpy.TextView { base.OnDataContextChanged(e); - // Snapshot the outgoing model's folding state and detach its property handler - // BEFORE binding to the new one. Dock recycles a single DecompilerTextView across - // every document tab, so a tab switch lands here with the FoldingManager still - // holding the previous tab's collapsed / expanded state. Capture that into the - // outgoing model's LastKnownFoldings so the next ApplyDocument can restore it - // when the user clicks back. Without this the foldings reset to their default - // (open) state on every tab toggle. + // Detach the outgoing model's handlers before binding to the new one. Avalonia doesn't + // surface the previous DataContext to this event, so we track it ourselves. if (boundModel is { } previous && !ReferenceEquals(previous, DataContext)) { previous.PropertyChanged -= OnModelPropertyChanged; - if (activeFoldingManager is { } outgoingManager) - previous.LastKnownFoldings = FoldingsViewState.Capture(outgoingManager.AllFoldings); - // The delegate closes over `activeFoldingManager` (the view field), not the - // model's own state. Once we've switched to a new model the field is about - // to point at someone else's FoldingManager, so a late invocation from - // DockWorkspace.CaptureCurrentViewState would write THIS tab's foldings into - // the outgoing model's LastKnownFoldings. Drop the delegate so that path is - // a no-op; the snapshot we just took is correct and shouldn't be clobbered. - previous.CaptureFoldingsState = null; + previous.CaptureViewState = null; } boundModel = DataContext as DecompilerTabPageModel; if (boundModel is { } model) { model.PropertyChanged += OnModelPropertyChanged; - // Foldings have no per-change event on AvaloniaEdit; instead DockWorkspace asks - // the view for a fresh snapshot at navigate-away time. Assigning the delegate - // every DataContext-change handles both the first attach and an ABA reattach. - model.CaptureFoldingsState = () => SnapshotFoldingsInto(model); + // Let DockWorkspace pull the live view state from this editor on demand when it + // records a navigation away. (Re)assigning every DataContext-change handles both + // the first attach and an ABA reattach. + model.CaptureViewState = GetCurrentViewState; ApplyDocument(model); } } - void SnapshotFoldingsInto(DecompilerTabPageModel model) - { - if (activeFoldingManager is { } manager) - model.LastKnownFoldings = FoldingsViewState.Capture(manager.AllFoldings); - else - model.LastKnownFoldings = null; - } + // Reads the editor's live caret + scroll + expanded-foldings state. Invoked by + // DockWorkspace via DecompilerTabPageModel.CaptureViewState when it writes a navigation + // record, so the captured position reflects exactly what the user is looking at now. + DecompilerTextViewState GetCurrentViewState() => new( + Editor.TextArea.Caret.Offset, + Editor.VerticalOffset, + Editor.HorizontalOffset, + activeFoldingManager is { } manager ? FoldingsViewState.Capture(manager.AllFoldings) : null); + + // The editor's template ScrollViewer (cached once found). ?? re-evaluates while null so it + // resolves once the template is applied. + ScrollViewer? EditorScrollViewer => + editorScrollViewer ??= Editor.GetVisualDescendants().OfType().FirstOrDefault(); + void OnModelPropertyChanged(object? sender, PropertyChangedEventArgs e) { @@ -491,7 +480,9 @@ namespace ILSpy.TextView && (e.PropertyName == nameof(DecompilerTabPageModel.Text) || e.PropertyName == nameof(DecompilerTabPageModel.SyntaxExtension))) { - ApplyDocument(model); + // Restore caret/scroll/foldings only on the Text change (the final content); + // SyntaxExtension fires an intermediate rebuild that must not move the scroll. + ApplyDocument(model, restoreViewState: e.PropertyName == nameof(DecompilerTabPageModel.Text)); } // HighlightedReference can change independently of a re-decompile (e.g. the analyzer // pane sets it while the user is already on the right tab). Apply it directly without @@ -513,7 +504,11 @@ namespace ILSpy.TextView HighlightLocalReferences(model, model.HighlightedReference); } - void ApplyDocument(DecompilerTabPageModel model) + // restoreViewState: only the Text change carries the final decompiled content, so only it + // should consume PendingViewState and restore (or reset) caret/scroll/foldings. The + // intermediate SyntaxExtension change rebuilds the display but must NOT touch the scroll, + // or it resets to the top before the Text pass restores -- a visible flicker on Back. + void ApplyDocument(DecompilerTabPageModel model, bool restoreViewState = true) { // A new decompile invalidates any tooltip resolved against the previous document — // force-close even if the popup currently wants to stay (mouseClick: true). @@ -543,14 +538,15 @@ namespace ILSpy.TextView FoldingManager.Uninstall(activeFoldingManager); activeFoldingManager = null; } - // Consume the pending snapshot up-front so it can't bleed into a later refresh - // even when the new document has zero foldings to apply it to (e.g. a namespace - // summary page that follows a method-body navigation). PendingFoldings is set - // by Back / Forward navigation; LastKnownFoldings is captured on tab-switch so - // raw tab toggles restore state too -- prefer the explicit Pending if both - // exist (the history path wins over the recycled tab path). - var pendingFoldings = model.PendingFoldings ?? model.LastKnownFoldings; - model.PendingFoldings = null; + // Consume the pending view state up-front so it can't bleed into a later refresh even + // when the new document has zero foldings to apply it to (e.g. a namespace summary page + // that follows a method-body navigation). PendingViewState is set by Back/Forward/GoTo + // navigation; null on a fresh navigation, in which case the caret/scroll reset below + // puts the new document at the top. + var pendingState = restoreViewState ? model.PendingViewState : null; + if (restoreViewState) + model.PendingViewState = null; + var pendingFoldings = pendingState?.Foldings; if (model.Foldings is { Count: > 0 } foldings) { // Defensive clamp: drop foldings that fall outside [0, TextLength]. Without @@ -593,29 +589,32 @@ namespace ILSpy.TextView if (model.HighlightedReference != null) HighlightLocalReferences(model, model.HighlightedReference); - // Restore caret + scroll position captured when the user previously navigated - // away from this node. Back/Forward (and the dropdown-history "GoTo") set the - // Pending* fields just before triggering the decompile; tab switches don't, - // but LastKnown* is kept fresh by the per-event push from the view, so falling - // through Pending -> LastKnown lets a raw tab toggle restore the same state. - // Clear Pending* so a subsequent user-initiated decompile doesn't jump the - // cursor again. Clamp to the new document length -- text-changed-since-capture - // is the common case for Refresh. - if ((model.PendingCaretOffset ?? model.LastKnownCaretOffset) is { } caret) - { - model.PendingCaretOffset = null; - Editor.TextArea.Caret.Offset = Math.Clamp(caret, 0, Editor.Document.TextLength); - Editor.TextArea.Caret.BringCaretToView(); - } - if ((model.PendingVerticalOffset ?? model.LastKnownVerticalOffset) is { } vy) - { - model.PendingVerticalOffset = null; - Editor.ScrollToVerticalOffset(vy); - } - if ((model.PendingHorizontalOffset ?? model.LastKnownHorizontalOffset) is { } hx) - { - model.PendingHorizontalOffset = null; - Editor.ScrollToHorizontalOffset(hx); + // Restore caret + scroll for Back/Forward/GoTo (PendingViewState carries the position + // captured when the user left this node). On a fresh navigation there's no pending + // state, so reset to the top: the editor is reused across navigations and AvaloniaEdit + // keeps the previous document's caret/scroll when the text is replaced, which would + // otherwise leave a new node scrolled to wherever the previous one was. Clamp the caret + // to the new document length (text may have changed since capture, e.g. Refresh). + // AvaloniaEdit's ScrollToVerticalOffset/ScrollToHorizontalOffset are no-ops in 12.0.0 + // (fixed upstream in AvaloniaUI/AvaloniaEdit#594), so set the ScrollViewer's Offset + // directly. Avalonia re-coerces Offset against the scroll extent on the next layout, + // so this sticks even though the freshly-set document isn't measured yet. + if (restoreViewState) + { + if (pendingState is { } state) + { + Editor.TextArea.Caret.Offset = Math.Clamp(state.CaretOffset, 0, Editor.Document.TextLength); + if (EditorScrollViewer is { } scrollViewer) + scrollViewer.Offset = new Vector(state.HorizontalOffset, state.VerticalOffset); + } + else + { + // Fresh navigation: reset caret + scroll to the top. The reused editor keeps the + // previous document's position when the text is replaced, so reset explicitly. + Editor.TextArea.Caret.Offset = 0; + if (EditorScrollViewer is { } scrollViewer) + scrollViewer.Offset = default; + } } // Swap any tab-contributed visual-line element generators (e.g. About-page hyperlink @@ -635,8 +634,6 @@ namespace ILSpy.TextView } Editor.TextArea.TextView.Redraw(); - - Editor.ScrollToHome(); } void OnHyperlinkOpenUri(object? sender, AvaloniaEdit.Rendering.OpenUriRoutedEventArgs e) diff --git a/ILSpy/TextView/DecompilerTextViewState.cs b/ILSpy/TextView/DecompilerTextViewState.cs new file mode 100644 index 000000000..776e66ef3 --- /dev/null +++ b/ILSpy/TextView/DecompilerTextViewState.cs @@ -0,0 +1,38 @@ +// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +namespace ILSpy.TextView +{ + /// + /// Snapshot of the decompiler editor's view state at a moment in time: caret offset, scroll + /// offsets, and the set of expanded foldings. Pulled from the live editor when a navigation + /// record is written, and applied back when Back/Forward re-decompiles the node. + /// + /// + /// This replaces the per-axis LastKnown*/Pending* fields that used to be pushed onto the + /// view-model on every caret/scroll event. That push mistook AvaloniaEdit's programmatic + /// caret-to-end (performed when the document text is replaced) for a user move and recorded + /// it, poisoning the captured position. Reading on demand at capture time avoids that. + /// + /// + public readonly record struct DecompilerTextViewState( + int CaretOffset, + double VerticalOffset, + double HorizontalOffset, + FoldingsViewState.Snapshot? Foldings); +}