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 MmdPdf.Services; using PdfPigDoc = UglyToad.PdfPig.PdfDocument; namespace MmdPdf { 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(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(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: MmdPdf 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); } } }