Files
mmd-pdf/Controls/Viewer/PdfViewer.Viewport.cs
T

2066 lines
120 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Media.Imaging;
using System.Windows.Shapes;
using Docnet.Core;
using Docnet.Core.Models;
using Microsoft.Win32;
using PdfSharpCore.Drawing;
using PdfSharpCore.Pdf;
using PdfSharpCore.Pdf.IO;
using KillerPDF.Services;
using PdfPigDoc = UglyToad.PdfPig.PdfDocument;
namespace KillerPDF.Controls
{
// Page viewport: builds the page tiles and annotation overlays for all four view modes (single,
// continuous, two-page, grid) and handles preview scrolling.
//
// Moved from Shell/Viewport.cs; this namespace and class line are the only changes. RenderPage /
// SetupContinuousView / AddSecondaryTile / WirePageOverlay and the _pages / _continuousCanvases
// invariant have to stay together - CLAUDE.md documents why splitting them repoints every page
// at the hidden primary tile.
//
// The body spells window things bare (PageList, _doc, Loc, RenderAllAnnotations, ...). Those
// resolve through PdfViewer.Bridge.cs, which forwards each one to Owner. That is what keeps this
// file byte-identical with its pre-move form instead of rewriting ~700 references.
public partial class PdfViewer
{
// ── Dark mode: image regions excluded from the inversion (#135 follow-up) ──────────────
// Cache keyed by "page|file" so tab switches and temp reloads (which change the temp file
// path) can never serve another document's rects; flushed with the render caches. Thread-
// safe because the continuous / secondary-tile workers fill it off the UI thread.
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, BitmapHelpers.FracRect[]>
_pageImageRects = new();
/// <summary>The page's image boxes for the inversion carve-out, cached per (file, page).
/// On a miss, opens PdfPig into the caller's ref (so a worker loop pays ONE open however
/// many pages it fills) - the caller disposes it; a held handle on the temp file would
/// block the save-time file swap. Encrypted or unparsable pages cache an empty set, which
/// falls back to inverting everything - the pre-carve-out behavior.</summary>
private BitmapHelpers.FracRect[] ImageRectsFor(string file, int page, ref PdfPigDoc? pig)
{
// Opt-in full inversion (moon right-click, "Invert images too"): no carve-out, and
// no PdfPig open paid for rects nobody will use.
if (BitmapHelpers.DocInvertImages) return [];
string key = page + "|" + file;
if (_pageImageRects.TryGetValue(key, out var hit)) return hit;
BitmapHelpers.FracRect[] rects;
try
{
pig ??= PdfPigDoc.Open(file);
rects = PdfImages.GetFracRects(pig, page);
}
catch { rects = []; }
_pageImageRects[key] = rects;
return rects;
}
/// <summary>Attach the scroll handler. The window used to wire this in its constructor, but
/// PagePreviewPanel is inside this control's namescope now, so the control does it - called
/// from the MainWindow ctor at the point the += used to sit.</summary>
internal void WireScrollChanged()
=> PagePreviewPanel.ScrollChanged += PagePreviewPanel_ScrollChanged;
/// <summary>Drop the per-page image-rect cache (the night-mode carve-out). Called by the
/// window's FlushAllRenderCaches when the invert state flips; it re-fills lazily.</summary>
internal void FlushImageRectCache() => _pageImageRects.Clear();
internal void ScrollContinuousToPage(int pageIndex)
{
if (pageIndex < 0 || pageIndex >= _continuousTops.Count) return;
double target = _continuousTops[pageIndex] * _zoomLevel;
PagePreviewPanel.ScrollToVerticalOffset(target);
}
// ── Current-page badge (#197, thanks Ryokoxx) ─────────────────────────────────────────
// One viewport-corner badge showing "page / total", replacing the per-tile tooltips that
// trailed the cursor. Slides up on scroll or page change, slides back down after idle.
private System.Windows.Threading.DispatcherTimer? _pageBadgeTimer;
private void ShowPageBadge(int page)
{
if (_doc is null || _doc.PageCount < 2) return;
ShowPageBadgeText($"{page + 1} / {_doc.PageCount}");
}
// #197 follow-up: one number says the least in Grid, where several tiles share the screen -
// the badge names the visible span there instead ("2-5 / 50").
private void ShowPageBadgeSpan(int first, int last)
{
if (_doc is null || _doc.PageCount < 2) return;
ShowPageBadgeText(first == last
? $"{first + 1} / {_doc.PageCount}"
: $"{first + 1}-{last + 1} / {_doc.PageCount}");
}
private void ShowPageBadgeText(string text)
{
PageBadgeText.Text = text;
PageBadgeSlide.BeginAnimation(TranslateTransform.YProperty,
new DoubleAnimation(0, TimeSpan.FromMilliseconds(140))
{ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } });
PageBadge.BeginAnimation(OpacityProperty,
new DoubleAnimation(1, TimeSpan.FromMilliseconds(120)));
if (_pageBadgeTimer is null)
{
_pageBadgeTimer = new System.Windows.Threading.DispatcherTimer
{ Interval = TimeSpan.FromMilliseconds(900) };
_pageBadgeTimer.Tick += (_, _) =>
{
_pageBadgeTimer!.Stop();
PageBadgeSlide.BeginAnimation(TranslateTransform.YProperty,
new DoubleAnimation(46, TimeSpan.FromMilliseconds(220))
{ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseIn } });
PageBadge.BeginAnimation(OpacityProperty,
new DoubleAnimation(0, TimeSpan.FromMilliseconds(220)));
};
}
_pageBadgeTimer.Stop();
_pageBadgeTimer.Start();
}
private void PagePreviewPanel_ScrollChanged(object sender, ScrollChangedEventArgs e)
{
// The vertical scrollbar can appear/disappear without a window resize (zoom, page count
// changes). When it does, re-anchor the annotate bars so a right-docked bar tracks the
// scrollbar's edge instead of getting covered (or stranded once it's gone).
bool vis = PagePreviewPanel.ComputedVerticalScrollBarVisibility == Visibility.Visible;
if (vis != _vScrollVisible)
{
_vScrollVisible = vis;
RepositionAnnotationBars();
}
if (_viewMode == ViewMode.Continuous && _continuousTops.Count > 0)
{
double viewportCenter = (PagePreviewPanel.VerticalOffset + PagePreviewPanel.ViewportHeight * 0.5)
/ Math.Max(0.01, _zoomLevel);
int nearest = 0;
double minDist = double.MaxValue;
for (int i = 0; i < _continuousTops.Count; i++)
{
if (i >= _continuousPanel.Children.Count) break;
var slot = (FrameworkElement)_continuousPanel.Children[i];
double center = _continuousTops[i] + slot.Height * 0.5;
double dist = Math.Abs(center - viewportCenter);
if (dist < minDist) { minDist = dist; nearest = i; }
}
// #197: surface the position badge on real scrolling, focused pane or not - the
// badge is per-pane furniture, unlike the page-jump box synced below.
if (e.VerticalChange != 0) ShowPageBadge(nearest);
SyncCurrentPageTo(nearest);
// Once the scroll settles, sharpen the pages now in view (and release the ones that left).
// Cheap when there's nothing to do: below the hi-res threshold it's a restore-only pass over
// an (almost always empty) set (#85).
StartRerenderTimer();
return;
}
// Grid scrolls through this same ScrollViewer but never tracked the current page, so the
// statusbar counter, the jump box, and anything reading PageList.SelectedIndex (e.g. the
// page a new bookmark targets, #133) went stale the moment the user scrolled. Track the
// tile nearest the viewport center, exactly like Continuous. TranslatePoint to the scroll
// content root already includes the zoom LayoutTransform, so no manual scaling is needed.
if (_viewMode == ViewMode.Grid && _pageContentPanel.Children.Count > 1
&& PagePreviewPanel.Content is FrameworkElement gridRoot)
{
double viewTop = PagePreviewPanel.VerticalOffset;
double viewBottom = viewTop + PagePreviewPanel.ViewportHeight;
double viewCenter = viewTop + PagePreviewPanel.ViewportHeight * 0.5;
int nearest = -1, firstVis = -1, lastVis = -1;
double minDist = double.MaxValue;
for (int i = 0; i < _pageContentPanel.Children.Count; i++)
{
if (_pageContentPanel.Children[i] is not FrameworkElement tile || tile.ActualHeight <= 0)
continue;
double top = tile.TranslatePoint(new Point(0, 0), gridRoot).Y;
double bottom = tile.TranslatePoint(new Point(0, tile.ActualHeight), gridRoot).Y;
double dist = Math.Abs((top + bottom) * 0.5 - viewCenter);
if (dist < minDist) { minDist = dist; nearest = i; }
if (bottom > viewTop && top < viewBottom)
{
if (firstVis < 0) firstVis = i;
if (i > lastVis) lastVis = i;
}
}
// #197 follow-up: grid scrolling never raised the badge (it only fired on a page
// change), and one page number can't say which tiles are on screen - show the span.
if (e.VerticalChange != 0 && firstVis >= 0) ShowPageBadgeSpan(firstVis, lastVis);
if (nearest >= 0) SyncCurrentPageTo(nearest);
}
}
// Reflects a scroll-derived current page in the jump box, the sidebar selection, and the
// statusbar page counter - without re-rendering (the selection handler is detached while
// the index is set, matching the original Continuous sync).
private void SyncCurrentPageTo(int nearest)
{
// Everything below is window chrome that describes the FOCUSED pane - the one sidebar
// list, the one jump box, the one status line. An unfocused pane scrolling must not
// overwrite them with its own page number.
if (Host != null && !Host.IsViewerFocused(this)) return;
if (_currentPage == nearest)
{
Host?.ViewerPageChanged(this, nearest);
if (Host != null) Host.PageJumpText = (nearest + 1).ToString();
Host?.EnsureSidebarPageVisible(this, nearest);
return;
}
if (Host != null) Host.PageJumpText = (nearest + 1).ToString();
// Reentrancy FLAG, not a detach/attach pair. The PageList's real subscription is the
// WINDOW's XAML-bound stub, so `-=` of this pane's own delegate removed NOTHING and
// the `+=` stacked this pane's handler onto the shared list as an EXTRA direct
// subscription - one more per scroll-sync, forever (found via zoomtrace, 2026-08-01,
// alongside the identical ZoomBox pattern).
_syncingPageList = true;
try { _currentPage = nearest; }
finally { _syncingPageList = false; }
// Keep the selected thumbnail in view. ScrollIntoView lives in
// PageList_SelectionChanged, which is detached above - so the scroll-driven path moved
// the highlight but never scrolled the sidebar, and the selection walked off the end
// of the visible list as the document scrolled.
Host?.EnsureSidebarPageVisible(this, _currentPage);
// This is the one write that detaches the handler (to avoid re-entering the render path
// from a scroll sync), so it is also the one that would slip past the mirror in
// PageList_SelectionChanged. Set it by hand.
State.CurrentPage = nearest;
if (_doc is not null)
SetStatus(string.Format(Loc("Str_PageOf"), nearest + 1, _doc.PageCount) + $" - {DisplayZoomPct():F0}%");
}
// Common overlay wiring shared by the continuous and secondary-tile builders: the move/up
// gesture handlers, the shared right-click context menu (per-page overlays don't inherit the
// primary's ContextMenu), and registration in both page maps. The mouse-DOWN handler and the
// overlay's size/layout are caller-specific, so those stay in the callers.
private void WirePageOverlay(Canvas overlay, int page)
{
overlay.MouseMove += Canvas_MouseMove;
overlay.MouseLeave += Canvas_MouseLeave;
overlay.PreviewMouseLeftButtonUp += Canvas_MouseLeftButtonUp;
overlay.PreviewMouseRightButtonUp += (s, ev) =>
{
// #128: Continuous never click-selects (current page is viewport-driven); the menu
// below is populated for the clicked page directly. Grid keeps the click highlight.
if (_viewMode == ViewMode.Grid) _currentPage = page;
if (_annotationCanvas.ContextMenu is ContextMenu cm)
{
// Selection chrome draws on _activeCanvas, so point it at this tile before populating.
_activeCanvas = (Canvas)s;
PopulateContextMenu(ev.GetPosition((Canvas)s), page);
cm.PlacementTarget = (UIElement)s;
cm.IsOpen = true;
ev.Handled = true;
}
};
_continuousCanvases[page] = overlay;
_pages[page] = overlay;
}
// Builds a page's annotation overlay. Size/transform differ by mode (continuous = render-dim + scale;
// grid/two-page = DIP 1:1); everything else - background, clip, tag, input handler - is identical.
private Canvas BuildPageOverlay(int page, double width, double height, System.Windows.Media.Transform? layoutTransform)
{
var overlay = new Canvas
{
Width = width,
Height = height,
Background = Brushes.Transparent,
ClipToBounds = true,
Tag = page
};
if (layoutTransform != null) overlay.LayoutTransform = layoutTransform;
overlay.PreviewMouseLeftButtonDown += Canvas_MouseLeftButtonDown;
WirePageOverlay(overlay, page);
// #197: no per-tile page tooltip anymore - it trailed the cursor and read as noise.
// The viewport-corner badge (ShowPageBadge) is the page indicator now.
return overlay;
}
// Build tile-0 (the primary page) and insert it at the head of the page panel. Wiring:
// left-down/move/leave/left-up, plus the attached ContextMenu set later in BuildContextMenu.
// It deliberately does NOT use WirePageOverlay - the primary must stay OUT of _continuousCanvases, and
// RenderPage remains the sole registrar of _pages[primary], preserving ClearSecondaryPages' "keep the
// index-0 tile" contract. Runs once from the constructor after _pageContentPanel is resolved.
internal void BuildPrimaryTile()
{
var img = new Image { Stretch = Stretch.None };
RenderOptions.SetBitmapScalingMode(img, BitmapScalingMode.HighQuality);
var overlay = new Canvas { Background = Brushes.Transparent, ClipToBounds = true };
overlay.PreviewMouseLeftButtonDown += Canvas_MouseLeftButtonDown;
overlay.MouseMove += Canvas_MouseMove;
overlay.MouseLeave += Canvas_MouseLeave;
overlay.PreviewMouseLeftButtonUp += Canvas_MouseLeftButtonUp;
var grid = new Grid();
grid.Children.Add(img);
grid.Children.Add(overlay);
var tile = new Border
{
Background = Brushes.White,
VerticalAlignment = VerticalAlignment.Top,
Margin = new Thickness(0, 0, 12, 12),
Child = grid,
};
_pageContentPanel.Children.Insert(0, tile);
PageImage = img;
_annotationCanvas = overlay;
}
internal void SetupContinuousView(int initialPage, bool fitDefault = true)
{
if (_doc is null) return;
// #130: a malformed page tree can parse to zero pages - Pages[0] below would throw
// ArgumentOutOfRangeException. Bail out instead of crashing; the view just stays empty.
if (_doc.PageCount == 0) return;
// Coming from Grid, the shared ScrollViewer still carries the grid's overrides
// (horizontal bar Disabled, vertical Visible). Continuous never passes through
// RefreshPageView, where the other modes restore them - so restore here, or a
// zoomed-in continuous view gets clamped to the viewport width: the page renders
// clipped with dead side margins and no horizontal scrollbar to reach the rest.
PagePreviewPanel.HorizontalScrollBarVisibility = ScrollBarVisibility.Auto;
PagePreviewPanel.VerticalScrollBarVisibility = ScrollBarVisibility.Auto;
// Grid zeroes the document-surface padding and Two-Page centers it vertically;
// restore both here for the same reason as the scrollbar overrides above.
DocSurfacePad.Padding = new Thickness(12);
DocSurfacePad.VerticalAlignment = VerticalAlignment.Top;
_continuousRenderCts?.Cancel();
_continuousPanel.Children.Clear();
_continuousTops.Clear();
_continuousCanvases.Clear();
_continuousLinks.Clear();
_pages.Clear();
// Fresh slot set: any hi-res re-sharpen bookkeeping from the previous layout is stale.
// (This reset used to live in the render pass, back when it repainted every slot.)
_continuousSharpenCts?.Cancel();
_continuousSharpPages.Clear();
_continuousSharpW = 0;
// Use the PDF's natural page width in WPF DIPs (96 DIP/inch, 72 pt/inch).
// This is zoom-independent, which is critical: FitToWidth computes
// zoom = viewportW / _continuousPageW
// and if _continuousPageW were derived from the current zoom level the two
// would cancel and FitToWidth would always return approximately the old zoom.
var refPage = _doc.Pages[0];
_continuousPageW = Math.Max(200.0, refPage.Width.Point * (96.0 / 72.0));
double y = 0;
for (int i = 0; i < _doc.PageCount; i++)
{
_continuousTops.Add(y);
var pdfPage = _doc.Pages[i];
double pw = pdfPage.Width.Point, ph = pdfPage.Height.Point;
if (_pageRotations.TryGetValue(i, out int prot) && (prot == 90 || prot == 270))
(pw, ph) = (ph, pw);
// Scaffold: reuse this tab's cached render dimensions (from a prior render of this page) so
// the frame is built at its REAL size up front. On a tab switch the page slots are already the
// right shape - no dark estimate-sized box that resizes when the bitmap finally streams in.
// Fall back to the page-box estimate only the first time a page is laid out. Both are the same
// canonical render-dim space (longest side -> 2048), so annotation coordinates stay identical.
int rdW, rdH;
if (_renderDims.TryGetValue(i, out var cachedDims) && cachedDims.w > 0 && cachedDims.h > 0)
{
rdW = cachedDims.w;
rdH = cachedDims.h;
}
else
{
double maxDim = Math.Max(pw, ph);
rdW = Math.Max(1, (int)Math.Round(2048.0 * pw / maxDim));
rdH = Math.Max(1, (int)Math.Round(2048.0 * ph / maxDim));
_renderDims[i] = (rdW, rdH);
}
double slotH = _continuousPageW * rdH / (double)rdW;
double slotScale = _continuousPageW / rdW;
var overlay = BuildPageOverlay(i, rdW, rdH, new System.Windows.Media.ScaleTransform(slotScale, slotScale));
var pageImg = new Image { Stretch = Stretch.None, Width = _continuousPageW, Height = slotH };
RenderOptions.SetBitmapScalingMode(pageImg, BitmapScalingMode.HighQuality);
var slotGrid = new Grid();
slotGrid.Children.Add(pageImg);
slotGrid.Children.Add(overlay);
// Record this page's links so they're clickable in continuous view (resolved by the
// bounds-check in Canvas_MouseLeftButtonDown). Without this, links never work in scrolling view.
AddSecondaryPageLinks(i, rdW, rdH);
var placeholder = new Border
{
Width = _continuousPageW,
Height = slotH,
Margin = new Thickness(0, 0, 0, 12),
Background = Brushes.White, // empty-page scaffold while the bitmap streams in (not a dark box)
Tag = i,
Child = slotGrid
};
// #128: no click-to-select in Continuous. The current page follows the viewport via
// scroll-sync (like Acrobat/Sumatra), so a click must not scroll or move the counter.
_continuousPanel.Children.Add(placeholder);
y += slotH + 12;
}
// Paint existing annotations onto the freshly built per-page overlays so they show
// immediately. Without this they stayed invisible until the next tool/page change
// happened to trigger a render for that page.
foreach (var annotPage in _annotations.Keys.ToList())
if (_continuousCanvases.ContainsKey(annotPage))
RenderAllAnnotations(annotPage);
// Re-apply the view's zoom now that _continuousPageW is known. Honor the saved fit mode; for a
// custom (None) zoom, keep the exact level on a tab restore (fitDefault=false) instead of snapping
// to fit-page - otherwise switching tabs in continuous mode loses the user's zoom. Fresh opens and
// view-mode switches pass fitDefault=true and still default to fit-page.
if (_fitMode == FitMode.Width) FitToWidth();
else if (_fitMode == FitMode.Page) FitToPage();
else if (fitDefault) FitToPage();
else SetZoom(_zoomLevel);
_continuousScrollTarget = initialPage;
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded,
() => ScrollContinuousToPage(initialPage));
_ = RenderContinuousPages(initialPage);
}
// ── Continuous view virtualization (#122) ──────────────────────────────────────────────
// Continuous used to rasterize EVERY page at open and keep every slot's bitmap alive for
// the life of the document - a 243-page image PDF pinned gigabytes. Now only a window of
// pages around the viewport holds real bitmaps: the render pass below fills the window,
// and VirtualizeContinuousSlots (scroll settle) releases bitmaps that scrolled far away
// and requests the ones approaching. Released slots keep their exact height, so scroll
// geometry never changes; they show the white scaffold until re-rendered (render cache
// hits re-attach instantly).
private const int ContinuousKeepPages = 10; // pages each side of the viewport that get bitmaps
private const int ContinuousReleasePages = 15; // release only beyond this (hysteresis, no edge churn)
internal async System.Threading.Tasks.Task RenderContinuousPages(int centerPage)
{
if (_doc is null || _currentFile is null) return;
_continuousRenderCts?.Cancel();
_continuousRenderCts = new System.Threading.CancellationTokenSource();
var cts = _continuousRenderCts;
string currentFile = _currentFile;
int pageCount = _doc.PageCount;
double targetW = _continuousPageW;
int renderW = Math.Max(800, Math.Min(2048, (int)(targetW * 2)));
// Window of pages to materialize. Slots that already hold a base bitmap are skipped;
// slots holding a hi-res re-sharpened bitmap belong to the resharpen pass, skip too.
int lo = Math.Max(0, centerPage - ContinuousKeepPages);
int hi = Math.Min(pageCount - 1, centerPage + ContinuousKeepPages);
var todo = new List<int>();
for (int i = lo; i <= hi; i++)
{
if (i >= _continuousPanel.Children.Count) break;
if (_continuousPanel.Children[i] is Border b && b.Child is Grid g
&& g.Children.Count > 0 && g.Children[0] is Image img && img.Source == null)
todo.Add(i);
}
if (todo.Count == 0) return;
// Capture per-page rotations on the UI thread before going async
var rotations = new Dictionary<int, int>(_pageRotations);
var session = _active;
await System.Threading.Tasks.Task.Run(() =>
{
Docnet.Core.Readers.IDocReader? docReader = null;
PdfPigDoc? pig = null; // opened lazily by ImageRectsFor on the first uncached page
try
{
foreach (int i in todo)
{
if (cts.IsCancellationRequested) return;
int rot = rotations.TryGetValue(i, out int rr) ? rr : 0;
// Cache hit: skip pdfium, just attach the cached bitmap to its slot.
var cb = TryGetCachedRender(session, i, renderW, rot);
if (cb != null)
{
int fic = i;
Dispatcher.Invoke(() =>
{
if (cts.IsCancellationRequested || _viewMode != ViewMode.Continuous) return;
SetContinuousSlot(fic, cb);
});
continue;
}
docReader ??= DocLib.Instance.GetDocReader(currentFile, new PageDimensions(renderW, renderW * 2));
using var pr = docReader.GetPageReader(i);
int w = pr.GetPageWidth();
int h = pr.GetPageHeight();
var raw = PdfiumInterop.RenderPageWithAnnotations(currentFile, i, w, h,
includeFormFields: false) // live overlays show the values; baking them too ghosts the text
?? pr.GetImage(); // #141: draw the file's own markup without Docnet form fill
if (w <= 0 || h <= 0 || raw is null) continue;
// #135: display-only dark mode, pictures excluded. Invert BEFORE the
// pixel-buffer rotation (the ops commute for the full page) so the image
// carve-out rects stay in unrotated page space.
if (DocInvert)
BitmapHelpers.InvertBgraInPlaceExcept(raw, w, h, ImageRectsFor(currentFile, i, ref pig));
if (rot != 0)
(raw, w, h) = BitmapHelpers.RotateBitmap(raw, w, h, rot);
int fi = i, fw = w, fh = h, prot = rot;
byte[] bytes = raw;
if (cts.IsCancellationRequested) return;
// Use the window's own dispatcher, not Application.Current.Dispatcher: during app
// shutdown Application.Current goes null and this background render would NRE.
Dispatcher.Invoke(() =>
{
if (cts.IsCancellationRequested || _viewMode != ViewMode.Continuous) return;
if (fi >= _continuousPanel.Children.Count) return;
// Render at the natural continuous width (zoom is a LayoutTransform, so the bitmap
// is zoom-independent and reusable). Square pixels via the matching dipH.
int ddw = Math.Max(1, (int)Math.Round(targetW));
int ddh = Math.Max(1, (int)Math.Round(targetW * fh / fw));
var bmp = BitmapHelpers.BuildScaledBitmap(fw, fh, bytes, ddw, ddh);
CacheRender(session, fi, renderW, prot, bmp);
SetContinuousSlot(fi, bmp);
});
}
}
catch { /* render canceled or doc closed */ }
finally { docReader?.Dispose(); pig?.Dispose(); }
}, cts.Token);
}
// Attaches a (cached or freshly built) page bitmap to its continuous slot and finalizes the slot /
// overlay sizes + scroll offsets. UI thread. Shared by the cache-hit and freshly-rendered paths.
private void SetContinuousSlot(int fi, System.Windows.Media.Imaging.BitmapSource bmp)
{
if (fi < 0 || fi >= _continuousPanel.Children.Count) return;
var slot = (Border)_continuousPanel.Children[fi];
int fw = bmp.PixelWidth, fh = bmp.PixelHeight;
double dipW = slot.Width;
double dipH = dipW * fh / fw;
if (slot.Child is not Grid slotGrid || slotGrid.Children.Count == 0 || slotGrid.Children[0] is not Image pageImg)
return;
pageImg.Source = bmp;
pageImg.Width = dipW;
pageImg.Height = dipH;
slot.Background = Brushes.White;
// A base attach over a re-sharpened slot supersedes the hi-res bitmap; the resharpen
// pass re-adds the page right after ITS SetContinuousSlot call, so this stays correct.
_continuousSharpPages.Remove(fi);
// Size the slot and overlay from the ACTUAL rendered page so a cropped page (which renders
// shorter than its MediaBox estimate) fills its slot with no white bars.
double oldH = double.IsNaN(slot.Height) ? 0 : slot.Height;
slot.Height = dipH;
double maxF = Math.Max(fw, fh);
int rdW = Math.Max(1, (int)Math.Round(2048.0 * fw / maxF));
int rdH = Math.Max(1, (int)Math.Round(2048.0 * fh / maxF));
_renderDims[fi] = (rdW, rdH);
if (slotGrid.Children.Count > 1 && slotGrid.Children[1] is Canvas ov)
{
ov.Width = rdW;
ov.Height = rdH;
ov.LayoutTransform = new System.Windows.Media.ScaleTransform(dipW / rdW, dipW / rdW);
}
// Slot heights are now exact; recompute scroll offsets from them.
double yy = 0;
for (int k = 0; k < _continuousPanel.Children.Count && k < _continuousTops.Count; k++)
{
_continuousTops[k] = yy;
double hk = ((FrameworkElement)_continuousPanel.Children[k]).Height;
if (double.IsNaN(hk)) hk = 0;
yy += hk + 12;
}
// Pages render in order, so when the target page is reached every page above it has its final
// height; re-scroll so a crop lands you back on the same page instead of drifting.
if (_continuousScrollTarget >= 0 && fi >= _continuousScrollTarget)
{
int tgt = _continuousScrollTarget;
_continuousScrollTarget = -1;
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded,
(Action)(() => ScrollContinuousToPage(tgt)));
}
// Virtualized slots render on approach, so a page ABOVE the viewport can refine its
// estimated height mid-scroll (e.g. scrolling upward into a cropped page). Compensate
// the scroll offset by the height delta so the content on screen doesn't jump.
else if (Math.Abs(dipH - oldH) > 0.5 && fi < _continuousTops.Count)
{
double viewTopSlots = PagePreviewPanel.VerticalOffset / Math.Max(0.01, _zoomLevel);
if (_continuousTops[fi] + dipH <= viewTopSlots + 1)
PagePreviewPanel.ScrollToVerticalOffset(
PagePreviewPanel.VerticalOffset + (dipH - oldH) * _zoomLevel);
}
RenderAllAnnotations(fi);
}
// Scroll-settle window maintenance for virtualized Continuous view (#122): releases the
// bitmaps of slots far outside the viewport (heights are kept, so nothing moves) and
// kicks a render pass when unrendered slots have come near. Runs on the UI thread from
// the same debounced timer as the re-sharpen pass.
private void VirtualizeContinuousSlots()
{
if (_viewMode != ViewMode.Continuous || _doc is null) return;
if (_continuousTops.Count == 0 || _continuousPanel.Children.Count == 0) return;
// Visible slot range - same zoom-independent mapping the re-sharpen pass uses.
double viewTop = PagePreviewPanel.VerticalOffset / Math.Max(0.01, _zoomLevel);
double viewBot = (PagePreviewPanel.VerticalOffset + PagePreviewPanel.ViewportHeight) / Math.Max(0.01, _zoomLevel);
int first = int.MaxValue, last = int.MinValue;
for (int i = 0; i < _continuousTops.Count && i < _continuousPanel.Children.Count; i++)
{
double top = _continuousTops[i];
double bot = top + ((FrameworkElement)_continuousPanel.Children[i]).Height;
if (bot >= viewTop && top <= viewBot) { first = Math.Min(first, i); last = Math.Max(last, i); }
}
if (first > last) return;
bool missing = false;
for (int i = 0; i < _continuousPanel.Children.Count; i++)
{
if (_continuousPanel.Children[i] is not Border b || b.Child is not Grid g
|| g.Children.Count == 0 || g.Children[0] is not Image img) continue;
bool keep = i >= first - ContinuousReleasePages && i <= last + ContinuousReleasePages;
if (!keep && img.Source != null)
{
img.Source = null; // slot keeps its height; white scaffold shows until re-rendered
_continuousSharpPages.Remove(i);
}
else if (i >= first - ContinuousKeepPages && i <= last + ContinuousKeepPages && img.Source == null)
{
missing = true;
}
}
if (missing) _ = RenderContinuousPages((first + last) / 2);
}
// ── Continuous zoom re-sharpen (#85) ─────────────────────────────────────────────────────────
// The continuous base pass renders every page at a fixed fit-width budget, so on high-DPI
// displays or deep zoom the upscaled bitmap goes soft (and, unlike Single mode, nothing ever
// re-rendered it - RenderPage is guarded off in Continuous). This re-renders ONLY the pages
// near the viewport at a DPI- and zoom-aware budget and swaps them into their slots. Slots that
// scroll away are restored to the cached base render, so hi-res bitmaps never accumulate beyond
// the visible window; the hi-res bitmaps are deliberately NOT put in the render cache for the
// same reason. Debounced via _rerenderTimer (zoom settle + scroll settle).
private void ResharpenContinuousVisible()
{
if (_viewMode != ViewMode.Continuous || _doc is null || _currentFile is null) return;
if (_continuousTops.Count == 0 || _continuousPanel.Children.Count == 0) return;
double targetW = _continuousPageW;
int baseW = Math.Max(800, Math.Min(2048, (int)(targetW * 2)));
var dpiInfo = VisualTreeHelper.GetDpi(this);
double dpiScale = Math.Max(dpiInfo.DpiScaleX, dpiInfo.DpiScaleY);
// #189: targetW * zoom * dpiScale already IS the page's on-screen size in device pixels,
// so the extra * 2 here was a 2x linear supersample on top of an already-correct budget -
// 4x the pixels and 4x the bytes for detail the display cannot resolve. Render at the
// size we actually draw at.
int hiW = (int)Math.Min(4096, targetW * dpiScale * Math.Max(1.0, _zoomLevel));
// Visible slot range. Slot space is zoom-independent (the LayoutTransform supplies the
// zoom), so divide the scroll offsets back down - same mapping ScrollChanged uses.
double viewTop = PagePreviewPanel.VerticalOffset / Math.Max(0.01, _zoomLevel);
double viewBot = (PagePreviewPanel.VerticalOffset + PagePreviewPanel.ViewportHeight) / Math.Max(0.01, _zoomLevel);
var visible = new List<int>();
for (int i = 0; i < _continuousTops.Count && i < _continuousPanel.Children.Count; i++)
{
double top = _continuousTops[i];
double bot = top + ((FrameworkElement)_continuousPanel.Children[i]).Height;
if (bot >= viewTop && top <= viewBot) visible.Add(i);
}
if (visible.Count > 0)
{
// One page of margin either side so a small scroll stays sharp.
if (visible[0] > 0) visible.Insert(0, visible[0] - 1);
if (visible[^1] < _continuousTops.Count - 1) visible.Add(visible[^1] + 1);
}
// hiW is now a true device-pixel width, so the trigger is simply "has the base render
// run out of pixels for the size we are drawing it at". The old 1.25x margin was
// calibrated against a hiW that was inflated 2x; without moving it the pass would stop
// firing where it is still needed and pages would be upscaled from the base render.
// 1.05 is hysteresis only, so a page sitting on the boundary doesn't re-raster on a nudge.
bool wantHi = hiW >= (int)(baseW * 1.05);
_continuousSharpenCts?.Cancel();
_continuousSharpenCts = new System.Threading.CancellationTokenSource();
var cts = _continuousSharpenCts;
// Pages sharpened earlier that scrolled away (or aren't wanted at this zoom): swap the
// cached base render back in so their hi-res bitmaps get collected. Cache miss = leave it.
var session = _active;
foreach (int p in _continuousSharpPages.ToList())
{
if (wantHi && visible.Contains(p)) continue;
int rot = _pageRotations.TryGetValue(p, out int rr) ? rr : 0;
var baseBmp = TryGetCachedRender(session, p, baseW, rot);
if (baseBmp != null) SetContinuousSlot(p, baseBmp);
_continuousSharpPages.Remove(p);
}
if (!wantHi) return;
// Zoom changed since the last pass: every sharpened slot is at the wrong budget, redo them.
bool budgetChanged = hiW != _continuousSharpW;
_continuousSharpW = hiW;
var work = visible.Where(p => budgetChanged || !_continuousSharpPages.Contains(p)).ToList();
if (work.Count == 0) return;
string currentFile = _currentFile;
var rotations = new Dictionary<int, int>(_pageRotations);
_ = System.Threading.Tasks.Task.Run(() =>
{
Docnet.Core.Readers.IDocReader? docReader = null;
PdfPigDoc? pig = null; // opened lazily by ImageRectsFor on the first uncached page
try
{
foreach (int p in work)
{
if (cts.IsCancellationRequested) return;
docReader ??= DocLib.Instance.GetDocReader(currentFile, new PageDimensions(hiW, hiW * 2));
using var pr = docReader.GetPageReader(p);
int w = pr.GetPageWidth(), h = pr.GetPageHeight();
var raw = PdfiumInterop.RenderPageWithAnnotations(currentFile, p, w, h,
includeFormFields: false) // live overlays show the values; baking them too ghosts the text
?? pr.GetImage(); // #141: draw the file's own markup without Docnet form fill
if (w <= 0 || h <= 0 || raw is null) continue;
int rot = rotations.TryGetValue(p, out int rr) ? rr : 0;
// #135: dark mode with pictures excluded; invert before the rotation so
// the carve-out rects stay in unrotated page space.
if (DocInvert)
BitmapHelpers.InvertBgraInPlaceExcept(raw, w, h, ImageRectsFor(currentFile, p, ref pig));
if (rot != 0) (raw, w, h) = BitmapHelpers.RotateBitmap(raw, w, h, rot);
int fp = p, fw = w, fh = h;
byte[] bytes = raw;
if (cts.IsCancellationRequested) return;
Dispatcher.Invoke(() =>
{
if (cts.IsCancellationRequested || _viewMode != ViewMode.Continuous) return;
int ddw = Math.Max(1, (int)Math.Round(targetW));
int ddh = Math.Max(1, (int)Math.Round(targetW * fh / (double)fw));
SetContinuousSlot(fp, BitmapHelpers.BuildScaledBitmap(fw, fh, bytes, ddw, ddh));
_continuousSharpPages.Add(fp);
});
}
}
catch { /* canceled or doc closed */ }
finally { docReader?.Dispose(); pig?.Dispose(); }
}, cts.Token);
}
// keepTiles: leave the existing secondary tiles in place instead of clearing them first.
// For a pixels-only repaint (invert toggle) the tile set is unchanged, and clearing it
// made the whole grid flash empty and re-stream - RenderAdditionalPages swaps bitmaps
// into existing tiles in place, so keeping them repaints without any layout jitter.
// Navigation keeps the default (clear first) since the tile set actually changes.
internal void RenderPage(int pageIndex, bool keepTiles = false)
{
if (_currentFile is null || _doc is null) return;
// Continuous has its own pipeline (SetupContinuousView + RenderContinuousPages into
// _continuousPanel) and owns the _pages map for every page. RenderPage targets the hidden
// single/grid primary (_annotationCanvas in the collapsed _pageContentPanel) and calls
// ClearSecondaryPages, which would WIPE the continuous _pages map and repoint the current
// page at the invisible primary - so any annotation added afterwards renders off-screen
// until a mode switch rebuilds the overlays. Stray callers (the zoom re-sharpen timer, the
// DPI-change handler) can fire RenderPage while continuous is active; ignore them. The mode
// switch sets _viewMode to the new (non-continuous) mode BEFORE calling RenderPage, so this
// guard never blocks a legitimate switch-into-single/grid render.
if (_viewMode == ViewMode.Continuous) return;
// Two-page spreads pair (0,1),(2,3),...; render the pair's left (even) page as primary so
// selecting the right page of a pair still shows the whole spread, not a lone page.
if (_viewMode == ViewMode.TwoPage) pageIndex = SpreadStart(pageIndex); // #193: book-aware
try
{
// Scale render resolution to match display DPI AND current zoom so the
// bitmap stays sharp when zoomed in. Base 2048 means Fit Width on a
// wide monitor stays crisp; zoom factor ensures 1:1 pixels at 2× zoom.
// Capped at 6144 to keep memory manageable.
var dpiInfo = VisualTreeHelper.GetDpi(this);
double dpiScaleX = dpiInfo.DpiScaleX;
double dpiScaleY = dpiInfo.DpiScaleY;
int scaledMax = (int)Math.Min(6144,
2048 * Math.Max(dpiScaleX, dpiScaleY) * Math.Max(1.0, _zoomLevel));
_lastRenderZoom = _zoomLevel;
int pgRot = _pageRotations.TryGetValue(pageIndex, out int pr0) ? pr0 : 0;
// #197: the cursor-trailing tooltip is gone; the corner badge announces the page.
ShowPageBadge(pageIndex);
// Reuse this tab's cached bitmap for (page, resolution, rotation) if present; otherwise
// rasterize once and cache it. On a switch back to a recent tab this skips pdfium entirely.
int width, height;
System.Windows.Media.Imaging.BitmapSource bitmap;
var cached = TryGetCachedRender(_active, pageIndex, scaledMax, pgRot);
if (cached != null)
{
bitmap = cached;
width = cached.PixelWidth;
height = cached.PixelHeight;
}
else
{
using var docReader = DocLib.Instance.GetDocReader(_currentFile, new PageDimensions(scaledMax, scaledMax));
using var pageReader = docReader.GetPageReader(pageIndex);
width = pageReader.GetPageWidth();
height = pageReader.GetPageHeight();
var rawBytes = PdfiumInterop.RenderPageWithAnnotations(_currentFile, pageIndex, width, height,
includeFormFields: false) // live overlays show the values; baking them too ghosts the text
?? pageReader.GetImage(); // #141
// Bail on an unusable render BEFORE touching the buffer. This check used to sit
// after the rotate; moving the invert ahead of the rotate (#135) meant the
// buffer was read while still only maybe-non-null, so validate first instead.
if (width <= 0 || height <= 0 || rawBytes == null || rawBytes.Length == 0)
{
PageImage.Source = null;
SetStatus(string.Format(Loc("Str_PageRenderError"), pageIndex + 1));
return;
}
// #135: display-only dark mode, pictures excluded. Before the rotation so the
// carve-out rects stay in unrotated page space; the one-shot PdfPig open is
// paid only on this page's first inverted render (the rects cache after).
if (DocInvert)
{
PdfPigDoc? pig = null;
try { BitmapHelpers.InvertBgraInPlaceExcept(rawBytes, width, height, ImageRectsFor(_currentFile, pageIndex, ref pig)); }
finally { pig?.Dispose(); }
}
// The temp file has /Rotate stripped so Docnet renders unrotated (no clipping); rotate
// the pixel buffer to match the visual.
if (pgRot != 0)
(rawBytes, width, height) = BitmapHelpers.RotateBitmap(rawBytes, width, height, pgRot);
// Bake the bitmap DPI from the canonical render-dim size (longest side -> 2048 DIP, the
// SAME zoom-independent basis continuous and the secondary tiles use) so the extra pixels
// display within a fixed DIP area regardless of zoom. LayoutTransform supplies the zoom.
double bLongest = Math.Max(1, Math.Max(width, height));
int bw = Math.Max(1, (int)Math.Round(2048.0 * width / bLongest));
int bh = Math.Max(1, (int)Math.Round(2048.0 * height / bLongest));
var wb = new WriteableBitmap(width, height, 96.0 * width / bw, 96.0 * height / bh, PixelFormats.Bgra32, null);
wb.WritePixels(new Int32Rect(0, 0, width, height), rawBytes, width * 4, 0);
wb.Freeze();
bitmap = wb;
CacheRender(_active, pageIndex, scaledMax, pgRot, bitmap);
}
// Canonical render-dim space: longest side -> 2048 DIP, identical to the continuous and
// secondary-tile paths. This is zoom-independent (aspect only), so the canvas/overlay size
// is byte-for-byte stable across every zoom re-render - no left-shift when re-sharpening,
// and annotation coordinates match across all view modes. LayoutTransform handles the zoom.
double longest = Math.Max(1, Math.Max(width, height));
int dipW = Math.Max(1, (int)Math.Round(2048.0 * width / longest));
int dipH = Math.Max(1, (int)Math.Round(2048.0 * height / longest));
_renderDims[pageIndex] = (dipW, dipH);
PageImage.Source = bitmap;
_annotationCanvas.Width = dipW;
_annotationCanvas.Height = dipH;
_annotationCanvas.Tag = pageIndex; // so clicks on the primary page resolve to the
// page actually shown (page 0 in grid), not the
// selected index - otherwise annotations on it
// are unhittable and clicks "do nothing".
ClearSelection();
if (!keepTiles) ClearSecondaryPages();
_pages[pageIndex] = _annotationCanvas; // the primary is a normal entry in the unified map
RenderAllAnnotations(pageIndex);
SetStatus(string.Format(Loc("Str_PageOf"), pageIndex + 1, _doc!.PageCount));
// Defer additional pages until layout has settled so ActualWidth is valid.
// RenderPageLinks runs AFTER RenderAdditionalPages so ClearSecondaryPages
// inside RenderAdditionalPages doesn't wipe the overlays we just added.
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded, () =>
{
// Only Grid/Two-Page lay out neighbor tiles. In Single mode RenderAdditionalPages would
// snap the panel width to pageW + 12 (the inter-tile gap), nudging the lone page ~6px left
// of center - the shift seen a beat after a zoom-in, when the re-sharpen timer re-renders.
// Match RefreshPageView's single-mode handling instead: no extra tiles, auto panel width.
if (_viewMode == ViewMode.Grid || _viewMode == ViewMode.TwoPage)
RenderAdditionalPages(pageIndex);
else
{
ClearSecondaryPages();
if (_pageContentPanel is not null) _pageContentPanel.Width = double.NaN;
}
RenderPageLinks(pageIndex, dipW, dipH);
});
_renderedPrimaryPage = pageIndex;
}
catch (Exception ex)
{
PageImage.Source = null;
SetStatus(string.Format(Loc("Str_RenderError"), ex.Message));
}
}
/// <summary>
/// Clears all dynamically-added secondary page borders from the panel,
/// leaving only the first child (the primary page border).
/// </summary>
// Removes secondary tiles whose page is no longer shown (keeps the primary at index 0 and any
// tile still in range so it can be reused in place). Keeps the tile map in sync.
private void RemoveSecondaryTilesNotIn(HashSet<int> keep)
{
if (_pageContentPanel is null) return;
var stale = new List<int>();
foreach (var k in _continuousCanvases.Keys)
if (!keep.Contains(k)) stale.Add(k);
foreach (var pg in stale)
{
if (_continuousCanvases.TryGetValue(pg, out var ov) && ov.Parent is Grid g && g.Parent is Border tile)
{
foreach (var gc in g.Children) if (gc is Image im) im.Source = null;
_pageContentPanel.Children.Remove(tile);
}
_continuousCanvases.Remove(pg);
_pages.Remove(pg);
}
}
private void ClearSecondaryPages()
{
if (_pageContentPanel is null) return;
// Explicitly null out Image sources before removing so the GC can
// reclaim the WriteableBitmap backing arrays promptly.
while (_pageContentPanel.Children.Count > 1)
{
var child = _pageContentPanel.Children[^1];
if (child is Border b && b.Child is Grid g)
{
foreach (var gc in g.Children)
if (gc is Image img) img.Source = null;
}
_pageContentPanel.Children.RemoveAt(_pageContentPanel.Children.Count - 1);
}
// NOTE: do NOT reset _pageContentPanel.Width here. Width is managed exclusively
// by RenderAdditionalPages (which runs only via Dispatcher) so that no synchronous
// call to ClearSecondaryPages triggers an intermediate layout pass that would cause
// the primary page to flash centered and then jerk back to left-aligned.
// Clear any link overlays from the annotation canvas.
foreach (var lo in _linkOverlays)
_annotationCanvas.Children.Remove(lo);
_linkOverlays.Clear();
_continuousCanvases.Clear(); // keep the page->tile map in sync with the visible tiles
// Unified map: keep only the CURRENT primary entry (key == _annotationCanvas.Tag) and drop
// everything else - the secondary overlays and any stale primary entry from a prior page.
int primPage = _annotationCanvas.Tag is int tp ? tp : -1;
foreach (var pg in _pages.Keys.Where(k => k != primPage).ToList())
_pages.Remove(pg);
}
/// <summary>
/// Renders secondary pages as a grid. Panel-width setup is synchronous so layout
/// is correct immediately; Docnet pixel rendering runs on a background thread so
/// the UI stays responsive. WPF element creation returns to the UI thread.
/// </summary>
private async void RenderAdditionalPages(int primaryPageIdx)
{
if (_currentFile is null || _doc is null) return;
// Grid is a stable overview anchored at page 0 (independent of the selected page), so it
// always shows the whole document instead of only the selected page onward.
if (_viewMode == ViewMode.Grid) primaryPageIdx = 0;
// Re-assert the grid's ScrollViewer overrides on every tile layout. RefreshPageView is
// where they normally land, but an unfocused pane's ApplyZoom SKIPS RefreshPageView (the
// focus check guarding the tile-stream churn), so a grid entered in an unfocused pane
// kept the 12px surround padding and Auto scrollbars: the panel overflowed by the
// padding + vertical scrollbar width and a horizontal scrollbar appeared under the grid.
if (_viewMode == ViewMode.Grid)
{
PagePreviewPanel.HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled;
PagePreviewPanel.VerticalScrollBarVisibility = ScrollBarVisibility.Visible;
DocSurfacePad.Padding = new Thickness(0);
}
double viewportW = PagePreviewPanel.ActualWidth;
// A transient zero width (a split drag rewriting the pane columns mid-layout) must NOT
// wipe the tiles: clearing here is what blanked the grid on a two-pane resize and forced
// the full one-at-a-time rebuild. Skip instead - the existing tiles stay put (transform-
// stretched) and the resize-settle refresh swaps crisp bitmaps into them in place.
if (viewportW <= 0) return;
if (_doc.PageCount <= 1)
{
ClearSecondaryPages();
_pageContentPanel.Width = double.NaN;
return;
}
// Snap the WrapPanel width to a whole number of page-width slots.
// Grid slots carry a constant GridGapPx on-screen gap (divided by zoom because tile
// margins scale with the view transform); other tiled modes keep the 12px gap.
double primaryPageW = _annotationCanvas.Width > 0 ? _annotationCanvas.Width : 595;
bool bookCover = _viewMode == ViewMode.TwoPage && BookMode && primaryPageIdx == 0;
double pageSlotW = primaryPageW + (_viewMode == ViewMode.Grid
? GridGapPx / Math.Max(0.01, _zoomLevel) : bookCover ? 0 : 12);
double availablePreZoom = (viewportW - 24) / _zoomLevel;
// +1e-6: same floating-point underflow guard as GridZoomStep, so a zoom set for n columns
// actually lays out n (not n-1) when the division lands a hair under the integer.
// Grid lays out its AUTHORITATIVE column count (set on grid zoom, restored per tab); it no longer
// derives columns from the zoom here - that zoom->columns->zoom round-trip lost the grid zoom on
// tab switches. Other modes still fit to the current zoom.
// #193: a book-layout cover is a ONE-page row - sizing the panel for two slots parked
// the cover in the left half of a centered two-slot panel, which read as left-aligned.
int pagesPerRow = _viewMode == ViewMode.TwoPage ? (bookCover ? 1 : 2)
: _viewMode == ViewMode.Grid ? Math.Max(1, _gridColumns)
: Math.Max(1, (int)(availablePreZoom / pageSlotW + 1e-6));
// +0.5/slot: secondary tiles round their DIP width to a whole pixel (AddSecondaryTile),
// so a row can measure up to half a pixel per tile wider than n exact slots. Without
// this slack the WrapPanel wraps the row's last tile early and the grid shows n-1
// columns with a dead column of background on the right. The slack is far below one
// slot width, so it can never admit an extra column.
double panelW = pagesPerRow * (pageSlotW + 0.5);
if (panelW > 0) _pageContentPanel.Width = panelW;
// A grid zoom changes the pre-transform margin that keeps the visible seam at GridGapPx.
// Update every existing tile in one layout pass, before the async bitmap stream starts.
// Doing this from AddSecondaryTile made each seam move only when that page's render callback
// arrived, so the already-zoomed grid visibly settled one page at a time.
if (_viewMode == ViewMode.Grid)
{
double gapDip = GridGapPx / Math.Max(0.01, _zoomLevel);
var margin = new Thickness(0, 0, gapDip, gapDip);
foreach (var child in _pageContentPanel.Children)
if (child is Border tile) tile.Margin = margin;
}
// Cancel any previously running secondary render.
_secondaryRenderCts?.Cancel();
_secondaryRenderCts = new System.Threading.CancellationTokenSource();
var cts = _secondaryRenderCts;
// Secondary pages: 1536 px base, scaled up for high-DPI displays so grid / two-page text
// stays crisp on 150%/200% screens (capped at 3072 to keep memory in check). Stays 1536
// at 100% DPI, so standard displays are unaffected.
int SecondaryMax = (int)Math.Min(3072, 1536 * Math.Max(1.0, VisualTreeHelper.GetDpi(this).DpiScaleX));
// Grid shows the whole document; Two-Page shows one secondary; other modes peek ahead.
int limit = _viewMode == ViewMode.Grid
? _doc.PageCount
: Math.Min(_doc.PageCount, primaryPageIdx + 1 + (_viewMode == ViewMode.TwoPage
? (BookMode && primaryPageIdx == 0 ? 0 : 1) // #193: the cover has no partner
: 25));
if (limit <= primaryPageIdx + 1) { ClearSecondaryPages(); return; }
// Per-tile reuse: drop tiles for pages that left the view, keep the rest. Pages that already
// have a tile get their bitmap swapped in place (AddSecondaryTile); only genuinely new pages
// are built. Stays smooth even mid-stream on a large doc, where the tile set is only partly
// built. (Navigation clears everything via RenderPage first, so it rebuilds.)
var keepPages = new HashSet<int>();
for (int i = primaryPageIdx + 1; i < limit; i++) keepPages.Add(i);
RemoveSecondaryTilesNotIn(keepPages);
string currentFile = _currentFile;
// Collect rotations on the UI thread before the background task.
var secRotations = new Dictionary<int, int>();
for (int i = primaryPageIdx + 1; i < limit; i++)
if (_pageRotations.TryGetValue(i, out int r) && r != 0)
secRotations[i] = r;
// Capture the primary page width and reset the tile map on the UI thread before
// streaming tiles in from the background render.
double primaryDipW = _annotationCanvas.Width > 0 ? _annotationCanvas.Width : 595;
// Render pixels on a background thread and attach each page tile to the UI as soon
// as it is ready, so large documents fill in progressively instead of blocking
// until every page has been rendered.
try
{
var session = _active;
int tileBucket = (int)Math.Round(primaryDipW); // tiles are sized to the primary width; key the cache by it
await System.Threading.Tasks.Task.Run(() =>
{
Docnet.Core.Readers.IDocReader? docReader = null;
PdfPigDoc? pig = null; // opened lazily by ImageRectsFor on the first uncached page
try
{
for (int i = primaryPageIdx + 1; i < limit; i++)
{
if (cts.IsCancellationRequested) break;
int rot = secRotations.TryGetValue(i, out int rr) ? rr : 0;
// Cache hit: skip pdfium entirely, just attach the cached tile bitmap.
var cachedTile = TryGetCachedRender(session, i, tileBucket, rot);
if (cachedTile != null)
{
int pic = i;
try
{
Dispatcher.Invoke(() =>
{
if (cts.IsCancellationRequested || _doc is null) return;
if (_viewMode != ViewMode.Grid && _viewMode != ViewMode.TwoPage) return;
AddSecondaryTile(pic, cachedTile, primaryDipW);
});
}
catch (System.Threading.Tasks.TaskCanceledException) { break; }
catch (OperationCanceledException) { break; }
continue;
}
// One bad page (a pdfium hiccup, a malformed object) must not kill the
// rest of the stream - it silently stranded every page after it with no
// tile, which read as "the grid's last column never refreshed".
try
{
docReader ??= DocLib.Instance.GetDocReader(currentFile, new PageDimensions(SecondaryMax, SecondaryMax));
using var pageReader = docReader.GetPageReader(i);
int w = pageReader.GetPageWidth();
int h = pageReader.GetPageHeight();
var rawBytes = PdfiumInterop.RenderPageWithAnnotations(currentFile, i, w, h,
includeFormFields: false) // live overlays show the values; baking them too ghosts the text
?? pageReader.GetImage(); // #141
if (w <= 0 || h <= 0 || rawBytes is null) continue;
// #135: dark mode with pictures excluded; invert before the rotation
// so the carve-out rects stay in unrotated page space.
if (DocInvert)
BitmapHelpers.InvertBgraInPlaceExcept(rawBytes, w, h, ImageRectsFor(currentFile, i, ref pig));
if (rot != 0)
(rawBytes, w, h) = BitmapHelpers.RotateBitmap(rawBytes, w, h, rot);
int pi = i, pw = w, ph = h, prot = rot;
byte[] bytes = rawBytes;
Dispatcher.Invoke(() =>
{
if (cts.IsCancellationRequested || _doc is null) return;
if (_viewMode != ViewMode.Grid && _viewMode != ViewMode.TwoPage) return;
int ddw = Math.Max(1, (int)Math.Round(primaryDipW));
int ddh = Math.Max(1, (int)Math.Round(primaryDipW * ph / pw));
var bmp = BitmapHelpers.BuildScaledBitmap(pw, ph, bytes, ddw, ddh);
CacheRender(session, pi, tileBucket, prot, bmp);
AddSecondaryTile(pi, bmp, primaryDipW);
});
}
// Dispatcher.Invoke throws when the dispatcher is shutting down (app closing) or
// the render was canceled; stop rendering cleanly instead of crashing.
catch (System.Threading.Tasks.TaskCanceledException) { break; }
catch (OperationCanceledException) { break; }
catch { continue; } // skip this page, keep streaming the rest
}
}
finally { docReader?.Dispose(); pig?.Dispose(); }
}, cts.Token);
}
catch (OperationCanceledException) { return; } // superseded - the newer render owns the tiles now
catch
{
// A transient failure mid-stream must not strand the tail of the grid unrendered
// (the "last column never refreshed" symptom). Retry once - the flag stops a
// persistently broken document from looping, and cache hits make the retry cheap.
if (!_secondaryRenderRetried && !cts.IsCancellationRequested)
{
_secondaryRenderRetried = true;
_ = Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Background,
(Action)(() =>
{
if (_viewMode is ViewMode.Grid or ViewMode.TwoPage) RenderAdditionalPages(primaryPageIdx);
}));
}
return;
}
_secondaryRenderRetried = false; // a complete pass clears the retry budget
}
// One-shot retry budget for RenderAdditionalPages - set when a failed stream schedules its
// retry, cleared by any pass that completes. Keeps a broken document from retry-looping.
private bool _secondaryRenderRetried;
/// <summary>
/// Builds one secondary-page tile (image + annotation overlay + links) and appends it
/// to the page content panel. Must run on the UI thread.
/// </summary>
private void AddSecondaryTile(int pi, System.Windows.Media.Imaging.BitmapSource bitmap, double primaryDipW)
{
int w = bitmap.PixelWidth, h = bitmap.PixelHeight;
int pageDipW = (int)Math.Round(primaryDipW);
int pageDipH = (int)Math.Round(primaryDipW * h / w);
// This page already has a tile: swap just the bitmap (same logical size, crisper pixels).
// No clear, no reflow - so the grid/spread never jumps or blinks.
if (_continuousCanvases.TryGetValue(pi, out var exOverlay)
&& exOverlay.Parent is Grid exGrid && exGrid.Children.Count > 0 && exGrid.Children[0] is Image exImg)
{
exImg.Source = bitmap;
// Keep the grid's constant on-screen gap exact across zoom/column changes: the
// reused tile's margin was computed at the old zoom, so refresh it here.
if (_viewMode == ViewMode.Grid && exGrid.Parent is Border exTile)
{
double exGap = GridGapPx / Math.Max(0.01, _zoomLevel);
exTile.Margin = new Thickness(0, 0, exGap, exGap);
}
return;
}
// Do NOT overwrite _renderDims if the page was already rendered as primary -
// its annotation coordinate mapping must stay intact.
if (!_renderDims.ContainsKey(pi))
_renderDims[pi] = (pageDipW, pageDipH);
var img = new Image { Source = bitmap, Stretch = Stretch.None };
RenderOptions.SetBitmapScalingMode(img, BitmapScalingMode.HighQuality);
var overlay = BuildPageOverlay(pi, pageDipW, pageDipH, null);
overlay.Cursor = CursorForTool(_currentTool);
var pageGrid = new Grid();
pageGrid.Children.Add(img);
pageGrid.Children.Add(overlay);
AddSecondaryPageLinks(pi, pageDipW, pageDipH);
var tile = new Border
{
Background = Brushes.White,
VerticalAlignment = VerticalAlignment.Top,
// Grid: constant GridGapPx on-screen gap between tiles (the document pane
// background shows through). Two-Page: no margin on the right page - the spread
// gap is the primary's right margin, and a bottom/trailing margin would make the
// fit-page margins uneven.
Margin = _viewMode == ViewMode.Grid
? new Thickness(0, 0, GridGapPx / Math.Max(0.01, _zoomLevel),
GridGapPx / Math.Max(0.01, _zoomLevel))
: new Thickness(0),
Child = pageGrid
};
_pageContentPanel.Children.Add(tile);
RenderAllAnnotations(pi);
// Grid tiles render asynchronously, so a "scroll to page N" requested when entering grid
// can't run until page N's tile exists. Do it the moment that tile streams in.
if (pi == _gridScrollToPage)
{
_gridScrollToPage = -1;
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded,
(Action)(() =>
{
if (_viewMode != ViewMode.Grid) return;
try
{
// Top-align the page's row in the viewport (accounts for the zoom transform).
if (PagePreviewPanel.Content is FrameworkElement content)
PagePreviewPanel.ScrollToVerticalOffset(
tile.TransformToVisual(content).Transform(new Point(0, 0)).Y);
else
tile.BringIntoView();
}
catch { tile.BringIntoView(); }
}));
}
}
internal void BootstrapDocumentView(int initialPage, bool autoFit, bool restoreFitMode = false)
{
// The document is (re)displaying - usually a different one (tab switch/close/open). The
// skip-render guard in PageList_SelectionChanged compares the target page to the last
// rasterised page (_renderedPrimaryPage) but not to WHICH document, so a switch to another
// doc at the same page index + zoom would skip the render and leave the previous doc on
// screen. Invalidate it here so the new document always renders.
_renderedPrimaryPage = -1;
ClearSecondaryPages();
ClearSelection();
RefreshPageList();
LoadOutlines();
DropZone.Visibility = Visibility.Collapsed;
PagePreviewPanel.Visibility = Visibility.Visible;
if (Host != null)
{
Host.CloseFileEnabled = true;
Host.PageJumpEnabled = true;
Host.PageTotalText = $"/ {_doc!.PageCount}";
}
if (_doc!.PageCount > 0)
{
int page = Math.Max(0, Math.Min(initialPage, _doc.PageCount - 1));
// Show the panel that matches THIS tab's view mode. This must run for every mode, not
// only Continuous: switching from a Continuous tab to a Single/Two-Page/Grid tab has to
// collapse the continuous panel, otherwise the previous tab's continuous render stays on
// screen over the new document.
bool isContinuous = _viewMode == ViewMode.Continuous;
_pageContentPanel.Visibility = isContinuous ? Visibility.Collapsed : Visibility.Visible;
_continuousPanel.Visibility = isContinuous ? Visibility.Visible : Visibility.Collapsed;
_currentPage = page;
// Continuous's SelectionChanged returns early (no RenderPage call), so build its panel here.
if (isContinuous)
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded,
() => SetupContinuousView(page, fitDefault: autoFit));
// Fit / zoom once the first page has rendered and layout has settled.
// DispatcherPriority.Background is lower than Loaded, so this fires after
// all pending RenderPage / RefreshPageView callbacks have completed.
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Background,
(Action)(() =>
{
if (autoFit)
{
// Grid opens to its 3-across default; other modes fit to width.
if (_viewMode == ViewMode.Grid)
{
_gridColumns = Math.Min(_doc?.PageCount ?? 1, 3);
SetZoom(GridZoomForN(_gridColumns));
}
else
FitToPage();
}
else if (restoreFitMode)
{
// Reopened document: keep its last page and view mode. A saved FIT mode is
// replayed against the current window (fit is window-relative by definition).
// A saved MANUAL zoom is restored as-is (#201): _zoomLevel already holds the
// saved value from the open path, and SetZoom clamps to ZoomMin/ZoomMax, so a
// zoom saved on different hardware can't open enormous or microscopic.
if (_viewMode == ViewMode.Grid)
{
_gridColumns = Math.Min(_doc?.PageCount ?? 1, 3);
SetZoom(GridZoomForN(_gridColumns));
}
else if (_fitMode == FitMode.Width) FitToWidth();
else if (_fitMode == FitMode.Page) FitToPage();
else SetZoom(_zoomLevel); // manual zoom: restore it (#201)
}
else
{
// Tab restore: keep the document's saved zoom. Grid's zoom is really "how many
// columns" for the CURRENT window width, and its SizeChanged/settle handlers
// recompute it as GridZoomForN(_gridColumns); replay the saved column count the
// same way so the two agree instead of fighting (a raw saved zoom from a different
// width loses). _gridColumns is restored per tab in ApplySessionState.
if (_viewMode == ViewMode.Grid) SetZoom(GridZoomForN(_gridColumns));
else SetZoom(_zoomLevel);
}
// #196: opening a file from Explorer left keyboard focus outside the document
// view (WPF's initial focus can land in the page-jump box, whose editable-
// TextBox guard swallows every shortcut), so arrows and PageUp/Down did
// nothing until a click. Focus the scroll panel unless the user is typing.
if (System.Windows.Input.Keyboard.FocusedElement is not TextBox { IsReadOnly: false })
PagePreviewPanel.Focus();
}));
}
}
internal void RefreshPageView(int pageIndex)
{
if (_viewMode == ViewMode.Continuous)
return; // continuous mode manages its own rendering
if (_viewMode == ViewMode.TwoPage) pageIndex = SpreadStart(pageIndex); // snap to the spread's left page (#193: book-aware)
// Grid fits its columns to the viewport, so it never needs a horizontal scrollbar.
// Leaving it on Auto shows a stray (green) thumb across the bottom when the tile panel
// overflows by the vertical scrollbar's width. Disable it for grid, Auto elsewhere.
PagePreviewPanel.HorizontalScrollBarVisibility =
_viewMode == ViewMode.Grid ? ScrollBarVisibility.Disabled : ScrollBarVisibility.Auto;
// Reserve the vertical scrollbar in Grid so its appearing/disappearing can't change the
// viewport width mid-resize and feed a width change back into the layout (the loop the grid
// used to guard against). A stable width lets the column-holding resize stay stable too.
PagePreviewPanel.VerticalScrollBarVisibility =
_viewMode == ViewMode.Grid ? ScrollBarVisibility.Visible : ScrollBarVisibility.Auto;
// Grid is edge-to-edge: zero the document-surface padding so tiles reach the pane
// edges (other modes keep the 12px surround; Continuous restores it in
// SetupContinuousView, which never passes through here).
DocSurfacePad.Padding = new Thickness(_viewMode == ViewMode.Grid ? 0 : 12);
// Two-Page centers the document surface vertically so Fit Page leaves EVEN top and
// bottom margins (top-anchored, all the leftover height landed at the bottom). The
// other modes stay top-anchored; Continuous restores this in SetupContinuousView.
DocSurfacePad.VerticalAlignment = _viewMode == ViewMode.TwoPage
? VerticalAlignment.Center : VerticalAlignment.Top;
// Primary-tile margin per mode: Single is centered with no gap; Grid gets the constant
// GridGapPx on-screen tile gap; Two-Page keeps only the 12px spread gap on the right
// (a bottom margin would make the fit-page margins vertically uneven).
if (_pageContentPanel is not null && _pageContentPanel.Children.Count > 0
&& _pageContentPanel.Children[0] is Border primaryBorder)
{
double gapDip = GridGapPx / Math.Max(0.01, _zoomLevel);
// #193: a book-layout cover has no facing page, so it drops the 12px spread gap
// and centers like a Single page instead of hanging left of an empty slot.
bool coverAlone = _viewMode == ViewMode.TwoPage && BookMode && pageIndex == 0;
primaryBorder.Margin = _viewMode == ViewMode.Grid ? new Thickness(0, 0, gapDip, gapDip)
: _viewMode == ViewMode.TwoPage ? (coverAlone ? new Thickness(0) : new Thickness(0, 0, 12, 0))
: new Thickness(0);
}
if (_viewMode == ViewMode.Grid || _viewMode == ViewMode.TwoPage)
RenderAdditionalPages(pageIndex);
else
{
ClearSecondaryPages();
if (_pageContentPanel is not null)
_pageContentPanel.Width = double.NaN;
}
if (_renderDims.TryGetValue(pageIndex, out var dims))
RenderPageLinks(pageIndex, dims.w, dims.h);
}
internal void ApplyZoom(bool lite = false)
{
if (_pageContentGrid.LayoutTransform is ScaleTransform st)
{
st.ScaleX = _zoomLevel;
st.ScaleY = _zoomLevel;
}
SyncZoomBox(); // keep the toolbar box in step (FitToWidth/FitToPage don't call SetZoom)
// Live-resize path: the ScaleTransform above already grew/shrank the existing render to
// match the new size - smooth and flicker-free. Skip the bitmap re-render and tile rebuild;
// PagePreviewPanel_SizeChanged debounces one crisp re-render once the drag settles, instead
// of thrashing it on every size tick (which is what made the page blink during a resize).
if (lite) return;
// Recalculate how many pages fit after zoom changes.
// Use RefreshPageView so link overlays are re-added after RenderAdditionalPages
// calls ClearSecondaryPages (which wipes them).
// State.CurrentPage, NEVER PageList.SelectedIndex, in every fit/zoom/render path in
// this file: the sidebar is a window singleton that follows the FOCUSED pane, so an
// unfocused pane's re-fit (settle timer -> ReapplyGridOrFit under WithOwnSession) read
// the OTHER pane's page number here. That index is usually absent from this pane's
// _renderDims, GetPageDipSize then returns a degenerate size, and the fit slams the
// zoom to the clamp - which is what made pane A zoom in wildly whenever a file was
// opened in pane B (2026-08-01, repeatedly). For the focused pane the two values are
// identical by the stage-3a sync, so this changes nothing single-pane.
int applyIdx = State.CurrentPage;
if (applyIdx >= 0)
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded,
() =>
{
// Focus check at EXECUTION time, not queue time: an unfocused pane's resize
// settle queues this from inside WithOwnSession, but by the time Loaded runs
// the session swap is undone - the shared fields and element accessors are
// back on the FOCUSED pane, so this refresh landed on the OTHER pane,
// cancelling and restarting its tile stream (the churn behind the grid's
// sequential rebuild on a two-pane resize). The unfocused pane keeps its
// transform-scaled tiles; its view math already ran inside the session.
if (Host == null || Host.IsViewerFocused(this)) RefreshPageView(applyIdx);
});
// If the user has zoomed in past ~10% of the last render, queue a deferred re-render at
// higher resolution so text re-sharpens quickly (especially on high-DPI displays, where
// the upscaled bitmap shows blur sooner). The timer debounces rapid Ctrl+scroll.
// Skipped in Grid: this re-renders via the selected page (not page 0) and, once the render
// hits its pixel cap when zoomed in, shifts page 0's render width - which is the basis for
// the grid's column math. That desync locks Ctrl+scroll to a 1<->2 column toggle. The grid
// is an overview and doesn't need the re-sharpen.
// Continuous re-sharpens on ANY zoom change (zoom-in sharpens the visible pages, zoom-out
// restores base bitmaps so hi-res memory is released); the other modes only on a >10% zoom-in.
if (applyIdx >= 0 && _doc is not null && _viewMode != ViewMode.Grid
&& (_viewMode == ViewMode.Continuous || _zoomLevel > _lastRenderZoom * 1.10))
{
StartRerenderTimer();
}
}
// Debounced high-resolution re-render, shared by zoom settle (all modes) and continuous scroll
// settle. Continuous gets the targeted visible-page re-sharpen (#85); Single/Two-Page re-render
// the primary via RenderPage (which is guarded off in Continuous).
internal void StartRerenderTimer()
{
if (_rerenderTimer is null)
{
_rerenderTimer = new System.Windows.Threading.DispatcherTimer
{ Interval = TimeSpan.FromMilliseconds(250) };
_rerenderTimer.Tick += (_, _) =>
{
_rerenderTimer!.Stop();
// A tick orphaned by a focus switch must not run: _doc, _renderDims and
// PageList are shared fields describing the FOCUSED pane, so an unfocused
// pane's re-sharpen rendered the OTHER pane's document into its own tiles at
// the other document's dimensions - which is what made opening a file in
// pane B zoom pane A in wildly (2026-08-01). The re-sharpen is a
// crispness optimization for the pane being zoomed; a pane that lost focus
// mid-debounce keeps its current render, same as the SizeChanged guard above
// ("an unfocused pane simply sits the fit out").
if (Host != null && !Host.IsViewerFocused(this)) return;
if (_doc is null) return;
if (_viewMode == ViewMode.Continuous)
{
VirtualizeContinuousSlots(); // #122: release far bitmaps / render approaching ones
ResharpenContinuousVisible();
return;
}
// Never re-render the primary in Grid (it would shift page 0's width basis and
// desync the column math); guards a timer started just before a switch into grid.
if (_viewMode != ViewMode.Grid && State.CurrentPage >= 0)
RenderPage(State.CurrentPage);
};
}
_rerenderTimer.Stop();
_rerenderTimer.Start();
}
// #189: the re-sharpen budget is measured in DEVICE pixels, so moving the window to a
// monitor at a different scale factor changes how many pixels the page needs without
// changing its size in DIPs. In a fit mode the monitor move also changes the window's DIP
// size, so the resize path re-fits and re-renders; at a manual zoom (FitMode.None) nothing
// else fires, and the page would sit upscaled from the old monitor's render. Queue the same
// debounced pass the zoom and resize settles use.
// This mattered less while hiW carried a 2x supersample, which happened to cover a
// 100% -> 200% jump; at a device-native budget there is no such headroom.
protected override void OnDpiChanged(DpiScale oldDpi, DpiScale newDpi)
{
base.OnDpiChanged(oldDpi, newDpi);
if (_doc is not null) StartRerenderTimer();
}
private void ResetZoom() => SetTrueZoom(1.0);
// On-screen pixel gap between grid tiles, so adjacent pages don't visually merge - the
// document pane background shows through it. Tile margins live inside the zoom transform,
// so every use divides by _zoomLevel to keep the gap a constant GridGapPx on screen.
// 2px, not 1: tile edges land on fractional device pixels (page width x fractional zoom),
// and a 1px gap straddling a pixel boundary anti-aliases away on some seams.
private const double GridGapPx = 2.0;
// Grid zoom snaps to "fit N pages across the viewport", so zooming steps through clean
// columns (1, 2, 3, ... per row) instead of arbitrary percentages. N rises as you zoom out
// and keeps going for larger documents until the page size hits the zoom floor.
internal double GridZoomForN(int n)
{
if (n < 1) n = 1;
double rdW = _annotationCanvas.Width > 0 ? _annotationCanvas.Width : 1583;
// Grid is edge-to-edge except a GridGapPx on-screen gap per column: fit n rdW slots
// plus n gaps (matching RenderAdditionalPages) to the CONTENT viewport, which excludes
// the reserved vertical scrollbar - the surround padding is 0 in grid. ViewportWidth
// can be 0 before the first layout pass; fall back to ActualWidth, and the Background
// re-fit that follows every grid entry corrects it.
double vw = PagePreviewPanel.ViewportWidth > 0
? PagePreviewPanel.ViewportWidth : PagePreviewPanel.ActualWidth;
if (vw <= 0 || rdW <= 0) return _zoomLevel;
// 2px slack: the zoom used to fit n columns EXACTLY, so a tile width pixel-snapping up
// a fraction (UseLayoutRounding) overflowed the wrap panel and the last column dropped
// to the next row - a 3-column grid showing 2 columns and a column-wide gap, at some
// pane widths and not others.
return (vw - n * GridGapPx - 2) / (n * rdW);
}
internal void GridZoomStep(bool zoomOut)
{
double rdW = _annotationCanvas.Width > 0 ? _annotationCanvas.Width : 1583;
double vw = PagePreviewPanel.ActualWidth;
if (vw <= 0 || rdW <= 0) { SetZoom(zoomOut ? _zoomLevel - ZoomStep : _zoomLevel + ZoomStep); return; }
// _gridColumns is the authoritative current column count (set on every grid zoom and restored
// per tab); step from it rather than re-deriving it from the zoom + geometry.
int curN = Math.Max(1, _gridColumns);
int newN = Math.Max(1, zoomOut ? curN + 1 : curN - 1);
// If the column count is already at the limit the clamped zoom is unchanged, so
// skip the re-render entirely - otherwise every Ctrl+Scroll reloads all tiles
// without changing anything.
double target = Math.Max(ZoomMin, Math.Min(ZoomMax, GridZoomForN(newN)));
if (Math.Abs(target - _zoomLevel) < 1e-4) return;
_gridColumns = newN;
SetZoom(target); // already clamped to [ZoomMin, ZoomMax]
}
/// <summary>
/// Central zoom-change entry point for buttons, keyboard shortcuts, and the dropdown.
/// Clamps to [ZoomMin, ZoomMax], applies the scale, syncs the combo box, and updates
/// the status bar. Does NOT apply a fit mode - call FitToWidth / FitToPage for that.
/// </summary>
// The internal _zoomLevel scales each page's layout box. In Continuous mode that box is
// the page's natural DIP width, so _zoomLevel already reads as true zoom (1.0 = 100%).
// In Single/Two-Page/Grid the box is the render-dimension bitmap (~2x natural width), so
// the raw _zoomLevel reads about half the real size. DisplayZoomFactor converts to true
// zoom for everything shown to (or typed by) the user; the internal value is unchanged.
private double DisplayZoomFactor()
{
if (_viewMode == ViewMode.Continuous || _doc is null) return 1.0;
int idx = _viewMode == ViewMode.Grid ? 0 : Math.Max(0, State.CurrentPage); // never the shared sidebar's index (see ApplyZoom)
if (idx < 0 || idx >= _doc.PageCount) return 1.0;
if (!_renderDims.TryGetValue(idx, out var d) || d.w <= 0) return 1.0;
double wpt = _doc.Pages[idx].Width.Point, hpt = _doc.Pages[idx].Height.Point;
if (_pageRotations.TryGetValue(idx, out int r) && (r == 90 || r == 270)) wpt = hpt;
double naturalW = wpt * 96.0 / 72.0;
if (naturalW <= 0) return 1.0;
return d.w / naturalW;
}
internal double DisplayZoomPct() => _zoomLevel * DisplayZoomFactor() * 100.0;
internal void SetZoom(double level)
{
_fitMode = FitMode.None;
_zoomLevel = Math.Max(ZoomMin, Math.Min(ZoomMax, level));
ApplyZoom();
SyncZoomBox();
if (_doc != null && _currentPage >= 0)
SetStatus(string.Format(Loc("Str_PageOf"), _currentPage + 1, _doc.PageCount) + $" - {DisplayZoomPct():F0}%");
}
/// <summary>
/// Zoom entry point for callers that work in TRUE zoom (1.0 = the 100% the user sees),
/// as opposed to <see cref="SetZoom"/>, which takes the internal render-dim scale.
/// Converts through <see cref="DisplayZoomFactor"/> so an absolute zoom lands on the same
/// percentage in every view mode instead of ~182% outside Continuous. The zoom dropdown
/// already does this same conversion inline for its presets.
/// </summary>
internal void SetTrueZoom(double trueZoom)
{
double zf = DisplayZoomFactor();
if (zf <= 0) zf = 1.0;
SetZoom(trueZoom / zf);
}
internal void ZoomIn_Click(object sender, RoutedEventArgs e) { if (_viewMode == ViewMode.Grid) GridZoomStep(false); else SetZoom(_zoomLevel + ZoomStep); }
internal void ZoomOut_Click(object sender, RoutedEventArgs e) { if (_viewMode == ViewMode.Grid) GridZoomStep(true); else SetZoom(_zoomLevel - ZoomStep); }
/// <summary>Set by SyncZoomBox around its programmatic writes. Same story as
/// _syncingPageList: the box's real subscription is the WINDOW's XAML-bound stub, so the
/// old detach/attach of this pane's own delegate removed nothing and ADDED a direct
/// subscription per sync - which is how pane A's handler kept running while pane B was
/// focused, fitting A against B's document (the zoomtrace smoking gun, 2026-08-01).</summary>
private bool _syncingZoomBox;
internal void SyncZoomBox()
{
_syncingZoomBox = true;
try
{
// When a fit mode is active, show the "Fit Width"/"Fit Page" entry rather than a raw
// percentage so the box matches the status bar.
string? fitTag = _fitMode == FitMode.Width ? "fitwidth"
: _fitMode == FitMode.Page ? "fitpage"
: null;
string target = $"{DisplayZoomPct():F0}%";
Host?.SyncZoomDisplay(fitTag, target);
}
finally { _syncingZoomBox = false; }
}
internal void ZoomBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (_syncingZoomBox) return; // programmatic sync, not a user pick
// Belt and braces beside the routing fix in MainWindowViewerBridge: this pane's zoom
// box actions only ever apply to the focused pane.
if (Host != null && !Host.IsViewerFocused(this)) return;
string? tag = Host?.SelectedZoomTag;
if (tag is null) return;
// Editable combos highlight the shown value after a pick (looks like selected text);
// collapse that selection to just the caret once the value settles.
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Input, (Action)(() =>
{
Host?.CollapseZoomTextSelection();
}));
if (tag == "fitwidth")
{
App.SetSetting("DefaultFitMode", FitMode.Width.ToString());
FitToWidth();
return;
}
if (tag == "fitpage")
{
App.SetSetting("DefaultFitMode", FitMode.Page.ToString());
FitToPage();
return;
}
if (double.TryParse(tag, System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out double z))
{
_fitMode = FitMode.None;
// Preset tags are true zoom (1.0 = 100%); convert to the internal render-dim scale.
double zf = DisplayZoomFactor(); if (zf <= 0) zf = 1.0;
_zoomLevel = Math.Max(ZoomMin, Math.Min(ZoomMax, z / zf));
ApplyZoom();
if (_currentPage >= 0 && _doc != null)
SetStatus(string.Format(Loc("Str_PageOf"), _currentPage + 1, _doc.PageCount) + $" - {DisplayZoomPct():F0}%");
}
}
// The selected page's DIP size for fit/zoom math (Single + Two-Page). Prefer _renderDims - it's set
// synchronously in RenderPage so it always matches the current page and is zoom-stable (scaledMax
// scales with zoom while RenderPage divides it back out, so the two cancel). Fall back to PageImage's
// live layout size only when _renderDims has no entry yet, and to 1 to avoid divide-by-zero. Single
// source so FitToWidth/FitToPage don't each re-derive it. (Continuous/Grid use their own page metrics.)
private (double w, double h) GetPageDipSize(int idx)
{
if (idx >= 0 && _renderDims.TryGetValue(idx, out var d))
return (d.w, d.h);
return (PageImage.ActualWidth > 0 ? PageImage.ActualWidth : 1,
PageImage.ActualHeight > 0 ? PageImage.ActualHeight : 1);
}
internal void FitToWidth(bool lite = false)
{
double viewW = PagePreviewPanel.ActualWidth - 40;
if (viewW <= 0) return;
// Continuous mode: pages are laid out at _continuousPageW (natural DIPs width)
// and scaled by the ScaleTransform on PageContentGrid. PageImage is hidden, so
// we cannot use its Source as a guard; use _continuousPageW directly instead.
if (_viewMode == ViewMode.Continuous)
{
if (_continuousPageW <= 0) return;
_fitMode = FitMode.Width;
_zoomLevel = Math.Max(ZoomMin, Math.Min(ZoomMax, viewW / _continuousPageW));
ApplyZoom(lite);
int ci = State.CurrentPage; // this pane's page, never the shared sidebar's (see ApplyZoom)
if (ci >= 0 && _doc != null)
SetStatus(string.Format(Loc("Str_FitWidth"), ci + 1, _doc.PageCount, $"{DisplayZoomPct():F0}"));
return;
}
if (PageImage.Source is null) return;
int idx = State.CurrentPage; // this pane's page, never the shared sidebar's (see ApplyZoom)
double dipW = GetPageDipSize(idx).w;
if (dipW <= 0) return;
// Two Page mode shows two pages side by side - each page gets roughly half
// the viewport width (minus a small gap between pages).
double slotW = _viewMode == ViewMode.TwoPage ? (viewW - 12) / 2 : viewW;
_fitMode = FitMode.Width;
_zoomLevel = Math.Max(ZoomMin, Math.Min(ZoomMax, slotW / dipW));
ApplyZoom(lite);
if (idx >= 0 && _doc != null)
SetStatus(string.Format(Loc("Str_FitWidth"), idx + 1, _doc.PageCount, $"{DisplayZoomPct():F0}"));
}
internal void FitToPage(bool lite = false)
{
double viewW = PagePreviewPanel.ActualWidth - 40;
double viewH = PagePreviewPanel.ActualHeight - 40;
if (viewW <= 0 || viewH <= 0) return;
// Continuous mode: derive the current page's natural height from its PDF aspect
// ratio and _continuousPageW, then fit both axes.
if (_viewMode == ViewMode.Continuous)
{
if (_continuousPageW <= 0 || _doc is null) return;
int ci = State.CurrentPage; // this pane's page, never the shared sidebar's (see ApplyZoom)
if (ci < 0 || ci >= _doc.PageCount) return;
var pdfPage = _doc.Pages[ci];
double ratio = Math.Max(0.1, pdfPage.Height.Point / Math.Max(1.0, pdfPage.Width.Point));
double dipH = _continuousPageW * ratio;
_fitMode = FitMode.Page;
_zoomLevel = Math.Max(ZoomMin, Math.Min(ZoomMax,
Math.Min(viewW / _continuousPageW, viewH / dipH)));
ApplyZoom(lite);
SetStatus(string.Format(Loc("Str_FitPage"), ci + 1, _doc.PageCount, $"{DisplayZoomPct():F0}"));
return;
}
if (PageImage.Source is null) return;
int idx = State.CurrentPage; // this pane's page, never the shared sidebar's (see ApplyZoom)
var (dipW, dipH2) = GetPageDipSize(idx);
if (dipW <= 0 || dipH2 <= 0) return;
double slotW2 = _viewMode == ViewMode.TwoPage ? (viewW - 12) / 2 : viewW;
_fitMode = FitMode.Page;
_zoomLevel = Math.Max(ZoomMin, Math.Min(ZoomMax,
Math.Min(slotW2 / dipW, viewH / dipH2)));
ApplyZoom(lite);
SetStatus(string.Format(Loc("Str_FitPage"), idx + 1, _doc!.PageCount, $"{DisplayZoomPct():F0}"));
}
// Re-fit the main view after a reload. Grid keeps its column-fit (FitToWidth alone would
// yank it out into a single-page Fit Width view); other modes honor the fit mode.
// Called for BOTH panes after a splitter drag, so like the viewport's SizeChanged it has to
// run against the pane it is named on rather than the focused pane. See WithOwnSession.
internal void ReapplyGridOrFit() => WithOwnSession(ReapplyGridOrFitCore);
private void ReapplyGridOrFitCore()
{
if (_viewMode == ViewMode.Grid)
{
double rdW = _annotationCanvas.Width > 0 ? _annotationCanvas.Width : 1583;
double vw = PagePreviewPanel.ActualWidth;
if (vw > 0 && rdW > 0)
SetZoom(GridZoomForN(Math.Max(1, _gridColumns))); // authoritative column count
else ApplyZoom();
return;
}
if (_fitMode == FitMode.Page) FitToPage();
else FitToWidth();
}
internal void NavigatePageByWheel(int delta)
=> NavigatePageStep(delta > 0 ? -1 : 1);
// Moves the selection one page - or one two-page SPREAD in Two-Page mode (#120), landing on
// the spread's left page so a press always shows the NEXT spread instead of re-showing the
// current one from its right page. direction: -1 = back, +1 = forward. Returns true when
// the selection moved. Shared by the wheel, the Up/Down edge-flip, and the Left/Right keys.
// #193: book layout for Two-Page - the cover displays alone, so facing pairs run
// (1,2), (3,4)... like a physical book instead of (0,1), (2,3). Global preference.
internal static bool BookMode => App.GetSetting("TwoPageBook") == "1";
// Left page of the spread containing <paramref name="page"/> under the active layout.
private static int SpreadStart(int page)
=> BookMode ? (page == 0 ? 0 : page - ((page + 1) % 2)) : page - page % 2;
// #193: re-run the current mode's layout in place (the book toggle re-pairs spreads).
// This must bypass ApplyViewMode's same-mode early return: the mode stays TwoPage while its
// pairing rule changes, so treating it as a no-op leaves the old cover/spread tiles visible.
internal void ReapplyViewMode()
{
if (_viewMode == ViewMode.TwoPage)
ApplyViewMode(_viewMode, force: true);
}
internal bool NavigatePageStep(int direction)
{
if (_doc is null) return false;
int cur = _currentPage;
if (_viewMode == ViewMode.TwoPage)
{
int baseIdx = SpreadStart(Math.Max(0, cur)); // left page of the current spread
int target;
if (BookMode)
target = direction > 0
? (baseIdx == 0 ? 1 : baseIdx + 2)
: (baseIdx <= 1 ? 0 : baseIdx - 2);
else
target = baseIdx + direction * 2;
if (target == baseIdx || target < 0 || target >= _doc.PageCount) return false;
_currentPage = target;
return true;
}
int t = cur + direction;
if (t < 0 || t >= _doc.PageCount) return false;
_currentPage = t;
return true;
}
private System.Windows.Threading.DispatcherTimer? _resizeRefitTimer;
private int _gridColumns = 1; // columns the grid is currently laid out in; held across resizes
// internal: PdfViewer's XAML binds this and forwards to it.
// Every pane raises this from its OWN ScrollViewer. It must NOT go through WithOwnSession:
// that swaps the shared fields mid-handler, and the fit below changes the scroll content's
// size, which raises this again - with the zoom being swapped underneath it the fit never
// converges and the app locks up before it can paint. An unfocused pane simply sits the
// fit out; it re-fits from ReapplyGridOrFit when the drag or the split settles.
internal void PagePreviewPanel_SizeChanged(object sender, SizeChangedEventArgs e)
{
if (Host != null && !Host.IsViewerFocused(this))
{
// Unfocused pane: it still has to re-fit, or it keeps the zoom from its old width
// and the page ends up cut off - which is what opening a file in the other pane
// did to this one. Just not INLINE: the shared fields describe the other pane right
// now, and fitting from inside a layout event is what looped. The settle timer runs
// ReapplyGridOrFit through WithOwnSession, after the pass, against this pane's own
// document.
StartResizeSettleTimer();
return;
}
PagePreviewPanelSizeChangedCore(e);
}
private void PagePreviewPanelSizeChangedCore(SizeChangedEventArgs e)
{
RepositionAnnotationBars(); // cheap; keep the draw/text bar tracking its anchored edge
if (_cropPreviewRect is not null || _cropConfirmBar is not null) return;
if (_viewMode == ViewMode.Grid)
{
// Grid columns depend only on width, so a height-only resize (e.g. dragging the bottom
// edge) changes nothing - skip it so it doesn't needlessly re-render/blink.
if (!e.WidthChanged) return;
// Hold the column count through a non-modal resize: scale the already-laid-out tiles via
// the transform so the same number of columns fills the new width (lite, no re-render).
if (_doc is null || _gridColumns < 1) return;
double rdWg = _annotationCanvas.Width > 0 ? _annotationCanvas.Width : 1583;
if (PagePreviewPanel.ActualWidth <= 0 || rdWg <= 0) return;
_zoomLevel = Math.Max(ZoomMin, Math.Min(ZoomMax, GridZoomForN(_gridColumns)));
ApplyZoom(lite: true);
StartResizeSettleTimer();
return;
}
// Non-modal resize (maximize/restore, splitter, programmatic): rescale lite + settle.
if (_fitMode == FitMode.Width) FitToWidth(lite: true);
else if (_fitMode == FitMode.Page) FitToPage(lite: true);
StartResizeSettleTimer();
}
// Coalesces resize ticks: the crisp re-render runs once, a beat after the last size change.
private void StartResizeSettleTimer()
{
if (_resizeRefitTimer is null)
{
_resizeRefitTimer = new System.Windows.Threading.DispatcherTimer
{ Interval = TimeSpan.FromMilliseconds(110) };
// This pane's own timer, so the work runs against this pane's document. An
// unfocused pane wants the full re-fit (it skipped the inline one); the focused
// pane already fitted live and only needs the crisp re-render.
_resizeRefitTimer.Tick += (_, _) =>
{
_resizeRefitTimer!.Stop();
if (Host != null && !Host.IsViewerFocused(this)) ReapplyGridOrFit();
else WithOwnSession(OnResizeSettled);
};
}
_resizeRefitTimer.Stop();
_resizeRefitTimer.Start();
}
private void OnResizeSettled()
{
if (_viewMode == ViewMode.Grid)
{
// Crisp re-render at the held column count for the final size (the drag only transform-
// scaled the tiles). The grid's width is stable (vertical scrollbar reserved), so this
// settles in one pass instead of looping.
if (_doc is not null && _gridColumns >= 1)
SetZoom(Math.Max(ZoomMin, Math.Min(ZoomMax, GridZoomForN(_gridColumns))));
RepositionAnnotationBars(); // settle the bar against the final pane size
return;
}
if (_fitMode == FitMode.Width) FitToWidth();
else if (_fitMode == FitMode.Page) FitToPage();
RepositionAnnotationBars(); // settle the bar against the final pane size (scrollbar may have toggled)
}
private int NearestContinuousPage(double yInPanel)
{
int best = -1; double bestDist = double.MaxValue;
for (int i = 0; i < _continuousTops.Count && i < _continuousPanel.Children.Count; i++)
{
double top = _continuousTops[i];
double h = ((FrameworkElement)_continuousPanel.Children[i]).Height;
if (double.IsNaN(h)) h = 0;
double bottom = top + h;
double dist = yInPanel < top ? top - yInPanel : (yInPanel > bottom ? yInPanel - bottom : 0);
if (dist < bestDist) { bestDist = dist; best = i; }
}
return best;
}
internal void SelectViewMode(ViewMode mode)
{
SetViewMode(mode);
// Leave the flyout open (PanelMenuItem) so the user can try view modes back to back.
}
// Fade timings for a view-mode switch: quick dip to black-out the relayout, calmer reveal.
private const int ViewFadeOutMs = 90;
private const int ViewFadeInMs = 140;
// Target mode while a fade-out is in flight. Non-null means a switch is mid-fade; a new
// SetViewMode during that window just retargets it (rapid F5-F8 presses land on the last).
// Forwarding property onto the per-view state - it belongs to a view, not to the window,
// since two panes fade independently. _pendingViewMode is defined in PdfViewer.Bridge.cs
// with the rest of the state accessors; PendingViewMode below is how the window's flyout
// code reaches it.
internal ViewMode? PendingViewMode { get => _pendingViewMode; set => _pendingViewMode = value; }
internal int GridColumns { get => _gridColumns; set => _gridColumns = value; }
// Fade wrapper around the actual mode switch (ApplyViewMode). The switch itself swaps panel
// visibility instantly and defers its layout/scroll setup through the dispatcher, so doing
// it live flashed intermediate frames - most visibly page 1 at the top of the continuous
// strip before the deferred ScrollContinuousToPage ran. Fading the viewport out, switching
// while it's invisible, and fading back in AFTER the setup queue drains hides all of that
// and gives every mode switch the same soft transition.
internal void SetViewMode(ViewMode mode)
{
if (_pendingViewMode is not null) // mid-fade: retarget the switch already underway
{
_pendingViewMode = mode;
return;
}
if (_viewMode == mode) return;
if (_doc is null) { ApplyViewMode(mode); return; } // start screen: nothing visible to fade
_pendingViewMode = mode;
var fadeOut = new DoubleAnimation(PagePreviewPanel.Opacity, 0,
new Duration(TimeSpan.FromMilliseconds(ViewFadeOutMs)))
{ EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } };
fadeOut.Completed += (_, _) =>
{
var target = _pendingViewMode ?? mode;
_pendingViewMode = null;
ApplyViewMode(target);
// The mode's setup work is queued at Loaded priority (continuous nests one more
// Loaded dispatch for its scroll-to-page; grid one Background dispatch for its
// re-fit). ContextIdle is below all of those, so the fade-in only starts once the
// new mode is laid out and scrolled to the right page - no intermediate frames.
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.ContextIdle, (Action)(() =>
{
var fadeIn = new DoubleAnimation(0, 1,
new Duration(TimeSpan.FromMilliseconds(ViewFadeInMs)))
{ EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } };
PagePreviewPanel.BeginAnimation(UIElement.OpacityProperty, fadeIn);
}));
};
PagePreviewPanel.BeginAnimation(UIElement.OpacityProperty, fadeOut);
}
internal void ApplyViewMode(ViewMode mode, bool force = false)
{
if (_viewMode == mode && !force) return;
_viewMode = mode;
_renderedPrimaryPage = -1; // spread/layout changes with the mode; force the next render
_gridScrollToPage = -1;
App.SetSetting("ViewMode", mode.ToString());
bool isContinuous = mode == ViewMode.Continuous;
_pageContentPanel.Visibility = isContinuous ? Visibility.Collapsed : Visibility.Visible;
_continuousPanel.Visibility = isContinuous ? Visibility.Visible : Visibility.Collapsed;
if (!isContinuous)
{
_continuousRenderCts?.Cancel();
_continuousPanel.Children.Clear();
_continuousTops.Clear();
_continuousCanvases.Clear();
_pages.Clear();
}
if (_doc is null) return;
int idx = _currentPage;
if (mode == ViewMode.Continuous)
{
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded,
() => SetupContinuousView(idx));
}
else
{
_secondaryRenderCts?.Cancel();
ClearSecondaryPages();
_pageContentPanel.Width = double.NaN;
// Drop any scroll offset carried over from the previous mode (especially Continuous,
// whose large vertical offset would otherwise land the grid mid-document).
PagePreviewPanel.ScrollToVerticalOffset(0);
PagePreviewPanel.ScrollToHorizontalOffset(0);
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded, () =>
{
RenderPage(mode == ViewMode.Grid ? 0 : idx);
// Grid: apply a clean column-fit zoom (continuous's zoom is far too large for a
// grid, and a non-column zoom leaves a gap). SetZoom -> ApplyZoom defers the
// single tile render, so return here instead of calling RefreshPageView again
// (a second render would duplicate tiles).
if (mode == ViewMode.Grid)
{
_gridColumns = Math.Min(_doc!.PageCount, 3);
SetZoom(GridZoomForN(_gridColumns));
// The first fit can run before the viewport width has settled (leaving the
// grid off-center / at the wrong zoom); re-fit once more after layout settles,
// and pin to the top so nothing carries over from the previous mode.
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Background,
(Action)(() =>
{
ReapplyGridOrFit();
// Selection is preserved across the switch; scroll to that page once
// its tile streams in (grid tiles render async). Page 0 stays at top.
if (idx > 0) _gridScrollToPage = idx;
else
{
PagePreviewPanel.ScrollToVerticalOffset(0);
PagePreviewPanel.ScrollToHorizontalOffset(0);
}
}));
return;
}
// Switching into Single or Two-Page fits the whole page so it isn't left at an
// awkward carried-over zoom from another mode.
if (mode == ViewMode.Single || mode == ViewMode.TwoPage) FitToPage();
else if (_fitMode == FitMode.Width) FitToWidth();
else if (_fitMode == FitMode.Page) FitToPage();
else ApplyZoom();
RefreshPageView(idx);
});
}
}
}
}