vendor: import KillerPDF 1.7.5 source (GPL-3.0) as the base for MMD PDF

This commit is contained in:
2026-08-27 06:58:22 +02:00
commit 532485a830
577 changed files with 149058 additions and 0 deletions
+679
View File
@@ -0,0 +1,679 @@
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
{
public partial class MainWindow
{
// ============================================================
// Window proc / Win32 interop (custom chrome, resize, DPI)
// ============================================================
private const int WM_GETMINMAXINFO = 0x0024;
private const int WM_DPICHANGED = 0x02E0;
private const int WM_MOUSEHWHEEL = 0x020E;
private const int WM_ENTERSIZEMOVE = 0x0231;
private const int WM_EXITSIZEMOVE = 0x0232;
private const int WM_ERASEBKGND = 0x0014;
private const uint MONITOR_DEFAULTTONEAREST = 0x00000002;
private const uint SWP_NOZORDER = 0x0004;
private const uint SWP_NOACTIVATE = 0x0010;
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
// Themed system menu (Shell/SystemMenu.cs): swallow the caption right-click and
// Alt+Space before anything else, or Windows draws its stock white HMENU.
if (TryHandleSystemMenu(msg, wParam, lParam)) { handled = true; return IntPtr.Zero; }
if (msg == WM_ERASEBKGND)
{
// WPF paints the whole client area itself, so let nothing erase the background to a flat
// fill underneath it during a resize - that erase is a flash that reads as part of the
// edge "jitter". Claim the message as handled and report success (1) without painting.
handled = true;
return new IntPtr(1);
}
if (msg == WM_GETMINMAXINFO)
{
WmGetMinMaxInfo(hwnd, lParam);
handled = true;
}
else if (msg == 0x0231) _inWindowSizeMove = true; // WM_ENTERSIZEMOVE
else if (msg == 0x0232) _inWindowSizeMove = false; // WM_EXITSIZEMOVE
else if (msg == WM_MOUSEHWHEEL)
{
// #196: WPF has no MouseHWheel event, so a precision touchpad's two-finger
// horizontal scroll (and a mouse's tilt wheel) died at the HwndSource and the
// document never panned sideways. Positive delta scrolls right.
int hDelta = unchecked((short)((wParam.ToInt64() >> 16) & 0xFFFF));
ActiveViewer.ScrollHorizontalExt(hDelta);
handled = true;
}
else if (msg == WM_DPICHANGED)
{
// Apply Windows' suggested rect so the window's apparent size is preserved
// on the new monitor. handled stays false so WPF's HwndSource also processes
// the message - updating its internal DPI scale and firing Window.DpiChanged.
var r = Marshal.PtrToStructure<RECT>(lParam);
SetWindowPos(hwnd, IntPtr.Zero, r.left, r.top,
r.right - r.left, r.bottom - r.top,
SWP_NOZORDER | SWP_NOACTIVATE);
// Re-render at the new DPI. DispatcherPriority.Loaded fires after WPF has
// finished its own DPI update, so VisualTreeHelper.GetDpi already reflects
// the new scale factor when RenderPage calls it.
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded,
(Action)(() =>
{
if (_doc is null) return;
if (_viewMode == ViewMode.Grid)
{
// Grid's primary tile (and the page-width basis the column math uses) is
// ALWAYS page 0 - rendering the selected page here would corrupt that basis
// and could collapse the grid to one column. Re-render page 0, then re-fit the
// columns to the new DPI/size so the grid is preserved across the monitor move.
ActiveViewer.RenderPage(0);
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded,
(Action)(() => ActiveViewer.ReapplyGridOrFit()));
return;
}
int idx = PageList.SelectedIndex;
if (idx >= 0) ActiveViewer.RenderPage(idx);
}));
}
// WM_NCHITTEST is handled natively by WindowChrome.ResizeBorderThickness. The document scrollbar
// is kept grabbable over the right resize band via WindowChrome.IsHitTestVisibleInChrome on the
// ScrollBar style (App.xaml), which is the WindowChrome-native way to exempt an element from the
// resize border - no manual hit-test override (that fought WindowChrome and killed scrollbar clicks).
return IntPtr.Zero;
}
private int WmNcHitTest(IntPtr hwnd, IntPtr lParam)
{
// lParam is screen coords: lo-word = X, hi-word = Y.
// Cast through short to preserve sign (handles negative coords on left/above primary monitor).
long lp = lParam.ToInt64();
int mx = unchecked((short)(lp & 0xFFFF));
int my = unchecked((short)((lp >> 16) & 0xFFFF));
if (!GetWindowRect(hwnd, out RECT rc)) return 0;
// The floating window has a transparent ShadowMargin around the visible content, so the resize
// grips must sit at the CONTENT edge (inset by the margin), not the window edge - otherwise you
// have to reach out into the shadow to resize. Maximized/snapped has no margin.
int sm = _chromeSquared ? 0 : (int)ShadowMargin;
bool onLeft = mx >= rc.left + sm && mx < rc.left + sm + ResizeBorder;
bool onRight = mx < rc.right - sm && mx >= rc.right - sm - ResizeBorder;
bool onTop = my >= rc.top + sm && my < rc.top + sm + ResizeBorder;
bool onBottom = my < rc.bottom - sm && my >= rc.bottom - sm - ResizeBorder;
// Never hijack a scrollbar for window resizing. The vertical scrollbar sits flush
// against the window's right edge, so the resize border used to swallow it - the
// cursor showed the resize arrow and dragging resized the window instead of moving
// the thumb. If a ScrollBar is under the cursor, report client area so it stays grabbable.
if ((onLeft || onRight || onTop || onBottom) && IsOverScrollBar(mx, my))
return HTCLIENT;
if (onTop && onLeft) return HTTOPLEFT;
if (onTop && onRight) return HTTOPRIGHT;
if (onBottom && onLeft) return HTBOTTOMLEFT;
if (onBottom && onRight) return HTBOTTOMRIGHT;
if (onLeft) return HTLEFT;
if (onRight) return HTRIGHT;
if (onTop) return HTTOP;
if (onBottom) return HTBOTTOM;
return 0;
}
// Hit-tests the visual tree at a screen point (physical pixels from WM_NCHITTEST)
// and reports whether a ScrollBar sits under the cursor.
private bool IsOverScrollBar(int screenX, int screenY)
{
try
{
var pt = PointFromScreen(new Point(screenX, screenY));
var res = VisualTreeHelper.HitTest(this, pt);
DependencyObject? hit = res?.VisualHit;
while (hit != null)
{
if (hit is System.Windows.Controls.Primitives.ScrollBar) return true;
hit = VisualTreeHelper.GetParent(hit);
}
}
catch { /* best-effort; fall through to normal resize handling */ }
return false;
}
private void WmGetMinMaxInfo(IntPtr hwnd, IntPtr lParam)
{
var mmi = Marshal.PtrToStructure<MINMAXINFO>(lParam);
IntPtr monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
if (monitor != IntPtr.Zero)
{
var info = new MONITORINFO { cbSize = Marshal.SizeOf(typeof(MONITORINFO)) };
GetMonitorInfo(monitor, ref info);
RECT work = info.rcWork;
RECT mon = info.rcMonitor;
// Normal maximize respects the taskbar (work area). F11 full screen needs the whole monitor:
// ptMaxTrackSize caps how large the window can ever be sized, so without this the explicit
// full-screen bounds get silently clamped back to the work area (taskbar stays visible).
RECT bounds = _fullScreen ? mon : work;
mmi.ptMaxPosition.x = Math.Abs(bounds.left - mon.left);
mmi.ptMaxPosition.y = Math.Abs(bounds.top - mon.top);
mmi.ptMaxSize.x = Math.Abs(bounds.right - bounds.left);
mmi.ptMaxSize.y = Math.Abs(bounds.bottom - bounds.top);
mmi.ptMaxTrackSize.x = mmi.ptMaxSize.x;
mmi.ptMaxTrackSize.y = mmi.ptMaxSize.y;
// Enforce the window's MinWidth/MinHeight during user resize. The custom chrome
// marks WM_GETMINMAXINFO handled, so WPF's own minimum enforcement is bypassed.
try
{
var dpi = VisualTreeHelper.GetDpi(this);
if (MinWidth > 0 && !double.IsInfinity(MinWidth)) mmi.ptMinTrackSize.x = (int)Math.Ceiling(MinWidth * dpi.DpiScaleX);
if (MinHeight > 0 && !double.IsInfinity(MinHeight)) mmi.ptMinTrackSize.y = (int)Math.Ceiling(MinHeight * dpi.DpiScaleY);
}
catch { /* DPI not available yet; skip min enforcement for this pass */ }
Marshal.StructureToPtr(mmi, lParam, true);
}
}
[DllImport("user32.dll")]
private static extern IntPtr MonitorFromWindow(IntPtr handle, uint flags);
[DllImport("user32.dll")]
private static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFO lpmi);
[DllImport("user32.dll")]
private static extern IntPtr SendMessage(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam);
[DllImport("user32.dll")]
private static extern bool SetWindowPos(
IntPtr hWnd, IntPtr hWndInsertAfter,
int X, int Y, int cx, int cy, uint uFlags);
[DllImport("user32.dll")]
private static extern bool GetWindowRect(IntPtr hwnd, out RECT rect);
private const int WM_NCLBUTTONDOWN = 0x00A1;
private const int WM_NCHITTEST = 0x0084;
private const int HTCLIENT = 1;
private const int HTCAPTION = 2;
private const int HTLEFT = 10;
private const int HTRIGHT = 11;
private const int HTTOP = 12;
private const int HTTOPLEFT = 13;
private const int HTTOPRIGHT = 14;
private const int HTBOTTOM = 15;
private const int HTBOTTOMLEFT = 16;
private const int HTBOTTOMRIGHT = 17;
private const int ResizeBorder = 8;
[StructLayout(LayoutKind.Sequential)]
private struct POINT { public int x; public int y; }
[StructLayout(LayoutKind.Sequential)]
private struct RECT { public int left, top, right, bottom; }
[StructLayout(LayoutKind.Sequential)]
private struct MINMAXINFO
{
public POINT ptReserved;
public POINT ptMaxSize;
public POINT ptMaxPosition;
public POINT ptMinTrackSize;
public POINT ptMaxTrackSize;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
private struct MONITORINFO
{
public int cbSize;
public RECT rcMonitor;
public RECT rcWork;
public uint dwFlags;
}
// ============================================================
// Window chrome
// ============================================================
// internal: each pane's tab strip forwards its empty-space drag here.
internal void TitleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (e.ClickCount == 2)
{
MaximizeBtn_Click(sender, e);
return;
}
// Delegate drag to Windows via WM_NCLBUTTONDOWN(HTCAPTION).
// This gives native restore-from-maximized-and-drag behavior:
// if the window is maximized, Windows restores it and follows the cursor
// exactly as a native title bar would.
e.Handled = true;
var hwnd = new WindowInteropHelper(this).Handle;
SendMessage(hwnd, WM_NCLBUTTONDOWN, new IntPtr(HTCAPTION), IntPtr.Zero);
}
// Custom bottom-right grip: forward a native bottom-right resize so it behaves exactly like the OS
// border resize (and stays smooth). Only when floating; maximized/snapped don't resize.
private void ResizeGrip_MouseDown(object sender, MouseButtonEventArgs e)
{
if (WindowState != WindowState.Normal) return;
e.Handled = true;
var hwnd = new WindowInteropHelper(this).Handle;
SendMessage(hwnd, WM_NCLBUTTONDOWN, new IntPtr(HTBOTTOMRIGHT), IntPtr.Zero);
}
private void Install_Click(object sender, RoutedEventArgs e)
{
bool machineInstallExists = App.MachineInstallExists();
bool userInstallExists = App.UserInstallExists();
bool updating = machineInstallExists || userInstallExists;
// Two checkboxes, matching Killendar and KillerShell: the desktop shortcut (on by
// default, as it always was) and the all-users install. If an all-users copy already
// exists, keep that scope selected: KillerPDF deliberately supports one installed
// copy, not competing Program Files and per-user installations.
var (confirmed, wantDesktop, allUsers) = KillerDialog.ShowTwoCheckPrompt(this,
Loc(machineInstallExists ? "Str_Dlg_UpdateMachineMsg" :
userInstallExists ? "Str_Dlg_UpdateUserMsg" : "Str_Dlg_InstallMsg"),
Loc("Str_Chk_Desktop"), check1Initial: true,
Loc("Str_Chk_AllUsers"), check2Initial: machineInstallExists,
Loc(updating ? "Str_Btn_DoUpdate" : "Str_Btn_DoInstall"),
Loc("Str_Btn_Cancel"));
if (!confirmed) return;
if (machineInstallExists && !allUsers)
{
KillerDialog.Show(this, Loc("Str_Dlg_OneInstallOnly"),
Loc("Str_Dlg_InstallTitle"), MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
// Hide the badge immediately so it doesn't flash if relaunch is slow
_portableBadge.Visibility = Visibility.Collapsed;
if (!App.InstallAndRelaunch(_currentFile, wantDesktop, allUsers))
{
// Elevation refused, or the copy failed - both already reported. Put the badge back
// so the session carries on rather than silently looking installed.
_portableBadge.Visibility = App.IsPortable() ? Visibility.Visible : Visibility.Collapsed;
SetStatus(Loc("Str_Status_InstallFailed"));
}
}
private void MinimizeBtn_Click(object sender, RoutedEventArgs e) =>
WindowState = WindowState.Minimized;
private void MaximizeBtn_Click(object sender, RoutedEventArgs e) =>
WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
private void CloseBtn_Click(object sender, RoutedEventArgs e) => Close();
// Rounded window corners look right only when floating; a maximized OR snapped window must
// square off or the rounded corners reveal the desktop / adjacent window behind them.
protected override void OnStateChanged(EventArgs e)
{
base.OnStateChanged(e);
UpdateWindowChrome();
RepositionAnnotationBars();
}
protected override void OnRenderSizeChanged(SizeChangedInfo sizeInfo)
{
base.OnRenderSizeChanged(sizeInfo);
UpdateWindowChrome();
RepositionAnnotationBars();
}
// Re-applies the saved placement to every visible annotation bar. Called synchronously from the
// window events that resize/move the content area (resize, maximize/restore, move), so
// the bar tracks its anchored edge and stays fully on-screen through all of them.
internal void RepositionAnnotationBars()
{
if (PagePreviewPanel?.Parent is not Grid area) return;
foreach (var bar in new[] { _drawSettingsBar, _textSettingsBar })
if (bar is not null && bar.Visibility == Visibility.Visible)
PositionAnnotationBar(bar, area);
}
// Anchors a bar to whichever edge it sits nearer and clamps it fully inside the document area:
// the gap from the anchored edge is honored when there's room, otherwise reduced so the bar
// never crosses the opposite edge. No-op until the bar has a measured width (PlaceAnnotationBar's
// deferred pass positions it once laid out).
private void PositionAnnotationBar(Border bar, Grid area)
{
// 98SE: classic toolbar band - flush, truly full width, no floating gaps and no slide
// parking. The document scroller is inset by the band's height (SyncSe98BarInset), so
// the vertical scrollbar starts BELOW the band instead of poking up beside its right
// end. Restores the theme's own edge thickness and padding: SetBarDockedBorder below
// writes hardcoded 1px borders and 4px padding straight over the BarEdgeThickness /
// BarPadding resource references, which is what flattened the classic 2px light bevel
// into a thin misplaced line.
if (ThemeManager.Current == Theme.SE98)
{
bar.HorizontalAlignment = HorizontalAlignment.Stretch;
bar.Margin = new Thickness(0);
bar.SetResourceReference(Border.BorderThicknessProperty, "BarEdgeThickness");
bar.SetResourceReference(Border.PaddingProperty, "BarPadding");
bar.SetResourceReference(Border.CornerRadiusProperty, "AnnotationBarCornerRadius");
// First pass runs before layout has measured the band; re-run once it has a height
// so the scroller inset below lands on the real value.
if (bar.ActualHeight <= 0)
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded,
(Action)(() => { if (bar.Parent is Grid a) PositionAnnotationBar(bar, a); }));
SyncSe98BarInset(bar, area);
return;
}
SyncSe98BarInset(bar, area); // clears a leftover 98SE inset after a theme switch
double w = bar.ActualWidth;
// The document's vertical scrollbar lives on the right edge of the area. Keep the bar clear
// of it when it's showing; when it isn't, the bar can use the full edge.
double sb = VerticalScrollBarInset();
double maxLeft = Math.Max(0, area.ActualWidth - w);
if (_annotBarCenterFrac is double frac)
{
// Center-parking needs a real measured width to place; edge anchors below don't, so they
// must still run on a freshly-rebuilt (unmeasured) bar - otherwise a same-tool refresh
// (e.g. clicking Bold) reveals the new bar at the default right edge, over the scrollbar.
if (w <= 0) return;
// Parked away from both edges: keep the same fraction of the width so it scales smoothly
// with the window instead of lurching toward an edge. Clamp so it never slides under the
// scrollbar on the right.
double maxLeftCentered = Math.Max(0, maxLeft - sb);
double left = Math.Max(0, Math.Min(maxLeftCentered, frac * area.ActualWidth - w / 2));
bar.HorizontalAlignment = HorizontalAlignment.Left;
bar.Margin = new Thickness(left, bar.Margin.Top, 0, 0);
SetBarDockedBorder(bar, dockedLeft: false, dockedRight: false);
}
else if (_annotBarAnchorRight)
{
// Sit the bar against the scrollbar's left edge when it's present (gap + scrollbar width),
// otherwise honor the plain gap right up to the pane edge.
double g = Math.Min(maxLeft, (_annotBarGap ?? 8) + sb);
bar.HorizontalAlignment = HorizontalAlignment.Right;
bar.Margin = new Thickness(0, bar.Margin.Top, g, 0);
// Only merge with the pane's edge line when nothing (no scrollbar) sits between them.
SetBarDockedBorder(bar, dockedLeft: false, dockedRight: sb <= 0 && g <= 0.5);
}
else
{
double g = Math.Min(maxLeft, _annotBarGap ?? 8);
bar.HorizontalAlignment = HorizontalAlignment.Left;
bar.Margin = new Thickness(g, bar.Margin.Top, 0, 0);
SetBarDockedBorder(bar, dockedLeft: g <= 0.5, dockedRight: false);
}
}
// Width reserved by the document pane's vertical scrollbar (matches the ScrollBar style's fixed
// 12px in MainWindow.xaml). Zero when the scrollbar isn't currently shown, so a docked bar can
// reach the pane edge; otherwise the bar stops at the scrollbar's left edge.
private const double DocScrollBarWidth = 12;
private double VerticalScrollBarInset() =>
PagePreviewPanel?.ComputedVerticalScrollBarVisibility == Visibility.Visible ? DocScrollBarWidth : 0;
// When the bar is docked flush against a side, drop its own 1px border on that side and swap it
// for 1px of padding. The document pane's border (same brush) then serves as the single shared
// edge line - no 2px double border, and no size or position change (so nothing jumps).
private static void SetBarDockedBorder(Border bar, bool dockedLeft, bool dockedRight)
{
bar.BorderThickness = new Thickness(dockedLeft ? 0 : 1, 0, dockedRight ? 0 : 1, 1);
bar.Padding = new Thickness(dockedLeft ? 5 : 4, 4, dockedRight ? 5 : 4, 4);
}
// 98SE reserves the docked band's height as top margin on the pane's document scroller, so
// the page and its vertical scrollbar start below the band (a classic toolbar strip) instead
// of the bar floating over them. Every other theme (and a removed bar) resolves to 0, which
// also clears a leftover inset after a theme switch. The scroller is looked up from the
// bar's own area so the inset always lands on the pane the bar actually lives in.
private static void SyncSe98BarInset(Border bar, Grid area, bool removing = false)
{
ScrollViewer? sv = null;
foreach (object child in area.Children)
if (child is ScrollViewer s) { sv = s; break; }
if (sv is null) return;
double inset = !removing && ThemeManager.Current == Theme.SE98 ? bar.ActualHeight : 0;
if (Math.Abs(sv.Margin.Top - inset) > 0.5)
sv.Margin = new Thickness(0, inset, 0, 0);
}
// Re-anchor a bar when its own size settles or changes (first measure, or the WrapPanel
// dropping to a second row on a narrow pane) - the 98SE scroller inset must track the
// band's real height, and the floating themes re-clamp against the new width.
private void AnnotBarSizeChanged(object sender, SizeChangedEventArgs e)
{
if (sender is Border b && b.Parent is Grid a) PositionAnnotationBar(b, a);
}
// Snapping changes the window's position/size but NOT its WindowState (it stays Normal), so
// re-evaluate the chrome on move too - otherwise a window snapped to a screen half keeps its
// rounded corners. (Hooked once in the constructor.)
private void OnWindowLocationChanged(object? sender, EventArgs e)
{
UpdateWindowChrome();
RepositionAnnotationBars();
}
// Applies the frame border and corner treatment for the current window layout.
// Under WindowChrome the window is a real (opaque, GPU-composited) HWND: the OS draws the
// drop shadow, and on Windows 11 the OS rounds the window corners (via DwmSetWindowAttribute
// below). So the app content fills a SQUARE client rect - the old transparent shadow margin,
// the fake WindowShadowBorder silhouette, and the internal rounded clip are all retired.
private bool? _appliedSquared; // last state pushed to the chrome; guards per-frame churn
private void UpdateWindowChrome()
{
bool max = WindowState == WindowState.Maximized || _fullScreen;
bool squared = max || IsSnapped() || ThemeManager.Current == Theme.SE98;
_chromeSquared = squared;
// The chrome treatment depends ONLY on the maximized/snapped state, not on the live size
// (the size-dependent rounded clip was retired with the WindowChrome migration). So skip the
// whole body - including the DwmSetWindowAttribute call and the property writes - while the
// state is unchanged. This is what was firing a native DWM corner call on every resize frame
// and making the toolbar/sidebar jump as content fell behind the window edge.
if (_appliedSquared == squared) return;
_appliedSquared = squared;
// Content fills the window rectangle. Rounding is done by the OS on the HWND, not here,
// so internal corners stay square to avoid dark nubs peeking past the rounded window edge.
if (RootBorder != null)
{
if (squared)
RootBorder.CornerRadius = new CornerRadius(0);
else
RootBorder.SetResourceReference(Border.CornerRadiusProperty, "WindowCornerRadius");
RootBorder.Margin = new Thickness(0);
// Only a maximized window drops the 1px frame (it's flush to every screen edge); a
// snapped window keeps it so it still reads against the window beside it.
RootBorder.BorderThickness = new Thickness(max || ThemeManager.Current == Theme.SE98 ? 0 : 1);
}
if (TitleBarBorder != null)
{
if (squared) TitleBarBorder.CornerRadius = new CornerRadius(0);
else TitleBarBorder.SetResourceReference(Border.CornerRadiusProperty, "TitleBarCornerRadius");
}
if (FooterBorder != null)
{
if (squared) FooterBorder.CornerRadius = new CornerRadius(0);
else FooterBorder.SetResourceReference(Border.CornerRadiusProperty, "FooterCornerRadius");
}
// The close tile owns only the window's top-right corner. Reusing the title bar's
// full (top-left + top-right) radius rounded the tile's interior left edge too,
// making its hover state look like a detached red cap instead of the window corner.
var titleCorners = TryFindResource("TitleBarCornerRadius") is CornerRadius tc
? tc : new CornerRadius(0, 7, 0, 0);
Resources["ChromeCloseCorner"] = squared
? new CornerRadius(0)
: new CornerRadius(0, titleCorners.TopRight, 0, 0);
// Retired: native OS shadow replaces the hand-cast one.
if (WindowShadowBorder != null)
{
WindowShadowBorder.Visibility = Visibility.Collapsed;
WindowShadowBorder.Effect = null;
}
// Ask Windows 11 to round the HWND when floating, square when maximized/snapped. No-op
// (caught) on Windows 10 and earlier, which simply keep square corners.
ApplyWindowCorners(rounded: !squared);
// This is the permanent resize hit target. Its child canvases choose dots or the
// Win98 hatch; collapsing the parent also hid the hatch.
if (ResizeGripDots != null)
ResizeGripDots.Visibility = Visibility.Visible;
UpdateRootClip(squared);
}
// Windows 11 native rounded-corner toggle (DWMWA_WINDOW_CORNER_PREFERENCE = 33).
private void ApplyWindowCorners(bool rounded)
{
try
{
var hwnd = new WindowInteropHelper(this).Handle;
if (hwnd == IntPtr.Zero) return;
int pref = rounded ? DWMWCP_ROUND : DWMWCP_DONOTROUND;
DwmSetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, ref pref, sizeof(int));
}
catch { /* pre-Win11 DWM: attribute unsupported, square corners */ }
}
private const int DWMWA_WINDOW_CORNER_PREFERENCE = 33;
private const int DWMWCP_DONOTROUND = 1;
private const int DWMWCP_ROUND = 2;
[DllImport("dwmapi.dll", EntryPoint = "DwmSetWindowAttribute")]
private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int value, int size);
private const double ShadowMargin = 10;
private bool _chromeSquared; // true when maximized/snapped
// Under WindowChrome the OS rounds the HWND itself, so content fills a square client rect and
// needs no internal rounded clip. (A rounded clip here would expose dark corner triangles
// against the now-square frame.) Kept as a no-op hook so existing call sites stay valid.
private void UpdateRootClip(bool squared)
{
if (RootClipGrid is null) return;
RootClipGrid.Clip = null;
}
// True when the window is Aero-Snapped (half/quarter screen). Snapping leaves WindowState
// == Normal, so it's detected by comparing the window rect to the monitor work area: a
// snapped window is flush to a work-area edge and smaller than the full work area.
private bool IsSnapped()
{
if (WindowState != WindowState.Normal) return false;
var hwnd = new WindowInteropHelper(this).Handle;
if (hwnd == IntPtr.Zero || !GetWindowRect(hwnd, out RECT w)) return false;
IntPtr mon = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
if (mon == IntPtr.Zero) return false;
var info = new MONITORINFO { cbSize = Marshal.SizeOf(typeof(MONITORINFO)) };
if (!GetMonitorInfo(mon, ref info)) return false;
RECT a = info.rcWork;
const int tol = 2; // device-pixel tolerance for "flush to edge"
bool flushLeft = Math.Abs(w.left - a.left) <= tol;
bool flushRight = Math.Abs(w.right - a.right) <= tol;
bool flushTop = Math.Abs(w.top - a.top) <= tol;
bool flushBottom = Math.Abs(w.bottom - a.bottom) <= tol;
bool fillsWidth = Math.Abs((w.right - w.left) - (a.right - a.left)) <= tol;
bool fillsHeight = Math.Abs((w.bottom - w.top) - (a.bottom - a.top)) <= tol;
// Exactly the work area (sized full but not maximized) is not a snap.
if (fillsWidth && fillsHeight) return false;
// Left/right half: full height, flush to one vertical edge, narrower than the work area.
if (flushTop && flushBottom && (flushLeft || flushRight) && !fillsWidth) return true;
// Quarter snap: flush into a corner and smaller than the work area in at least one axis.
if ((flushLeft || flushRight) && (flushTop || flushBottom) && (!fillsWidth || !fillsHeight))
return true;
return false;
}
private bool _fadingOut;
protected override void OnClosing(System.ComponentModel.CancelEventArgs e)
{
// Second pass (our own Close after the fade): let it through.
if (_fadingOut) { base.OnClosing(e); return; }
// Fold the live (active-tab) dirty flag back into its session, then prompt once if
// any open tab has unsaved changes.
// Capture BOTH panes' live state, then ask across BOTH. `_sessions` is only the focused
// pane, so testing it alone quits without a word while the other pane holds unsaved
// edits - a data-loss bug.
Viewer.CaptureActiveIfAny();
ViewerB.CaptureActiveIfAny();
bool anyDirty = _isDirty || AllSessions().Any(s => s.IsDirty);
if (anyDirty)
{
// fadeClose:false so the prompt closes instantly instead of adding its own 150ms fade
// before the app's fade-out starts - otherwise the two run back-to-back (300ms of waiting).
// Default to No so a stray Enter can't silently discard unsaved work.
var res = KillerDialog.Show(this,
Loc("Str_Dlg_UnsavedExit"),
Loc("Str_Dlg_AppTitle"), MessageBoxButton.YesNo, MessageBoxImage.Warning, fadeClose: false,
defaultResult: MessageBoxResult.No);
if (res != MessageBoxResult.Yes)
{
e.Cancel = true;
return;
}
}
// #105, KillerFind-style (family standard): ONE quit prompt with two opt-out
// checkboxes replaces the old Yes=forget / No=reopen question. Unchecked
// "Close my open tabs" = the session reopens next launch; "Remember my choice"
// locks the answer so we stop asking. Cancel keeps the app open.
// Only asked when a document is actually open (loaded, or a lazy not-yet-loaded
// restored tab) - with nothing open there are no tabs to close or reopen, so the
// empty window just quits.
bool anyOpenDoc = AllSessions().Any(s =>
s.Doc != null || !string.IsNullOrEmpty(s.CurrentFile) || !string.IsNullOrEmpty(s.DeferredPath));
// A confirmed "close without saving" already IS the quit confirmation - never stack
// the quit prompt on top of it (one dialog max per close). The open-tabs / remember
// preference just keeps its saved value for that close.
if (!anyDirty && anyOpenDoc && App.GetSetting("RememberChoiceLocked") != "1")
{
var (confirmed, closeTabs, remember) = KillerDialog.ShowQuitPrompt(this,
Loc("Str_Dlg_QuitMsg"),
Loc("Str_Chk_CloseTabs"), App.GetSetting("RememberOpenFiles") == "0",
Loc("Str_Dlg_RememberChoice"),
Loc("Str_Btn_Quit"), Loc("Str_Btn_CancelDlg"));
if (!confirmed)
{
e.Cancel = true;
return;
}
App.SetSetting("RememberOpenFiles", closeTabs ? "0" : "1");
if (remember) App.SetSetting("RememberChoiceLocked", "1");
}
SaveWindowSettings();
// Fade the whole app out before it really closes (matches the dialog fade-out).
e.Cancel = true;
_fadingOut = true;
var anim = new System.Windows.Media.Animation.DoubleAnimation(
Opacity, 0, new Duration(TimeSpan.FromMilliseconds(WindowFx.FadeMs)))
{
EasingFunction = new System.Windows.Media.Animation.QuadraticEase
{ EasingMode = System.Windows.Media.Animation.EasingMode.EaseOut }
};
anim.Completed += (_, _) => Close();
BeginAnimation(OpacityProperty, anim);
}
}
}