Skip to content

Commit 0129f01

Browse files
sjvrensburgclaude
andcommitted
fix(portals): skip redundant live crop (#190) + consolidate surface lifecycle (#192)
#190 — While the portal pop-out is popped out AND hosting the live confined viewport, the docked preview is hidden and the pop-out shows the live DocumentView, so the saved-tracking / auto-pin 300-DPI block crop has no on-screen consumer. RenderPortalTarget and PinAutoReference now elide the PDFium rasterise in that state (new PortalCropRedundant gate = popped-out && live), set _trackingCropSkipped, and still pin + aim the live viewport. Docking re-materialises the current target via RerenderDisplayedCrop so the re-revealed docked preview is never stale; _autoPin now carries the caption block so the float+caption union crop can be reproduced without re-parsing. A docked temporary peek is live but NOT popped out, so its visible docked preview still renders normally. PortalsView's empty-state hint now keys off DisplayedPortalId (a target is pinned) instead of the crop bitmap, which is now null while elided. #192 — Extracted BuildSecondarySurface / DisposeSecondarySurface in MainWindow.Panes.cs as the single shared core (new DocumentView + Initialize + BindViewport(ownsImages) + RegisterSurface / unregister + Teardown + SafeRemoveViewport). Split panes, tear-off windows, and the live portal viewport all route through them, so a future surface-lifecycle change lands in one place. The portal path keeps its genuine differences (own viewport creation/seating, not focused on create) at the call site. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 55f04a3 commit 0129f01

6 files changed

Lines changed: 168 additions & 50 deletions

File tree

src/RailReader2/ViewModels/MainWindowViewModel.PortalViewport.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,14 @@ private set
4747
/// <summary>The "no active portal" hint shows only when not live and there is no crop to show.</summary>
4848
public bool ShowPortalHint => !_isPortalLive && PortalWindowImage is null;
4949

50+
/// <summary>True when the saved-tracking / auto-pin crop (<see cref="ActivePortalImage"/>) has no
51+
/// on-screen consumer: the pop-out is popped out (the docked preview is hidden, showing the Dock
52+
/// button) AND hosting the live confined viewport (the pop-out's static crop is hidden). Rasterising
53+
/// the 300-DPI block crop is then pure waste (#190) — the render paths skip it and docking
54+
/// re-materialises the current target. NB: a docked temporary peek IS live but NOT popped out, so the
55+
/// visible docked preview still renders its saved-tracking crop.</summary>
56+
private bool PortalCropRedundant => IsPortalPoppedOut && _isPortalLive;
57+
5058
/// <summary>Raised when the pop-out's desired live target may have changed (a pin, peek, clear, or
5159
/// pop-out/dock). MainWindow re-syncs the hosted DocumentView (creating, re-aiming, or tearing it
5260
/// down). Raised only at genuine content-change points — never per animation frame — so the user can

src/RailReader2/ViewModels/MainWindowViewModel.Portals.cs

Lines changed: 99 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -82,11 +82,20 @@ public bool ShouldShowPortalWindow
8282
partial void OnIsPortalPoppedOutChanged(bool value)
8383
{
8484
OnPropertyChanged(nameof(ShouldShowPortalWindow));
85-
if (!value && _isPortalViewLocked)
85+
if (value) return;
86+
87+
// Docking. A per-pop-out view lock is released and tracking catches up to the reading position;
88+
// EvaluatePortals re-pins (and renders) if the position moved to a different portal while locked.
89+
if (_isPortalViewLocked)
8690
{
8791
ReleasePortalLock();
8892
EvaluatePortals(forceRender: true);
8993
}
94+
// The docked preview is visible again. While popped-out + live the tracking crop was skipped
95+
// (#190) — re-materialise the current target now so the preview isn't blank/stale. A no-op (no
96+
// re-rasterise) unless a crop was actually elided; if the unlock above already re-pinned to a
97+
// different target it cleared the flag, so this won't double-render.
98+
RerenderDisplayedCrop();
9099
}
91100

92101
// The pop-out window's content (PortalWindowImage/Label/Hint) and visibility (ShouldShowPortalWindow)
@@ -108,6 +117,11 @@ private void RaisePortalWindowProps()
108117
// True when the active portal's target page was not yet analysed at render time, so a forced pass
109118
// (target page just analysed) should retry instead of being debounced away.
110119
private bool _portalTargetPending;
120+
// True when the currently displayed target's tracking crop was deliberately NOT rasterised because
121+
// it had no on-screen consumer (popped-out + live: PortalCropRedundant) — so ActivePortalImage does
122+
// not reflect _displayedPortalId. Docking re-materialises it (RerenderDisplayedCrop, #190). Cleared
123+
// whenever the crop is actually (re-)rendered or the displayed pin is cleared.
124+
private bool _trackingCropSkipped;
111125
// Reading position (page, block, line) at the last evaluation — lets steady reading on one line
112126
// skip the whole match. Includes the line because line-precise sources can change the match as the
113127
// reading position advances line-by-line within a block.
@@ -133,10 +147,11 @@ private void RaisePortalWindowProps()
133147
// failure cannot re-trigger a full-page rasterisation on every pass. Cleared on tab switch.
134148
private readonly HashSet<string> _failedAutoPins = [];
135149
// Source/target blocks of the currently displayed auto pin, kept so the on-page markers can be
136-
// drawn for it (auto pins are never saved Portals, so BuildPortalMarkers has nothing else to read).
137-
// One field, set and cleared in lockstep with the "auto:" _displayedPortalId via SetAutoPin /
138-
// ClearAutoPin, so the two can never drift out of sync.
139-
private (int SrcPage, int SrcBlock, int SrcLine, int TgtPage, int TgtBlock)? _autoPin;
150+
// drawn for it (auto pins are never saved Portals, so BuildPortalMarkers has nothing else to read)
151+
// and so the float+caption union crop can be reproduced on a dock re-render (#190 — hence CaptionBlock).
152+
// One field, set and cleared in lockstep with the "auto:" _displayedPortalId, so the two can never
153+
// drift out of sync.
154+
private (int SrcPage, int SrcBlock, int SrcLine, int TgtPage, int TgtBlock, int CaptionBlock)? _autoPin;
140155
// _displayedPortalId values for auto pins, so they share the pin-until-different machinery with
141156
// saved portals without ever colliding with a saved portal's guid. The resolved target is part
142157
// of the identity, so a same-labelled but different float (numbering restarts across chapters)
@@ -309,6 +324,7 @@ private void ClearActivePortal()
309324
_displayedPortalId = null;
310325
_autoPin = null;
311326
_portalTargetPending = false;
327+
_trackingCropSkipped = false;
312328
SetPortalImage(null);
313329
ActivePortalLabel = null;
314330
PortalHint = NoActivePortalHint;
@@ -325,6 +341,7 @@ private void ResetDisplayedPortal()
325341
_displayedPortalId = null;
326342
_autoPin = null;
327343
_portalTargetPending = false;
344+
_trackingCropSkipped = false;
328345
ActivePortalLabel = null;
329346
PortalHint = NoActivePortalHint;
330347
InvalidatePortalMarkers();
@@ -350,7 +367,7 @@ internal void EvaluatePortals(bool forceRender = false)
350367
if (_controller.FocusedViewport?.Owner is not { } doc || ActiveTab is not { } tab)
351368
{
352369
ClearPortalPeek();
353-
ClearActivePortal();
370+
ClearActivePortal(); // also resets _trackingCropSkipped
354371
_portalImageOwner = null;
355372
_lastEvalReadingBlock = null;
356373
_autoRefPending = false;
@@ -386,6 +403,7 @@ internal void EvaluatePortals(bool forceRender = false)
386403
_displayedPortalId = null;
387404
_autoPin = null;
388405
_portalTargetPending = false;
406+
_trackingCropSkipped = false;
389407
_pendingTargetForLink = null;
390408
_pageSizeCache.Clear();
391409
_referenceIndex.Clear();
@@ -517,21 +535,30 @@ private void PinAutoReference(DocumentModel doc, ReferenceIndex.Reference refere
517535
string autoId = AutoPinId(reference, target);
518536
if (!doc.TryGetAnalysis(target.Page, out var targetAnalysis)) return;
519537
var blocks = targetAnalysis.Blocks;
520-
var region = Union(blocks[target.TargetBlock].BBox, blocks[target.CaptionBlock].BBox);
521-
var bitmap = RenderRegionCrop(doc, target.Page, region);
522-
if (bitmap is null)
538+
539+
// Render the float+caption union crop into the tracking preview — unless that preview has no
540+
// on-screen consumer right now (#190: popped-out + live shows the confined viewport instead and
541+
// re-renders on dock). The skip still pins + aims the live viewport; only the rasterise is elided.
542+
Bitmap? bitmap = null;
543+
if (!PortalCropRedundant)
523544
{
524-
// Remember the failure: a deterministic render failure must not re-trigger a full-page
525-
// rasterisation on every line advance / forced pass. Cleared on tab switch.
526-
_failedAutoPins.Add(autoId);
527-
return; // keep whatever was shown
545+
var region = Union(blocks[target.TargetBlock].BBox, blocks[target.CaptionBlock].BBox);
546+
bitmap = RenderRegionCrop(doc, target.Page, region);
547+
if (bitmap is null)
548+
{
549+
// Remember the failure: a deterministic render failure must not re-trigger a full-page
550+
// rasterisation on every line advance / forced pass. Cleared on tab switch.
551+
_failedAutoPins.Add(autoId);
552+
return; // keep whatever was shown
553+
}
528554
}
529555

530556
_displayedPortalId = autoId;
531-
_autoPin = (srcPage, srcBlock, srcLine, target.Page, target.TargetBlock);
557+
_autoPin = (srcPage, srcBlock, srcLine, target.Page, target.TargetBlock, target.CaptionBlock);
532558
_portalTargetPending = false;
559+
_trackingCropSkipped = bitmap is null; // skipped while redundant → re-render on dock
533560
ActivePortalLabel = $"{reference} (p.{target.Page + 1}) · auto";
534-
SetPortalImage(bitmap);
561+
SetPortalImage(bitmap); // null while redundant: releases the prior crop (nothing shows it now)
535562
PortalHint = null;
536563
InvalidatePortalMarkers(); // a previously accented saved portal loses the accent
537564
RaisePortalView(); // auto pin changed → (re-)aim the live portal viewport at the float
@@ -554,27 +581,84 @@ private void RenderPortalTarget(DocumentModel doc, int page, int block)
554581
if (!resolvable)
555582
{
556583
_portalTargetPending = true;
584+
_trackingCropSkipped = false; // nothing to skip — waiting on analysis
557585
SetPortalImage(null);
558586
PortalHint = "Resolving target…";
559587
RaisePortalView(); // unresolved → live view shows hint until the page is analysed
560588
return;
561589
}
562590

563591
_portalTargetPending = false;
592+
593+
// #190: while popped-out AND live, the docked preview is hidden and the pop-out shows the live
594+
// confined viewport — the 300-DPI crop has no on-screen consumer, so skip the rasterise. Docking
595+
// re-materialises it (RerenderDisplayedCrop). The live viewport renders the block itself, so it's
596+
// still aimed below via RaisePortalView.
597+
if (PortalCropRedundant)
598+
{
599+
_trackingCropSkipped = true;
600+
SetPortalImage(null); // release the prior crop (nothing on-screen consumes it now)
601+
PortalHint = null;
602+
RaisePortalView();
603+
return;
604+
}
605+
564606
var bitmap = RenderBlockCrop(doc, page, block);
565607
if (bitmap is null)
566608
{
609+
_trackingCropSkipped = false;
567610
SetPortalImage(null);
568611
PortalHint = "Could not render portal target.";
569612
// The live viewport renders the block directly (it doesn't need the crop), so still aim it.
570613
RaisePortalView();
571614
return;
572615
}
616+
_trackingCropSkipped = false;
573617
SetPortalImage(bitmap);
574618
PortalHint = null;
575619
RaisePortalView(); // saved target resolved → (re-)aim the live portal viewport at it
576620
}
577621

622+
/// <summary>Materialise the currently displayed target's tracking crop into <see cref="ActivePortalImage"/>
623+
/// when it was elided (#190). The render paths skip the rasterise while popped-out + live (the docked
624+
/// preview is hidden); docking re-reveals that preview, so the crop must be produced now or it would
625+
/// stay blank/stale. A no-op unless a crop was actually skipped (so a normal dock never re-rasterises).
626+
/// UI thread only (PDFium).</summary>
627+
private void RerenderDisplayedCrop()
628+
{
629+
if (!_trackingCropSkipped) return;
630+
if (_controller.FocusedViewport?.Owner is not { } doc || ActiveTab is not { } tab
631+
|| _displayedPortalId is null)
632+
{
633+
_trackingCropSkipped = false;
634+
return;
635+
}
636+
637+
if (_autoPin is { } a)
638+
{
639+
// Reproduce the auto pin's float+caption union crop (the stored CaptionBlock makes this
640+
// possible without re-parsing the reference). A render failure here just leaves the docked
641+
// preview blank; the flag clears either way so it can't retry-loop on every dock.
642+
_trackingCropSkipped = false;
643+
if (doc.TryGetAnalysis(a.TgtPage, out var analysis)
644+
&& a.TgtBlock >= 0 && a.TgtBlock < analysis.Blocks.Count
645+
&& a.CaptionBlock >= 0 && a.CaptionBlock < analysis.Blocks.Count)
646+
{
647+
var region = Union(analysis.Blocks[a.TgtBlock].BBox, analysis.Blocks[a.CaptionBlock].BBox);
648+
SetPortalImage(RenderRegionCrop(doc, a.TgtPage, region));
649+
}
650+
}
651+
else if (tab.Portals.Portals.FirstOrDefault(p => p.Id == _displayedPortalId) is { } portal)
652+
{
653+
// Now docked (not redundant), RenderPortalTarget rasterises and clears _trackingCropSkipped.
654+
RenderPortalTarget(doc, portal.Target.Page, ResolveAnchorBlock(doc, portal.Target));
655+
}
656+
else
657+
{
658+
_trackingCropSkipped = false; // displayed id no longer matches a saved/auto pin
659+
}
660+
}
661+
578662
/// <summary>Rasterise a detected block region and decode it to an Avalonia <see cref="Bitmap"/>,
579663
/// or null if the page/block is unavailable or rendering fails. UI thread only (PDFium).</summary>
580664
private Bitmap? RenderBlockCrop(DocumentModel doc, int page, int block)

src/RailReader2/Views/MainWindow.DocumentWindows.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ private void OnDocumentWindowClosed(object? sender, EventArgs e)
8080
// Was this window's viewport the focused one? Capture before disposal re-points focus.
8181
bool wasFocused = Vm is { } vm0
8282
&& ReferenceEquals(vm0.Controller.FocusedViewport, view.SurfaceViewport);
83-
DisposeSecondaryView(view);
83+
DisposeSecondarySurface(view);
8484

8585
// Closing the FOCUSED window: Core re-points FocusedViewport to the primary, but the desktop
8686
// focus projections (ambient size, status-bar zoom, rail toolbar, menu gating) are only
@@ -98,7 +98,7 @@ private bool CloseFocusedDocumentWindow()
9898
foreach (var win in _documentWindows)
9999
if (win.HostedView is { } v && ReferenceEquals(v.SurfaceViewport, focused))
100100
{
101-
win.Close(); // → OnDocumentWindowClosed → DisposeSecondaryView
101+
win.Close(); // → OnDocumentWindowClosed → DisposeSecondarySurface
102102
return true;
103103
}
104104
return false;

src/RailReader2/Views/MainWindow.Panes.cs

Lines changed: 39 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,43 @@ private void TeardownPanes(MainWindowViewModel vm)
6363
CloseAllDocumentWindows();
6464
}
6565

66-
// ── Shared surface lifecycle (reused by split panes and tear-off windows) ──────────────
66+
// ── Shared surface lifecycle (split panes, tear-off windows, AND the live portal viewport) ──────
67+
//
68+
// The DocumentView wiring half of "spin up a secondary surface" is identical for all three: a fresh
69+
// DocumentView, model/pref wiring, its own ViewportImages (ownsImages), then surface registration.
70+
// BuildSecondarySurface / DisposeSecondarySurface are that single shared core (#192) — the callers
71+
// own the genuinely different parts: how the viewport is created + seated, whether the new surface
72+
// is focused, and where the view is hosted (PaneGrid, a tear-off window, or the portal pop-out).
73+
74+
/// <summary>Bind a fresh chrome-carrying DocumentView to an already-created <paramref name="vp"/> and
75+
/// register it as a tickable surface. The shared core of every secondary surface; the caller created
76+
/// + seated the viewport and decides focus + hosting. Null if there's no active tab.</summary>
77+
private DocumentView? BuildSecondarySurface(Viewport vp)
78+
{
79+
if (Vm is not { } vm || vm.ActiveTab is not { } tab) return null;
80+
var view = new DocumentView();
81+
view.Initialize(vm, tab); // model wiring (annotations / prefs)
82+
view.BindViewport(vp, new ViewportImages(vp), ownsImages: true); // its own page/minimap images
83+
vm.RegisterSurface(view);
84+
return view;
85+
}
6786

68-
/// <summary>Create a fresh viewport on the active document and a DocumentView bound to it,
69-
/// opening on the focused view's current page. Registers it as a surface. Null if no document.</summary>
87+
/// <summary>Tear down a surface built by <see cref="BuildSecondarySurface"/>: unregister it from the
88+
/// frame loop, retire its owned images on the composition thread, then remove + dispose its viewport
89+
/// from the owning model (Core re-points focus off a removed focused view to that doc's primary).
90+
/// Shared by split panes, tear-offs, and the live portal viewport (#192).</summary>
91+
private void DisposeSecondarySurface(DocumentView view)
92+
{
93+
if (Vm is not { } vm) return;
94+
var vp = view.SurfaceViewport;
95+
vm.UnregisterSurface(view);
96+
view.Teardown(); // retires its owned page/minimap images + freeze crops on the composition thread (#180, #191)
97+
if (vp is { } v)
98+
vm.SafeRemoveViewport(v); // safe whether the model is alive or already disposed
99+
}
100+
101+
/// <summary>Create a fresh viewport on the active document and a DocumentView bound to it, opening on
102+
/// the focused view's current page (split panes / tear-offs). Null if no document.</summary>
70103
private DocumentView? CreateSecondaryView()
71104
{
72105
if (Vm is not { } vm || vm.ActiveTab is not { } tab) return null;
@@ -82,23 +115,7 @@ private void TeardownPanes(MainWindowViewModel vm)
82115
// rail synchronously, otherwise it schedules analysis and the fan-out seats it when it arrives.
83116
doc.SubmitAnalysis(vp, vm.Controller.Worker, vm.Controller.Config.NavigableRoles);
84117

85-
var view = new DocumentView();
86-
view.Initialize(vm, tab); // model wiring (annotations / prefs)
87-
view.BindViewport(vp, new ViewportImages(vp), ownsImages: true); // its own page/minimap images
88-
vm.RegisterSurface(view);
89-
return view;
90-
}
91-
92-
/// <summary>Unregister a secondary surface, dispose its images, and remove + dispose its viewport
93-
/// from its owning document. Core re-points focus off a removed focused view to that doc's primary.</summary>
94-
private void DisposeSecondaryView(DocumentView view)
95-
{
96-
if (Vm is not { } vm) return;
97-
var vp = view.SurfaceViewport;
98-
vm.UnregisterSurface(view);
99-
view.Teardown(); // retires its owned page/minimap images + freeze crops on the composition thread (#180, #191)
100-
if (vp is { } v)
101-
vm.SafeRemoveViewport(v); // shared with the portal viewport teardown (MainWindowViewModel)
118+
return BuildSecondarySurface(vp);
102119
}
103120

104121
// ── Split panes ────────────────────────────────────────────────────────────────────────
@@ -132,7 +149,7 @@ private void RemovePane(DocumentView pane)
132149
if (idx < 0) return;
133150

134151
_panes.RemoveAt(idx);
135-
DisposeSecondaryView(pane); // Core re-points focus to Primary if this pane was focused
152+
DisposeSecondarySurface(pane); // Core re-points focus to Primary if this pane was focused
136153
RebuildPaneGrid();
137154

138155
// Move focus to a neighbouring remaining pane (Core already fell back to Primary; refine it).
@@ -147,7 +164,7 @@ private void OnCloseExtraSurfaces()
147164
// Remove every docked pane except the primary.
148165
for (int i = _panes.Count - 1; i >= 1; i--)
149166
{
150-
DisposeSecondaryView(_panes[i]);
167+
DisposeSecondarySurface(_panes[i]);
151168
_panes.RemoveAt(i);
152169
}
153170
RebuildPaneGrid();

0 commit comments

Comments
 (0)