using System; using System.Windows; using System.Windows.Controls; using System.Windows.Input; using System.Windows.Media; using KillerPDF.Controls; namespace KillerPDF { /// /// Two document panes side by side in one window. /// /// One toolbar, sidebar, status line and set of document fields serve both panes, so every /// window -> viewer call resolves through rather than naming a pane. /// public partial class MainWindow { /// Neither pane may go below this. Both handles clamp against both minimums, so /// dragging either one stops at whichever pane would go under first. private const double MinPaneWidth = 320; /// Inset a pane keeps from the window edge. private const double PaneEdge = 8; /// Channel between the two cards, sized so the gap either side of a pane reads the /// same whether its neighbor is the other pane or the window edge. Zero when unsplit. private double SplitGutter => TryFindResource("SplitPaneGutterWidth") is double width ? width : PaneEdge; /// The pane every window control acts on. internal PdfViewer ActiveViewer { get; private set; } = null!; private bool _isSplit; /// Read by each pane's RebuildTabStrip: while split, both panes show their tab band /// even with one document, or their card tops sit at different heights. internal bool IsSplit => _isSplit; private bool _draggingSplit; private double _splitDragStartX; private double _splitDragStartAWidth; /// Pane A's chosen width. A is the FIXED column and B takes the remainder, so /// narrowing the window eats into pane B first and pane A keeps the size you gave it. Only /// once B is down to does A start giving ground, and it stops at /// the same minimum. It was the other way round - A star-sized - which meant dragging the /// window's right edge shrank the pane at the far LEFT of the window. private double _paneAWidth; /// Pane A's share of the host at the last layout. Window-state JUMPS (snap, /// maximize, unmaximize, full screen) re-derive from this so the /// panes keep their proportions; an interactive edge drag keeps A fixed as documented /// above. WM_ENTER/EXITSIZEMOVE () tells the two apart. private double _paneARatio; /// True while the user is interactively moving or resizing the window /// (WM_ENTERSIZEMOVE..WM_EXITSIZEMOVE, tracked in WndProc). private bool _inWindowSizeMove; /// Wire both panes up. Called from the constructor. private void InitSplitPanes() { Viewer.AttachHost(this); ViewerB.AttachHost(this); ActiveViewer = Viewer; // Each pane builds its own tile tree. Routing this through ActiveViewer would build // pane A twice and leave pane B with a null annotation canvas. Viewer.InitTiles(); ViewerB.InitTiles(); // Each pane's strip binds to its OWN session collection. Routing this through // ActiveViewer would bind pane A's strip twice and leave pane B's showing nothing. Viewer.InitTabStripExt(); ViewerB.InitTabStripExt(); // PreviewMouseDown, not MouseDown: the page overlays and annotation tools handle the // bubbling event and would swallow it. Viewer.PreviewMouseDown += (_, _) => FocusPane(Viewer); ViewerB.PreviewMouseDown += (_, _) => FocusPane(ViewerB); // The wheel focuses too. The zoom toolbar, the zoom box and Ctrl+wheel all act on the // FOCUSED pane, so wheeling over a pane you had not clicked was zooming and scrolling // the other one - which reads as "zooming pane B zoomed pane A". Preview, so focus has // moved before the pane's own wheel handler runs. Viewer.PreviewMouseWheel += (_, _) => FocusPane(Viewer); ViewerB.PreviewMouseWheel += (_, _) => FocusPane(ViewerB); // Re-lay the columns whenever the host resizes, so the window's own edge takes width // out of pane B first. Without this A, being the fixed column, would simply keep its // width and B would be clipped to nothing. // // SyncSplitMinWidth must NOT be called from here. Setting MinWidth can resize the // window, which fires this handler again, which sets MinWidth again - an unbounded // layout loop that hard-froze the app. The floor only changes when the split opens or // closes, so that is the only place it is recomputed. // // Queued, never run inline. ApplyPaneWidths writes the column widths, which is itself a // layout change - assigning straight from the handler re-enters the layout pass it was // raised by. At Background priority it runs after that pass has finished. SplitHost.SizeChanged += (_, _) => Dispatcher.BeginInvoke(new Action(OnSplitHostResized), System.Windows.Threading.DispatcherPriority.Background); ApplyFocusHalo(); } /// Point the window's element accessors at a pane, with NONE of FocusPane's side /// effects, and hand back the previous one. WithOwnSession needs this: it swaps the document /// FIELDS to a pane, but every element the render path reaches for - PageGrid, PageHost, /// ContinuousHost, PreviewScroller - still resolves through ActiveViewer, so an unfocused /// pane's re-fit measured and painted into the OTHER pane's tiles. That is what zoomed a /// pane wildly, and what put one pane's pages into the other. internal PdfViewer SwapActiveViewer(PdfViewer pane) { var prev = ActiveViewer; ActiveViewer = pane; return prev; } /// F10 toggles the split. Bound from KeyboardShortcuts. internal void ToggleSplit() { if (_isSplit) CloseSplit(); else OpenSplit(); } /// Reopen the split and pane B's tabs from the last session. Runs at the tail of /// the startup restore, after pane A is populated. /// /// Pane B's tabs are restored the same lazy way pane A's are - placeholders, with only its /// active tab loaded - but loading it has to happen with B focused, because the load path /// writes through the window's shared document fields. Focus always returns to A; which /// pane had focus at exit is not saved yet. private bool _restoringSplit; // set only during that restore - see OpenSplit private void RestorePaneB() { try { RestorePaneBCore(); } catch (Exception ex) { // A failed restore must never take the window down with it. Whatever went wrong, // the app still has to come up - worst case with one pane and no tabs in B. _restoringSplit = false; SetStatus(string.Format(Loc("Str_St_RestorePane"), ex.Message)); } } private void RestorePaneBCore() { if (App.GetSetting("SplitOpen") != "1") return; // Seed pane A's fixed width from the saved setting BEFORE opening the split. // OpenSplit's own fallback (Viewer.ActualWidth) is always 0 at this point - the window // is still inside its own Loaded handler and has not laid out yet - so without this, // pane A opened pinned to MinPaneWidth every launch no matter what width it was left // at, which is what "the pane is always small when the app loads" was (#161). var savedA = App.GetSetting("SplitPaneAWidth"); if (!string.IsNullOrEmpty(savedA) && double.TryParse(savedA, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out double aw) && aw >= MinPaneWidth) _paneAWidth = aw; _restoringSplit = true; try { OpenSplit(); } finally { _restoringSplit = false; } var saved = App.GetSetting("OpenTabsB"); if (string.IsNullOrEmpty(saved)) return; var restored = new System.Collections.Generic.List(); foreach (var f in saved!.Split('|')) if (!string.IsNullOrEmpty(f) && System.IO.File.Exists(f)) restored.Add(PdfViewer.MakeDeferredSession(f)); if (restored.Count == 0) return; var wantActive = App.GetSetting("ActiveTabB"); var target = (!string.IsNullOrEmpty(wantActive) ? restored.FirstOrDefault(s => string.Equals(s.OriginalFile, wantActive, StringComparison.OrdinalIgnoreCase)) : null) ?? restored[0]; ViewerB.SetSessionsExt(restored, target); FocusPane(ViewerB); ViewerB.ApplySessionStateExt(target); ViewerB.MaterializeDeferredExt(target); ViewerB.RebuildTabStripExt(); FocusPane(Viewer); // B's document was loaded against whatever width the column had at that instant, which // during startup is not yet its final one. Re-fit both once the layout settles - same // reason as the queued fit in OpenSplit, one pass later. Dispatcher.BeginInvoke(new Action(() => { Viewer.ReapplyGridOrFit(); ViewerB.ReapplyGridOrFit(); }), System.Windows.Threading.DispatcherPriority.Loaded); } /// Sidebar rail button, same action as F10. private void SplitPaneRailBtn_Click(object sender, RoutedEventArgs e) => ToggleSplit(); /// Light the rail button while the split is open, the same tell the night-mode /// moon beside it uses. private void SyncSplitRailButton() { if (SplitPaneRailBtn != null) SplitPaneRailBtn.Tag = _isSplit ? "on" : null; } private void OpenSplit() { if (_isSplit) return; _isSplit = true; // Pane A keeps the width it already has and the WINDOW grows to make room for pane B, // which opens matching it. Halving pane A instead would resize the document you are // reading just to put a second one beside it - and it is the counterpart of the close, // which shrinks the window back by exactly the same amount, so F10 round-trips to where // it started. If there is no room left on the work area the window grows as far as it // can and ApplyPaneWidths takes the shortfall out of pane A, down to the minimum. // During the startup restore, pane A's width was already seeded from the saved setting // by RestorePaneBCore, above, before this ran - Viewer.ActualWidth is not usable there, // see the comment on that seed. Everywhere else, pane A keeps the width it already has. double aNow = _restoringSplit && _paneAWidth > 0 ? _paneAWidth : Viewer.ActualWidth > 0 ? Viewer.ActualWidth : MinPaneWidth; _paneAWidth = aNow; // No window resize during the startup restore. The window is still inside its Loaded // handler and has not rendered yet; changing Width there produced a blank, unpainted // window. On restore the saved size already accounts for both panes anyway, so there is // nothing to grow by - ApplyPaneWidths just divides what is there. double grow = 0; if (WindowState == WindowState.Normal && !_restoringSplit) { double room = SystemParameters.WorkArea.Right - Left; grow = Math.Min(aNow + SplitGutter, Math.Max(0, room - Width)); } // Pane B's final width is whatever the window could actually give it. double bTarget = Math.Max(MinPaneWidth, grow - SplitGutter); // Maximized, snapped, or already hard against the work area edge: there is no room to // grow the window by pane A's full width, so keeping pane A at its size would squeeze // pane B down to the bare minimum - a lopsided split nobody asked for on a window that // is plainly big enough for two. When the window cannot grow that far, split what is // already there evenly instead (2026-08-01). The exact round-trip (pane A keeps // its size, the window grows to match) stays the behavior whenever there IS room. bool evenSplit = !_restoringSplit && grow + 0.5 < aNow + SplitGutter; if (evenSplit) { _paneAWidth = Math.Max(MinPaneWidth, (aNow - SplitGutter) / 2); bTarget = Math.Max(MinPaneWidth, aNow - SplitGutter - _paneAWidth); } // Start closed and slide open. During the restore there is nothing to slide - the panes // are simply there when the window paints - so the columns go straight to their places. PaneACol.Width = new GridLength(aNow, GridUnitType.Pixel); PaneBCol.Width = new GridLength(0, GridUnitType.Pixel); PaneGutterCol.Width = new GridLength(0); if (_restoringSplit) { PaneGutterCol.Width = new GridLength(SplitGutter); // NOT called synchronously here. RestoreWindowSettings ran moments ago in the same // Loaded handler, but WPF layout is asynchronous - SplitHost.ActualWidth at this // point still reflects the window's PRE-restore size (or 0), not the size just // assigned. Calling ApplyPaneWidths against that stale/zero width is what clamped // pane A down and left pane B with whatever tiny remainder fell out - the saved // split came back on every launch no matter what it was saved at (#161). Deferred to // Loaded priority, same as the re-fit below and in RestorePaneBCore's tail, so it // runs once the restored window size has actually been laid out. The window is still // hidden behind RootClipGrid's opacity hold at this point (see ContentRendered), // so there is nothing to see between the temporary zero-width column set two lines // up and this correcting it. // SyncSplitMinWidth reads SplitHost.ActualWidth too (via its "chrome" measurement), // so it rides along in the same deferred call rather than running synchronously // below against the same stale width. Dispatcher.BeginInvoke(new Action(() => { ApplyPaneWidths(); SyncSplitMinWidth(); // Same width-gate re-run as OpenSplit's done callback: on restore the panes // reach their real widths only after this deferred layout pass. Viewer.SyncRecentBoxWidth(); ViewerB.SyncRecentBoxWidth(); }), System.Windows.Threading.DispatcherPriority.Loaded); } ViewerB.Visibility = Visibility.Visible; SplitHandleA.Visibility = Visibility.Visible; SplitHandleB.Visibility = Visibility.Visible; // Both bands, so both card tops line up - the band is a two-pane decision now. Viewer.RebuildTabStripExt(); ViewerB.RebuildTabStripExt(); // Pane B's start screen has never been filled: its recent list is only populated when a // pane shows its empty state, and B goes straight from hidden to visible without one. // Without this its Recent box stayed blank until B first took focus. PopulateRecentFilesList(); ApplyFocusHalo(); SyncSplitRailButton(); SetStatus(Loc("Str_St_SplitOn")); if (_restoringSplit) { // Already in place, and no animation during startup: a timer-driven slide running // while the window is still being brought up left it painting nothing at all. // (ApplyPaneWidths/SyncSplitMinWidth already queued above, deferred past this point.) return; } // Pane A only tweens when the even-split branch above actually moved its target; the // exact-round-trip path leaves it null and AnimateSplitWidth skips it, unchanged from // before. AnimateSplitWidth(opening: true, bTarget, grow, aFrom: evenSplit ? aNow : (double?)null, aTo: evenSplit ? _paneAWidth : (double?)null, done: () => { ApplyPaneWidths(); // hand pane B back to the star column SyncSplitMinWidth(); // Both panes just changed width, so both have to re-fit. Queued at Loaded so it // runs once the columns are real - on the startup restore the split opens before // the first layout pass has settled, and pane A kept the zoom it was fitted at // full width and opened clipped, with a horizontal scrollbar. Dispatcher.BeginInvoke(new Action(() => { Viewer.ReapplyGridOrFit(); if (_isSplit) ViewerB.ReapplyGridOrFit(); // Re-gate the start screens' Recent boxes against the SETTLED widths. The // populate above ran while pane B's column was still 0 (the slide had not // started), so SyncRecentBoxWidth's width gate collapsed B's box and nothing // re-ran it - pane B showed an empty start screen until the next open action // repopulated the list (2026-08-01). Viewer.SyncRecentBoxWidth(); ViewerB.SyncRecentBoxWidth(); }), System.Windows.Threading.DispatcherPriority.Loaded); }); } private System.Windows.Threading.DispatcherTimer? _splitAnim; /// True while the open/close slide is running. ApplyPaneWidths sits it out - it is /// driven from SplitHost's SizeChanged, which fires on every frame of the slide, and it /// would put pane B straight back on the star column and undo the animation. private bool _splitAnimating; /// Slide the split open or shut. Pane B's column and the gutter animate between 0 /// and ; the window's width changes by /// in one step. Pane A normally never changes size - the window absorbs the whole /// difference - but the even-split open (OpenSplit, when the window cannot grow) also has to /// shrink pane A down to its half-share, and / /// tween it alongside B so the two panes move together instead of A sitting frozen at full /// width until ApplyPaneWidths snaps it down on the final frame - which is what made the /// panes slide erratically before settling (2026-08-01). /// GridLength has no built-in animation, so the columns are stepped off a timer. private void AnimateSplitWidth(bool opening, double bTarget, double widthDelta, Action done, double? aFrom = null, double? aTo = null) { _splitAnim?.Stop(); _splitAnimating = false; // The WINDOW's width changes in ONE step, never animated. Stepping Width frame by frame // off a timer put the app in an unusable state: it fights whatever the window manager // is doing, and a tick landing while Windows was in its own modal resize loop could // stall the animation - leaving _splitAnimating latched, the columns frozen part-open // with the drag handles still live over the pane, the mouse captured and the window // unresizable. Only the columns slide. if (WindowState == WindowState.Normal && Math.Abs(widthDelta) > 0.5) { double w = Width + (opening ? widthDelta : -widthDelta); Width = Math.Max(MinWidth, w); } double bStart = opening ? 0 : ViewerB.ActualWidth; double bEnd = opening ? bTarget : 0; double gStart = opening ? 0 : SplitGutter; double gEnd = opening ? SplitGutter : 0; // Only set on the even-split open; every other call leaves both null, and the tick below // then never touches PaneACol at all - identical to the pre-existing behavior. bool tweenA = aFrom.HasValue && aTo.HasValue && Math.Abs(aFrom.Value - aTo.Value) > 0.5; double aStart = aFrom ?? 0; double aEnd = aTo ?? 0; if (bStart <= 0 && bEnd <= 0 && !tweenA) { done(); return; } // nothing to slide _splitAnimating = true; var clock = System.Diagnostics.Stopwatch.StartNew(); const double durationMs = 160; _splitAnim = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromMilliseconds(15) }; _splitAnim.Tick += (_, _) => { // Belt and braces: finish on elapsed time, so a dropped or delayed tick can never // strand the split half-open with its state latched. double t = Math.Min(1, clock.Elapsed.TotalMilliseconds / durationMs); double e = 1 - Math.Pow(1 - t, 3); // ease out, so it settles rather than stopping dead PaneGutterCol.Width = new GridLength(gStart + (gEnd - gStart) * e); PaneBCol.Width = new GridLength(bStart + (bEnd - bStart) * e, GridUnitType.Pixel); if (tweenA) PaneACol.Width = new GridLength(aStart + (aEnd - aStart) * e, GridUnitType.Pixel); if (t >= 1) { _splitAnim!.Stop(); _splitAnimating = false; done(); } }; _splitAnim.Start(); } private void CloseSplit() { if (!_isSplit) return; _isSplit = false; // Focus returns to A before B is hidden: leaving ActiveViewer pointing at a collapsed // pane would leave the toolbar driving something invisible. FocusPane(Viewer); double aStart = Viewer.ActualWidth; double bStart = ViewerB.ActualWidth; // The WINDOW shrinks back by pane B plus the gutter; pane A keeps the width it has. // Expanding pane A to swallow both panes was the wrong half of the trade - closing the // second pane should put the window back where opening it found it, not resize the // document you were reading. Drop the floor first, with _isSplit already false, or the // two-pane minimum blocks the shrink. SyncSplitMinWidth(); // Handles go first, not in FinishCloseSplit. They sit in the gutter column and stay // hit-testable while it is closing, so a click during the slide could grab a divider // that is on its way out and capture the mouse with nothing left to drag. SplitHandleA.Visibility = Visibility.Collapsed; SplitHandleB.Visibility = Visibility.Collapsed; EndSplitDrag(SplitHandleA); EndSplitDrag(SplitHandleB); if (aStart <= 0 || bStart <= 0) { FinishCloseSplit(); return; } // never laid out PaneACol.Width = new GridLength(aStart, GridUnitType.Pixel); PaneBCol.Width = new GridLength(bStart, GridUnitType.Pixel); // THE RULE IS THE CORNERS (2026-08-01, after several rounds of narrower // conditions each missing a case): // - SQUARED corners (_chromeSquared: maximized OR snapped) - the window is pinned to // screen edges and must not move, so pane A expands to fill the space pane B gives // up. WindowState alone is NOT this test: a snapped window stays WindowState.Normal // (see OnWindowLocationChanged), which is exactly the case every earlier version of // this condition got wrong. // - ROUNDED corners (floating) - closing the second pane closes the second pane: the // window shrinks by pane B plus the gutter and pane A keeps the size it had. // The widthDelta is 0 in the squared case so AnimateSplitWidth cannot shrink a snapped // window (it skips only MAXIMIZED ones on its own, since they are not Normal). bool fillA = _chromeSquared; double aTarget = fillA ? aStart + bStart + SplitGutter : aStart; _paneAWidth = aTarget; AnimateSplitWidth(opening: false, 0, fillA ? 0 : bStart + SplitGutter, FinishCloseSplit, aFrom: fillA ? aStart : (double?)null, aTo: fillA ? aTarget : (double?)null); } /// Tail of CloseSplit: everything that must be true once the slide has finished. /// Split apart so the animation and the never-laid-out shortcut share it. private void FinishCloseSplit() { PaneGutterCol.Width = new GridLength(0); PaneBCol.Width = new GridLength(0); PaneACol.Width = new GridLength(1, GridUnitType.Star); // one pane takes it all SyncSplitMinWidth(); // release the split floor ViewerB.Visibility = Visibility.Collapsed; SplitHandleA.Visibility = Visibility.Collapsed; SplitHandleB.Visibility = Visibility.Collapsed; Viewer.RebuildTabStripExt(); // back to the single-pane rule: hide the band under two tabs Viewer.SyncRecentBoxWidth(); // pane A may have just widened past the Recent box's gate ApplyFocusHalo(); SyncSplitRailButton(); SetStatus(Loc("Str_St_SplitOff")); } /// Point the window's chrome at a pane and move the halo. Cheap and idempotent, so /// it is safe to call from every mouse-down. Internal (not private): PdfViewer's own /// SwitchToTab calls this too, to re-assert ownership of the shared fields before a tab /// switch inside a pane that is not (yet) ActiveViewer - see the comment there. internal void FocusPane(PdfViewer pane) { if (ReferenceEquals(ActiveViewer, pane)) return; // _doc, _currentFile, _annotations and the rest are window fields that both panes bridge // to, so they describe one pane at a time. A pane's documents live in its session list // and swap into those fields when it takes focus - the same handshake tab switching // uses. Without it the sidebar, page count and status line keep describing the pane you // just left. Capture before the swap, apply after, or the outgoing pane's scroll and // zoom land in the incoming pane's session. ActiveViewer.CaptureActiveIfAny(); ActiveViewer = pane; pane.ApplyActiveSessionIfAny(); ApplyFocusHalo(); // The moon lights for the FOCUSED pane's invert state (invert is per pane). DocInvertBtn.Tag = pane.DocInvert ? "on" : null; // Title-bar filename follows the focused pane (it stayed on the previous pane's // document, 2026-08-15). _originalFile holds this pane's file after the session // swap above; an empty pane clears the label. FileNameLabel.Text = System.IO.Path.GetFileName(_originalFile ?? ""); // Restore rather than refresh: RefreshPageList re-decodes every page, which on a large // document costs seconds on every click between panes. RestorePageListForActivePane(); LoadOutlines(); SyncZoomBox(); SetTool(_currentTool, restoringPane: true); // No render here. Each pane keeps its own tile tree, so its document stays painted // whether or not it has focus - focus moves the chrome, not the pixels. A // RenderActiveSession() call here also fires on any mouse-down that reaches a pane, // including while the file dialog is open, painting a document into it before the user // has picked one. } /// Accent border on the focused pane, normal border on the other. Only while split: /// with one pane there is nothing to disambiguate. private void ApplyFocusHalo() { if (!_isSplit) { Viewer.SetFocusHalo(false); ViewerB.SetFocusHalo(false); return; } Viewer.SetFocusHalo(ReferenceEquals(ActiveViewer, Viewer)); ViewerB.SetFocusHalo(ReferenceEquals(ActiveViewer, ViewerB)); } /// Lay the two columns out from : A is the fixed column, /// B is the star that takes what is left. Run on every split-host resize as well as on the /// gutter drag, so narrowing the window comes out of B until B is at the minimum, then out /// of A until A is too. Neither pane can be squeezed below MinPaneWidth from any direction. /// SplitHost.SizeChanged lands here. A size change OUTSIDE an interactive /// move/resize is a window-state jump - snap, maximize, unmaximize, full screen - and those /// keep the panes' RATIO; a 50/50 split must not come out of maximize as 75/25. An edge /// drag keeps pane A fixed so the window edge eats pane B only (see _paneAWidth). private void OnSplitHostResized() { if (_isSplit && !_inWindowSizeMove && !_draggingSplit && _paneARatio > 0) { double avail = SplitHost.ActualWidth - SplitGutter; if (avail > 0) _paneAWidth = Math.Max(MinPaneWidth, avail * _paneARatio); } ApplyPaneWidths(); } private void ApplyPaneWidths() { if (!_isSplit || _splitAnimating) return; // the slide owns the columns while it runs double avail = SplitHost.ActualWidth - SplitGutter; if (avail <= 0) return; double aW = _paneAWidth > 0 ? _paneAWidth : avail / 2; // Give B its minimum first, then let A have what it asked for out of the rest. double maxA = avail - MinPaneWidth; aW = Math.Min(aW, maxA); aW = Math.Max(aW, MinPaneWidth); // Window too narrow to honor both: A keeps the minimum and B takes the remainder. The // window's own MinWidth (SyncSplitMinWidth) is what normally stops this happening. if (aW > avail) aW = Math.Max(0, avail); // Only write when it actually moves, or every layout pass has a fresh value to react to. if (PaneACol.Width.IsStar || Math.Abs(PaneACol.Width.Value - aW) > 0.5) PaneACol.Width = new GridLength(aW, GridUnitType.Pixel); if (!PaneBCol.Width.IsStar) PaneBCol.Width = new GridLength(1, GridUnitType.Star); // Remember the proportion this layout settled on - the value the state-jump path // (OnSplitHostResized) restores. Updated here so a divider drag, an edge resize, and a // programmatic width all refresh it consistently. _paneARatio = aW / avail; } /// The window has no minimum of its own. The only floor is the PANE minimum, which /// is the same number in both modes - one pane's worth unsplit, two plus the gutter when /// split. Everything outside the panes (the sidebar, the margins) is MEASURED rather than /// assumed, so collapsing or moving the sidebar lowers the floor by exactly its width /// instead of the pane having to give the space up. private void SyncSplitMinWidth() { double chrome = Math.Max(0, ActualWidth - SplitHost.ActualWidth); double floor = _isSplit ? chrome + SplitGutter + MinPaneWidth * 2 : chrome + MinPaneWidth; // Only when it actually moves. An unconditional assignment can resize the window, and // anything that re-enters this from a layout event then has a loop to run round. if (Math.Abs(MinWidth - floor) > 0.5) MinWidth = floor; } // One boundary, two handles: whichever you grab is the pane you are sizing. Both resolve to // pane A's width - B is the star and takes the remainder - so they cannot disagree. private void SplitHandle_MouseDown(object sender, MouseButtonEventArgs e) { if (!_isSplit || sender is not Border h) return; _draggingSplit = true; _splitDragStartX = e.GetPosition(SplitHost).X; _splitDragStartAWidth = Viewer.ActualWidth; h.CaptureMouse(); e.Handled = true; } private void SplitHandle_MouseMove(object sender, MouseEventArgs e) { if (!_draggingSplit || sender is not Border h || !h.IsMouseCaptured) return; // Self-heal a lost button-up. If capture survives past the release - which happens when // the up lands on the window's own resize border - the handle keeps the mouse, the // resize cursor stays on screen and nothing else can be clicked until something else // steals capture. Checking the real button state on every move ends the drag anyway. if (e.LeftButton != MouseButtonState.Pressed) { EndSplitDrag(h); return; } double dx = e.GetPosition(SplitHost).X - _splitDragStartX; double total = SplitHost.ActualWidth; double aimA = _splitDragStartAWidth + dx; double maxA = total - SplitGutter - MinPaneWidth; // Out of slack: pane B is already at its minimum and cannot give up any more. Rather // than the divider going dead, GROW THE WINDOW by what pane A still wants - pane A // gets bigger, pane B keeps exactly its width and simply travels right with the // window's edge. Stops at the edge of the work area, and does nothing while maximized. if (aimA > maxA && WindowState == WindowState.Normal) { double room = SystemParameters.WorkArea.Right - Left; double grow = Math.Min(aimA - maxA, Math.Max(0, room - Width)); if (grow > 0) { Width += grow; maxA += grow; } } if (maxA < MinPaneWidth) return; // window too narrow to split meaningfully _paneAWidth = Math.Max(MinPaneWidth, Math.Min(maxA, aimA)); ApplyPaneWidths(); e.Handled = true; } /// Release the drag and the mouse together. Both the normal button-up and the /// self-heal in MouseMove come through here, so capture cannot be left behind. private void EndSplitDrag(Border? h) { if (h is { IsMouseCaptured: true }) h.ReleaseMouseCapture(); _draggingSplit = false; } /// Anything at all that takes capture away mid-drag - an alt-tab, a system move, /// another control grabbing it - has to leave the drag state consistent, or the next click /// is swallowed by a drag that thinks it is still running. private void SplitHandle_LostMouseCapture(object sender, MouseEventArgs e) => _draggingSplit = false; private void SplitHandle_MouseUp(object sender, MouseButtonEventArgs e) { EndSplitDrag(sender as Border); e.Handled = true; // Re-fit once the drag settles, not per mouse-move: a re-render every frame stutters // on a large document. Viewer.ReapplyGridOrFit(); if (_isSplit) ViewerB.ReapplyGridOrFit(); // The drag changed both panes' widths; re-gate their Recent boxes (an empty pane // dragged wide enough should gain the list, one squeezed narrow should shed it). Viewer.SyncRecentBoxWidth(); if (_isSplit) ViewerB.SyncRecentBoxWidth(); } } }