Files
mmd-pdf/Controls/Viewer/PdfViewer.TabStrip.cs
kua-agent 6610acfa44 feat: rebrand to MMD PDF, single corporate theme, and remove original text under an edit
- Services/PdfRedactText.cs: strip text whose origin falls inside a CoverAnnotation
  from the page content stream at save time, hooked into PdfBurn.DrawAnnotationsIntoDoc.
  Fixes edited values staying recoverable by text extraction.
- Themes/MMD.xaml replaces all thirteen themes; picker and accent strip removed; no dark mode.
- Rename KillerPDF -> MMD PDF across code, resources, packaging and locale strings; new icon.
- Remove the upstream author credit and the in-app install button.
2026-08-27 07:37:09 +02:00

724 lines
41 KiB
C#

using System;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
namespace MmdPdf.Controls
{
// This pane's tab band: which tabs are in the strip, which of them owns an edge, what the card's
// top corners do, where the focus ring runs, and the drag that reorders them or hands one to the
// other pane.
//
// Ported from KillerShell (FilePane.xaml + Tabs.cs + DualPane.cs + PaneDrag.cs). The strip is an
// ItemsControl bound to _sessions over a UniformGrid, and every visual decision below is a
// NOTIFYING FLAG ON THE SESSION that a template trigger reads - not a property written onto a
// code-built Border. That is the whole point: there is one place each rule is expressed, so a
// fix to one edge case cannot break the next one.
//
// Two consequences worth knowing before changing anything here:
// * UniformGrid divides the band equally, so the last visible tab ALWAYS reaches the strip's
// right edge. Edge ownership is decided, never measured. The old strip measured it after
// every reflow, which is why the halo came and went with the pane width.
// * A collapsed child is not counted when UniformGrid divides the band, so windowing tabs out
// into the chevron needs no width arithmetic at all - the survivors fill the band on their own.
public partial class PdfViewer
{
/// <summary>Bind the strip to this pane's sessions. Called once, from InitSplitPanes.</summary>
private void InitTabStrip()
{
if (TabStrip != null) TabStrip.ItemsSource = _sessions;
}
/// <summary>
/// The one funnel. Every add, close, switch, drag-reorder and resize ends here, and it is the
/// only thing that writes the strip's state.
/// </summary>
/// <remarks>
/// Still called RebuildTabStrip because ~20 call sites and RebuildTabStripExt already say so,
/// but it rebuilds nothing: the ItemsControl repaints itself off the collection and the
/// notifying flags.
/// </remarks>
private void RebuildTabStrip()
{
if (TabStrip == null || TabStripBorder == null) return;
foreach (var t in _sessions) t.RefreshTabLabel();
int docTabs = _sessions.Count(t => t.Doc != null || t.DeferredPath != null);
// Only show the strip once THIS pane has more than one document - a single open PDF
// doesn't need tabs, split or not. (KillerShell forces the band on in both panes whenever
// either one has 2+ tabs, so the two card tops always line up; the simpler per-pane
// rule is used here instead, so a lone tab never shows a bar even while split.)
bool show = docTabs > 1;
TabStripBorder.Visibility = show ? Visibility.Visible : Visibility.Collapsed;
// The -1 tucks the card's top border a pixel into the band, so the active tab and the card
// read as one surface. ONLY while there IS a band: a collapsed element contributes no
// height, so with no strip the -1 lifts this pane a pixel above the other one instead of
// tucking under anything. The bandless card gets 3px of top air instead of 0 - at 0 it
// opened 3px too high against the chrome (2026-08-01).
CardRow.Margin = new Thickness(0, show ? -1 : 3, 0, 0);
// Win98 tabs sit on a raised client frame. Keep the frame's vertical sides and bottom;
// the one-pixel top ledge is drawn by TabBarRing so the active tab can cover its own
// segment while the ledge remains visible beneath the inactive tabs.
bool retroTheme = Services.ThemeManager.Current == Services.Theme.SE98;
bool retroTabs = show && retroTheme;
if (retroTheme)
{
PaneBevelOuterDark.SetResourceReference(Border.BorderBrushProperty, "DocumentPaneBevelTopLeftBrush");
PaneBevelOuterLight.SetResourceReference(Border.BorderBrushProperty, "DocumentPaneBevelBottomRightBrush");
PaneBevelInnerDark.SetResourceReference(Border.BorderBrushProperty, "DocumentPaneBevelInnerTopLeftBrush");
PaneBevelInnerLight.SetResourceReference(Border.BorderBrushProperty, "DocumentPaneBevelInnerBottomRightBrush");
}
else
{
PaneBevelOuterDark.SetResourceReference(Border.BorderBrushProperty, "PaneBevelDarkBrush");
PaneBevelOuterLight.SetResourceReference(Border.BorderBrushProperty, "PaneBevelLightBrush");
PaneBevelInnerDark.SetResourceReference(Border.BorderBrushProperty, "PaneBevelDark2Brush");
PaneBevelInnerLight.SetResourceReference(Border.BorderBrushProperty, "PaneBevelLight2Brush");
}
if (retroTabs)
{
PaneBorder.BorderThickness = new Thickness(1, 0, 1, 1);
PaneBevelOuterDark.BorderThickness = new Thickness(1, 0, 0, 0);
PaneBevelOuterLight.BorderThickness = new Thickness(0, 0, 1, 1);
PaneBevelInnerDark.BorderThickness = new Thickness(1, 0, 0, 0);
// PaneBorder + the outer dark bevel are the complete Win98 right edge.
// A third same-color inner rule made the edge fat and produced a one-pixel
// step where the selected last tab joined it.
PaneBevelInnerLight.BorderThickness = new Thickness(0, 0, 0, 1);
}
else if (retroTheme)
{
// With no tab band there is nothing for a raised client frame to join. The old
// generic fallback below reapplied all four 98SE bevel resources and drew a heavy
// rectangle around the entire document pane. Keep the single-document client
// flush; the classic frame is only part of the multi-tab treatment above.
PaneBorder.BorderThickness = new Thickness(0);
PaneBevelOuterDark.BorderThickness = new Thickness(0);
PaneBevelOuterLight.BorderThickness = new Thickness(0);
PaneBevelInnerDark.BorderThickness = new Thickness(0);
PaneBevelInnerLight.BorderThickness = new Thickness(0);
}
else
{
PaneBorder.BorderThickness = new Thickness(1);
PaneBevelOuterDark.SetResourceReference(Border.BorderThicknessProperty, "PaneBevelLightThickness");
PaneBevelOuterLight.SetResourceReference(Border.BorderThicknessProperty, "PaneBevelDarkThickness");
PaneBevelInnerDark.SetResourceReference(Border.BorderThicknessProperty, "PaneBevel2LightThickness");
PaneBevelInnerLight.SetResourceReference(Border.BorderThicknessProperty, "PaneBevel2DarkThickness");
}
// Which tabs fit at this width, before anything below asks which is on an edge.
ApplyTabWindow();
// First and last VISIBLE, not first and last in the list. Both are about the strip's own
// edges: IsLast drops the divider that would otherwise land on the right edge as a stray
// rule, and IsFirst/IsLast keep the tab from drawing the outer ring side that the band
// already draws. With tabs windowed out, the tab sitting on an edge is not the one at the
// end of the collection.
//
// And NOT the last visible tab while the chevron is showing: the chevron is what sits on
// the band's right edge then, so the tab is a middle tab in every way that matters. Told
// otherwise it dropped the divider that separates it from the chevron AND handed its right
// side to the band, which drew that side at the band's edge - past the chevron, as an
// accent stripe up the far right with nothing under it.
bool chevron = TabOverflowBtn.Visibility == Visibility.Visible;
var strip = _sessions.Where(t => t.IsStripVisible).ToList();
foreach (var t in _sessions)
{
t.IsFirst = false;
t.IsLast = false;
t.RetroBeforeActive = false;
t.RetroAfterActive = false;
t.RetroLastInactive = false;
t.UseRetroTabChrome = retroTabs;
}
if (strip.Count > 0)
{
strip[0].IsFirst = true;
strip[strip.Count - 1].IsLast = !chevron;
int activeIndex = strip.IndexOf(_active!);
if (retroTabs && activeIndex >= 0)
{
if (activeIndex > 0) strip[activeIndex - 1].RetroBeforeActive = true;
if (activeIndex + 1 < strip.Count) strip[activeIndex + 1].RetroAfterActive = true;
if (!chevron && !strip[^1].IsActive) strip[^1].RetroLastInactive = true;
}
}
SyncPaneLeadingCorner();
UpdatePaneFocusRing();
UpdateTabStripFade();
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded, (Action)UpdateFooterFade);
}
// ════════════════════════════════════════════════════════════════════════════════════════
// OVERFLOW
// ════════════════════════════════════════════════════════════════════════════════════════
// The strip is a UniformGrid, so every tab takes an equal share of the band whatever the
// count - right up to a point, and then off a cliff. Eight tabs in a half-width pane came out
// around forty pixels each, which is not a label, it is a shape. A tab you cannot read is a
// tab you have to click to identify, and at that point the strip has stopped being navigation.
//
// So the COUNT is capped rather than the width. As many tabs as fit at TabFloorWidth stay in
// the strip and the rest are collapsed. The chevron at the right end lists every tab, so
// nothing is unreachable.
//
// Scrolling was the other option and is what a browser does. It lost because the band is a
// bordered surface the pane's focus ring runs along, and a scrolled band cannot be edge to
// edge - the ring would have to stop somewhere that is not a corner.
/// <summary>Narrowest a tab may get before the strip stops taking more.</summary>
/// <remarks>
/// Picked from what it has to hold rather than off a grid: 120px is about sixteen characters
/// at this size once the close x and the padding are paid for - "Quarterly-Repo...", enough to
/// tell two documents apart. Much below a hundred and the ellipsis starts eating the part that
/// distinguishes them, which is the whole job.
/// </remarks>
private const double TabFloorWidth = 120;
/// <summary>What the chevron takes out of the band while it is showing.</summary>
private const double TabChevronWidth = 26;
/// <summary>Index of the leftmost tab currently in the strip. 0 whenever they all fit.</summary>
/// <remarks>
/// Per pane, like the sessions themselves: the two strips are different widths and hold
/// different numbers of tabs, so one shared index would have each pane scrolling the other.
/// </remarks>
private int _tabWindow;
/// <summary>
/// Decide which of this pane's tabs are in the strip at its current width, and show or hide
/// the chevron. Called from RebuildTabStrip, before anything reads which tab is on an edge.
/// </summary>
/// <remarks>
/// The window is a contiguous RUN, not a set: tabs keep their order and their neighbors, so a
/// strip that has moved still reads like the tab bar it was. It shifts the least it can to
/// keep the active tab on screen, which is the one invariant that matters - a tab you just
/// switched to and cannot see is worse than no strip at all.
/// </remarks>
private void ApplyTabWindow()
{
int n = _sessions.Count;
if (n == 0) { TabOverflowBtn.Visibility = Visibility.Collapsed; return; }
// ActualWidth is 0 until the band has been measured once - on the first pass, and on any
// pass that runs while the pane is hidden. Falling back to the pane's own width keeps the
// answer sane instead of capping the strip at one tab and having to be undone by the
// SizeChanged that follows.
double avail = TabStripBorder.ActualWidth > 0 ? TabStripBorder.ActualWidth : ActualWidth;
// Two passes, because the chevron's width changes the answer that decides whether there is
// a chevron. Asked without it first: if everything fits there is none, and the whole band
// belongs to the strip.
int cap = (int)(avail / TabFloorWidth);
bool overflow = cap < n;
if (overflow)
{
cap = Math.Max(1, (int)((avail - TabChevronWidth) / TabFloorWidth));
if (cap >= n) overflow = false;
}
TabOverflowBtn.Visibility = overflow ? Visibility.Visible : Visibility.Collapsed;
int start = 0;
if (overflow)
{
// Clamped before the active tab is considered, so a window left pointing past the end
// by a close does not survive as a scroll nobody asked for.
start = Math.Max(0, Math.Min(_tabWindow, n - cap));
int active = _active == null ? -1 : _sessions.IndexOf(_active);
if (active >= 0 && active < start) start = active;
else if (active >= 0 && active > start + cap - 1) start = active - cap + 1;
_tabWindow = start;
}
else
{
_tabWindow = 0;
cap = n;
}
for (int i = 0; i < n; i++)
_sessions[i].IsStripVisible = i >= start && i < start + cap;
}
/// <summary>The band was resized, so the strip may hold a different number of tabs.</summary>
/// <remarks>
/// Goes through RebuildTabStrip rather than calling ApplyTabWindow alone: a different set of
/// visible tabs is a different first and last tab, and those are the card's corner rounding
/// and the focus ring's outer verticals as much as they are the strip.
/// </remarks>
private void TabBarResized()
{
if (_sessions.Count == 0 || _inTabResize) return;
// Reentrancy guard, not an optimization. RebuildTabStrip writes CardRow.Margin and flips
// the band's own Visibility, either of which can raise SizeChanged again from inside this
// call - and a layout loop in WPF is not a slow app, it is a hung one. The pass that
// follows would compute the same answer anyway.
_inTabResize = true;
try { RebuildTabStrip(); }
finally { _inTabResize = false; }
}
private bool _inTabResize;
private void TabStripBorder_SizeChanged(object sender, SizeChangedEventArgs e) => TabBarResized();
/// <summary>The chevron: every tab in this pane, hidden ones included, in strip order.</summary>
/// <remarks>
/// EVERY tab, not only the overflowed ones. A list that shows just what is off screen makes
/// you work out which those are before you can use it, and the visible ones cost nothing to
/// include. Built on each open rather than kept: titles change on every save and load.
/// </remarks>
private void TabOverflow_Click(object sender, RoutedEventArgs e)
{
var menu = MakeThemedMenu();
foreach (var t in _sessions)
{
var sess = t;
// Doubled, because a lone underscore in a MenuItem header is an access-key marker:
// "Q3_Report" would draw as "Q3Report" with an R underlined, and file names carry
// underscores all the time.
var item = MakeMenuItem(sess.TabLabel.Replace("_", "__"), (_, _) => SwitchToTab(sess), glyph: "");
// Bold rather than a check mark: the menu has no icon column to put one in.
if (sess.IsActive) item.FontWeight = FontWeights.Bold;
menu.Items.Add(item);
}
menu.PlacementTarget = TabOverflowBtn;
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
menu.IsOpen = true;
}
// ════════════════════════════════════════════════════════════════════════════════════════
// CARD CORNERS + FOCUS RING
// ════════════════════════════════════════════════════════════════════════════════════════
/// <summary>
/// Square off the card's top corners under a flush active tab.
/// </summary>
/// <remarks>
/// Each top corner squares only when the tab sitting on it is the ACTIVE one: the first tab
/// owns the top-left, the last owns the top-right. That tab's outer edge is flat and flush
/// with the card's, so a curve underneath cuts a notch out from under a square tab. An
/// inactive tab is window-colored and so a different surface anyway, and the card keeps its
/// rounding under it; no strip at all and the card takes its full radius back.
///
/// Read off the MODEL, never re-measured from the visual tree. Skipped in full screen, where
/// ApplyFullScreen owns the radius and squares all four.
/// </remarks>
private void SyncPaneLeadingCorner()
{
// PaneBorder / PaneShadow, not DocPaneBorder / DocPaneShadow: those names are the
// window's forwards to the FOCUSED pane, and this runs for whichever pane's strip
// changed. Using them here squares pane A's corners when pane B's tabs move.
if (PaneBorder == null || _fullScreen) return;
double r = TryFindResource("RadCard") is CornerRadius rc ? rc.TopLeft : 6;
bool strip = TabStripBorder != null && TabStripBorder.Visibility == Visibility.Visible;
bool firstActive = strip && _active?.IsFirst == true;
bool lastActive = strip && _active?.IsLast == true;
var cr = new CornerRadius(firstActive ? 0 : r, lastActive ? 0 : r, r, r);
PaneBorder.CornerRadius = cr;
if (PaneShadow != null) PaneShadow.CornerRadius = cr;
// Keep the ring's top radii in step with the card's, so its curved sides land exactly on
// the card's own left/right border rather than beside them.
if (TabBarRing != null)
{
TabBarRing.CornerRadius = new CornerRadius(cr.TopLeft, cr.TopRight, 0, 0);
if (Services.ThemeManager.Current == Services.Theme.SE98)
{
// The raised pane's light top rule is the horizontal part of the selected-tab
// route. The selected tab covers its own segment; the remaining rule turns up
// at the tab sides and therefore reads as one continuous classic outline.
TabBarRing.BorderThickness = new Thickness(0, 1, 0, 0);
TabBarRing.SetResourceReference(Border.BorderBrushProperty, "BevelLightBrush");
}
else
{
// Modern themes do not use the Win98 edge overlays, so the ring carries the
// side only where the active tab makes that card corner square.
TabBarRing.BorderThickness = new Thickness(firstActive ? 1 : 0, 1, lastActive ? 1 : 0, 0);
TabBarRing.SetResourceReference(Border.BorderBrushProperty, "PaneEdgeBrush");
}
}
}
/// <summary>
/// Mark this pane's focus state on its tabs and draw the ring's outer verticals.
/// </summary>
/// <remarks>
/// The ring has to continue UP and AROUND the active tab, or it stops dead at the band and the
/// tab and card read as two surfaces. The tab's own share of that is a template trigger on
/// PaneFocused; all this does is set the flag.
///
/// PaneDimmed is the other half - the active tab of the pane that does NOT have focus drops
/// its lip to the card's border color, because two lips at full accent both claim to be the
/// live pane. Deliberately NOT !PaneFocused: with one pane open both are false and that pane's
/// lip stays bright.
///
/// The outermost verticals come from the BAND, not from the tab: a first or last tab's own
/// outer border sits on the ScrollViewer's clip edge and gets cut, so whether it survived
/// depended on how the UniformGrid divided a fractional band width. TabEdgeLeft/Right are
/// anchored to the band's own edges, which are the card's edges, so there is no arithmetic to
/// land wrong.
/// </remarks>
private void UpdatePaneFocusRing()
{
bool split = Host?.IsSplitView == true;
bool retro = Services.ThemeManager.Current == Services.Theme.SE98;
// Modern themes only need a focus ring when panes are split. 98SE uses the
// selected pane's surface color as its focus indicator, including single-pane mode.
bool paneActive = PaneHasFocus && (split || retro);
bool lit = PaneHasFocus && split && !retro;
// TabBarRing is the pane border's top segment inside the tab band. Corner syncing
// assigns its geometry and an idle brush, so focus must restore the live accent here
// every time the tab state is rebuilt. Without this assignment, the active tab and
// the vertical card edges lit up while the horizontal segments beside the tab stayed
// dark, leaving the focused-pane perimeter visibly broken.
if (TabBarRing != null && !retro)
TabBarRing.SetResourceReference(Border.BorderBrushProperty,
lit ? "TabActiveRingBrush" : "PaneEdgeBrush");
foreach (var t in _sessions)
{
// 98SE uses PaneFocused only for the shared darker pane/tab surface. Its focus
// thickness resources are zero, so this never revives the modern accent outline.
t.PaneFocused = paneActive && t.IsActive;
t.PaneDimmed = split && !paneActive && t.IsActive;
}
if (PaneBorder != null)
PaneBorder.SetResourceReference(Border.BackgroundProperty,
retro ? (paneActive ? "FocusedPaneBrush" : "TabInactiveBrush") : "BgCanvas");
if (PaneShadow != null)
PaneShadow.SetResourceReference(Border.BackgroundProperty,
retro ? (paneActive ? "FocusedPaneBrush" : "TabInactiveBrush") : "BgCanvas");
// Same ownership rule the card's corner rounding uses, read off the tab rather than
// recomputed: with the strip windowed the tab on an edge is not the one at the end of the
// list, and two places working that out separately is two places to get it wrong.
bool firstActive = _active?.IsFirst == true;
bool lastActive = _active?.IsLast == true;
bool firstInactiveRetro = retro && _sessions.Any(t => t.IsStripVisible && t.IsFirst && !t.IsActive);
bool lastInactiveRetro = retro && _sessions.Any(t => t.RetroLastInactive);
// The XAML declares these with NO Background - unlike KillerShell's copy, which paints
// them PrimaryBrush directly in markup, MmdPdf's accent key is only known at runtime
// (SelectionAccent, resolved the same way SetFocusHalo resolves the card border). Without
// this they toggle Visible and still draw nothing: a transparent Border is invisible
// whatever its Visibility says.
if (TabEdgeLeft != null)
{
if (retro)
{
// This is the OUTER gray frame, not a duplicate highlight. The active first
// tab is inset one pixel: its own white bevel lands at x+1 and its inset light
// gray bevel at x+2, exactly where the pane draws those same two raised layers.
// Keeping the three responsibilities separate makes the complete side read
// gray / white / light-gray instead of a flat or doubled white line.
// Run through the band's final row so it meets the card edge below. Leaving the
// inactive case one pixel short exposed a literal gap in the left frame.
TabEdgeLeft.Margin = new Thickness(0, firstActive ? 3 : 5, 0, 0);
TabEdgeLeft.Visibility = firstActive || firstInactiveRetro
? Visibility.Visible : Visibility.Collapsed;
TabEdgeLeft.SetResourceReference(Border.BackgroundProperty, "PaneBorderBrush");
}
else
{
TabEdgeLeft.Margin = new Thickness(0, 9, 0, 0);
TabEdgeLeft.Visibility = lit && firstActive ? Visibility.Visible : Visibility.Collapsed;
TabEdgeLeft.SetResourceReference(Border.BackgroundProperty, "SelectionAccent");
}
}
if (TabEdgeRight != null)
{
// The tab now reserves its final pixel, so this is only the outer frame. Because
// the border lives inside TabScroll it shares the tab's vertical origin and no
// longer starts above the tab or cuts through the scrollbar-arrow corner.
if (retro && lastInactiveRetro)
{
TabEdgeRight.Margin = new Thickness(0, 5, 0, 1);
}
else
{
TabEdgeRight.Margin = retro ? new Thickness(0, 3, 0, 0) : new Thickness(0, 9, 0, 0);
}
TabEdgeRight.Visibility = retro
? (lastActive || lastInactiveRetro ? Visibility.Visible : Visibility.Collapsed)
: (lit && lastActive ? Visibility.Visible : Visibility.Collapsed);
TabEdgeRight.SetResourceReference(Border.BackgroundProperty, retro ? "PaneBorderBrush" : "SelectionAccent");
}
}
// ════════════════════════════════════════════════════════════════════════════════════════
// TAB GESTURES
// ════════════════════════════════════════════════════════════════════════════════════════
// Left-click switches on mouse-UP, so a press can begin a drag without switching first.
private void Tab_MouseDown(object sender, MouseButtonEventArgs e)
{
if (sender is not FrameworkElement fe || fe.DataContext is not DocumentSession s) return;
if (e.ChangedButton == MouseButton.Middle) { e.Handled = true; CloseTab(s); }
}
private void Tab_RightClick(object sender, MouseButtonEventArgs e)
{
if (sender is not FrameworkElement fe || fe.DataContext is not DocumentSession s) return;
var menu = MakeThemedMenu();
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_CloseTab"), (_, _) => CloseTab(s), "Ctrl+W", ""));
var others = MakeMenuItem(Loc("Str_Ctx_CloseOthers"), (_, _) => CloseOtherTabs(s), "Ctrl+Shift+W", "");
others.IsEnabled = _sessions.Count(z => z.Doc != null || z.DeferredPath != null) > 1;
menu.Items.Add(others);
menu.PlacementTarget = fe;
menu.IsOpen = true;
e.Handled = true;
}
private void CloseTab_Click(object sender, RoutedEventArgs e)
{
if (sender is Button b && b.Tag is DocumentSession s) CloseTab(s);
}
// ════════════════════════════════════════════════════════════════════════════════════════
// DRAG: reorder within this pane, or hand the tab to the other one
// ════════════════════════════════════════════════════════════════════════════════════════
// Arm on press; past the threshold the grabbed tab glues to the cursor and its neighbors
// glide aside as it crosses their layout-slot midpoints. A plain click still switches on
// release.
//
// Over the OTHER pane the real tab cannot follow the hand - it is still parked in the strip it
// came from - so a ghost takes over and the reorder stands down (Shell/PaneDrag.cs). Coming
// back into this pane hands control straight back.
private DocumentSession? _tabDragSession;
private Point _tabDragStart;
private double _tabGrabDX;
private bool _tabDragging;
/// <summary>Cursor offset inside the grabbed tab, so the window's ghost can sit exactly where
/// the tab did when it was picked up.</summary>
internal double TabGrabOffsetX => _tabGrabDX;
private FrameworkElement? TabContainer(DocumentSession s)
=> TabStrip?.ItemContainerGenerator.ContainerFromItem(s) as FrameworkElement;
/// <summary>Did the press land on a button (the close x) rather than on the tab itself?</summary>
private static bool InsideButton(object src)
{
var d = src as System.Windows.DependencyObject;
while (d != null && d is not Button && d is not Window)
d = VisualTreeHelper.GetParent(d);
return d is Button;
}
/// <summary>Midpoint X of a tab's LAYOUT slot (ignores any in-flight slide transform).</summary>
private static double LayoutMidX(FrameworkElement fe)
{
var slot = System.Windows.Controls.Primitives.LayoutInformation.GetLayoutSlot(fe);
return slot.X + slot.Width / 2;
}
/// <summary>Set a tab's horizontal offset immediately - glues the grabbed tab to the cursor.</summary>
private static void SetTabOffsetX(FrameworkElement tab, double x)
{
if (tab.RenderTransform is not TranslateTransform tt)
{
tt = new TranslateTransform();
tab.RenderTransform = tt;
}
tt.BeginAnimation(TranslateTransform.XProperty, null); // drop any prior animation so the set sticks
tt.X = x;
}
/// <summary>Glide a just-reordered neighbor from where it was into its new slot, so a swap
/// reads as a movement instead of an instant jump.</summary>
private static void AnimateTabSlide(FrameworkElement? tab, double fromX)
{
if (tab == null) return;
if (tab.RenderTransform is not TranslateTransform tt)
{
tt = new TranslateTransform();
tab.RenderTransform = tt;
}
tt.BeginAnimation(TranslateTransform.XProperty, null);
var anim = new System.Windows.Media.Animation.DoubleAnimation(fromX, 0,
new Duration(TimeSpan.FromMilliseconds(140)))
{
EasingFunction = new System.Windows.Media.Animation.CubicEase
{ EasingMode = System.Windows.Media.Animation.EasingMode.EaseOut },
};
tt.BeginAnimation(TranslateTransform.XProperty, anim);
}
internal void CleanupTabTransforms()
{
foreach (var s in _sessions)
if (TabContainer(s) is { } c)
{
c.RenderTransform = null;
Panel.SetZIndex(c, 0);
}
}
private void Tab_DragDown(object sender, MouseButtonEventArgs e)
{
if (sender is not FrameworkElement bd || bd.DataContext is not DocumentSession s) return;
if (InsideButton(e.OriginalSource)) return; // the close x handles its own click
_tabDragSession = s;
_tabDragStart = e.GetPosition(TabStrip);
_tabGrabDX = e.GetPosition(bd).X;
_tabDragging = false;
bd.CaptureMouse();
// Own the press entirely so it cannot bubble to the title bar's window-drag handler, and
// so the mouse capture rather than the caption hit-test drives the drag - which is what
// makes it Y-independent.
e.Handled = true;
}
private void Tab_DragMove(object sender, MouseEventArgs e)
{
if (sender is not FrameworkElement bd || !bd.IsMouseCaptured || _tabDragSession is null) return;
var cont = TabContainer(_tabDragSession);
if (cont == null) return;
double x = e.GetPosition(TabStrip).X;
if (!_tabDragging && Math.Abs(x - _tabDragStart.X) < SystemParameters.MinimumHorizontalDragDistance) return;
_tabDragging = true;
Panel.SetZIndex(cont, 3); // the grabbed tab rides above its neighbors
var over = Host?.TabDropTarget(this, e);
Host?.UpdateTabDragFeedback(this, _tabDragSession, e, over);
if (over != null) return; // the ghost has it; no reorder while the pointer is away
int cur = _sessions.IndexOf(_tabDragSession);
double slide = cont.ActualWidth;
double rawLeft = x - _tabGrabDX;
double leftEdge = rawLeft;
double rightEdge = rawLeft + cont.ActualWidth;
double maxLeft = Math.Max(0, TabStrip.ActualWidth - slide);
double renderLeft = Math.Min(Math.Max(0, rawLeft), maxLeft);
// Swap when the ADVANCING edge crosses a neighbor's layout-slot midpoint. Edge against
// midpoint gives natural hysteresis, so a tab parked on a boundary does not bounce.
bool swapped = false;
if (cur + 1 < _sessions.Count && TabContainer(_sessions[cur + 1]) is { } right && rightEdge > LayoutMidX(right))
{
_sessions.Move(cur + 1, cur);
AnimateTabSlide(TabContainer(_sessions[cur]), slide); // it jumped left; glide it in from the right
swapped = true;
}
else if (cur - 1 >= 0 && TabContainer(_sessions[cur - 1]) is { } left && leftEdge < LayoutMidX(left))
{
_sessions.Move(cur - 1, cur);
AnimateTabSlide(TabContainer(_sessions[cur]), -slide); // it jumped right; glide it in from the left
swapped = true;
}
// After a swap the grabbed tab's slot has moved by a neighbor's width; refresh layout so
// the new slot is current, then offset it back under the cursor.
if (swapped) TabStrip.UpdateLayout();
var dragged = TabContainer(_tabDragSession);
if (dragged == null) return;
var slot = System.Windows.Controls.Primitives.LayoutInformation.GetLayoutSlot(dragged);
SetTabOffsetX(dragged, renderLeft - slot.X);
}
private void Tab_DragUp(object sender, MouseButtonEventArgs e)
{
if (sender is not FrameworkElement bd || !bd.IsMouseCaptured) return;
bd.ReleaseMouseCapture();
bool wasDragging = _tabDragging;
var s = _tabDragSession;
_tabDragSession = null;
_tabDragging = false;
Host?.HideTabDragFeedback(); // the ghost goes whatever the drop turns out to be
if (!wasDragging)
{
if (s != null) SwitchToTab(s);
return;
}
// Dropped over the OTHER pane? Then this was a move, not a reorder. Checked on RELEASE
// rather than mid-drag on purpose: moving a tab between panes re-creates its container,
// which would pull the mouse capture out from under the drag that is still running.
if (s != null && Host?.TabDropTarget(this, e) is { } target)
{
Host.MoveTabToPane(this, target, s, e);
return;
}
RebuildTabStrip(); // a reorder may have moved the active tab on or off an edge
// Settle the grabbed tab from its dragged offset into its final slot.
var cont = s != null ? TabContainer(s) : null;
if (cont?.RenderTransform is TranslateTransform tt && Math.Abs(tt.X) > 0.5)
{
var settle = new System.Windows.Media.Animation.DoubleAnimation(0,
new Duration(TimeSpan.FromMilliseconds(120)))
{
EasingFunction = new System.Windows.Media.Animation.CubicEase
{ EasingMode = System.Windows.Media.Animation.EasingMode.EaseOut },
};
settle.Completed += (_, _) => CleanupTabTransforms();
tt.BeginAnimation(TranslateTransform.XProperty, settle);
}
else CleanupTabTransforms();
}
// ════════════════════════════════════════════════════════════════════════════════════════
// CROSS-PANE MOVE (the window drives this - Shell/PaneDrag.cs)
// ════════════════════════════════════════════════════════════════════════════════════════
// A session is already self-contained - it owns its document, annotations, undo stack, render
// cache and view state - so a move between panes is a move: out of one collection, into the
// other. Nothing is reloaded, which is what lets a large document cross without a re-render.
/// <summary>This pane's band, for the window's drop hit-test and caret math.</summary>
internal Border TabBandCtl => TabStripBorder;
/// <summary>This pane's strip, for the window's caret math.</summary>
internal ItemsControl TabStripCtl => TabStrip;
/// <summary>How many tabs this pane holds. The caret divides the band by this.</summary>
internal int TabCount => _sessions.Count;
/// <summary>Take <paramref name="s"/> out of this pane and pick whatever should be active in
/// its place. Pure bookkeeping - the caller re-renders, because which pane's fields are live
/// at that moment is its decision, not this one's.</summary>
internal void DetachSessionExt(DocumentSession s)
{
int idx = _sessions.IndexOf(s);
if (idx < 0) return;
_sessions.Remove(s);
// Its bitmaps travel with it: leaving the session in this pane's LRU would have this pane
// clearing a cache the other pane is now serving from.
_renderLru.Remove(s);
if (ReferenceEquals(_active, s))
SetActiveSession(_sessions.Count > 0 ? _sessions[Math.Min(idx, _sessions.Count - 1)] : null);
RebuildTabStrip();
}
/// <summary>Put <paramref name="s"/> into this pane at <paramref name="index"/> and make it
/// the front tab - the tab you just dragged is the one you are looking at.</summary>
internal void AdoptSessionExt(DocumentSession s, int index)
{
_sessions.Insert(Math.Min(Math.Max(0, index), _sessions.Count), s);
SetActiveSession(s);
RebuildTabStrip();
}
}
}