using System.Diagnostics; using System.IO; using System.Runtime.InteropServices; using System.Text.Json; using System.Windows; using System.Windows.Controls; using System.Windows.Controls.Primitives; 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 { // ============================================================ // Draw/Highlight settings bar // ============================================================ // The quick-colors shown in the annotate bars. User-configurable via the color picker's swatch // row (shared "UserSwatches" setting); seeded with these 8 defaults, restorable via the picker's // Reset. SwatchColors reads the live set each time a bar is built, so edits show up immediately. private static readonly Color[] DefaultSwatchColors = UiKit.DefaultSwatches; private static Color[] SwatchColors => LoadUserSwatches(); private static Color[] LoadUserSwatches() { var raw = App.GetSetting("UserSwatches"); if (string.IsNullOrWhiteSpace(raw)) return [.. DefaultSwatchColors]; List list = []; foreach (var part in raw!.Split(',')) { var t = part.Trim().TrimStart('#'); if (t.Length == 6 && int.TryParse(t, System.Globalization.NumberStyles.HexNumber, System.Globalization.CultureInfo.InvariantCulture, out int v)) list.Add(Color.FromRgb((byte)((v >> 16) & 0xFF), (byte)((v >> 8) & 0xFF), (byte)(v & 0xFF))); } return list.Count > 0 ? [.. list] : [.. DefaultSwatchColors]; } // Frozen cached brushes for hot-path UI construction private static readonly SolidColorBrush _swatchDimBorder = Freeze(new SolidColorBrush(Color.FromRgb(0x44, 0x44, 0x44))); private static readonly SolidColorBrush _drawBarBackground = Freeze(new SolidColorBrush(Color.FromRgb(0x1a, 0x1a, 0x1a))); private static readonly SolidColorBrush _thumbBorderBrush = Freeze(new SolidColorBrush(Color.FromRgb(0x33, 0x33, 0x33))); private static T Freeze(T freezable) where T : System.Windows.Freezable { freezable.Freeze(); return freezable; } // Wraps a floating annotation bar's content with the app's film-grain layer so these bars // carry the same texture as the Settings / signature / dialog surfaces. The grain extends // under the host border's 4px padding (negative margin) and matches its bottom corners. private Grid GrainWrap(UIElement content) { var g = new Grid(); // SetResourceReference, not a FindResource snapshot: these hosts are built once and // outlive theme switches, so a snapshot kept the grain painting on 98SE (whose // GrainOpacity is 0) and its -4 margin overhang made the bevel read as misaligned. var grain = new Border { Margin = new Thickness(-4), IsHitTestVisible = false }; grain.SetResourceReference(Border.CornerRadiusProperty, "AnnotationBarCornerRadius"); grain.SetResourceReference(UIElement.OpacityProperty, "GrainOpacity"); grain.SetResourceReference(Border.BackgroundProperty, "GrainBrushShared"); g.Children.Add(grain); g.Children.Add(content); return g; } // A grab handle (vertical dots) placed at the left of an annotation bar so it can be slid // left/right along the top of the document. Returns the handle for EnableBarSlide. private Border MakeBarGrip(int dotCount = 3) { // Real ellipse dots (not a braille glyph, which didn't render on some fonts/themes - the // grip looked empty on the Light bars). Matches the sidebar splitter / minimized-bar dots. // dotCount scales with bar height: 3 for single-row bars, 4 for the double-height text bar. var dots = new StackPanel { Orientation = Orientation.Vertical, VerticalAlignment = VerticalAlignment.Center }; var fill = (Brush)FindResource("MutedTextBrush"); for (int i = 0; i < dotCount; i++) dots.Children.Add(new System.Windows.Shapes.Ellipse { Width = 3, Height = 3, Margin = new Thickness(0, 1.5, 0, 1.5), Fill = fill }); return new Border { Background = Brushes.Transparent, Cursor = Cursors.Hand, Padding = new Thickness(1, 0, 10, 0), // hard to the left edge, more gap before the labels VerticalAlignment = VerticalAlignment.Stretch, Child = dots }; } // Wraps an annotate bar's content with a film-grain layer (always visible, even minimized) and a // hidden grip-dots strip (the same dots the sidebar splitter uses) revealed when minimized. private FrameworkElement BuildBarHost(FrameworkElement content) { var host = new Grid(); // KillerNotes ribbon contract: 98SE adds the dark right edge to the host's light // top/left edge. Other themes define this as transparent/zero, so their floating // annotation card remains unchanged. var darkEdge = new Border { IsHitTestVisible = false }; darkEdge.SetResourceReference(Border.BorderBrushProperty, "BarEdgeDarkBrush"); darkEdge.SetResourceReference(Border.BorderThicknessProperty, "BarEdgeDarkThickness"); Panel.SetZIndex(darkEdge, 20); host.Children.Add(darkEdge); // Grain stays put when the controls collapse, so the minimized strip keeps the texture. // SetResourceReference so a theme switch retargets it - 98SE's GrainOpacity of 0 must // actually clear the texture on an already-built bar (a FindResource snapshot did not). var hostGrain = new Border { Margin = new Thickness(-4), IsHitTestVisible = false }; hostGrain.SetResourceReference(Border.CornerRadiusProperty, "AnnotationBarCornerRadius"); hostGrain.SetResourceReference(UIElement.OpacityProperty, "GrainOpacity"); hostGrain.SetResourceReference(Border.BackgroundProperty, "GrainBrushShared"); host.Children.Add(hostGrain); host.Children.Add(content); // the collapsible controls var dots = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, IsHitTestVisible = false }; var fill = TryFindResource("MutedTextBrush") as Brush ?? Brushes.Gray; // match the sidebar handle dots for (int i = 0; i < 6; i++) dots.Children.Add(new System.Windows.Shapes.Ellipse { Width = 3, Height = 3, Margin = new Thickness(2, 0, 2, 0), Fill = fill }); // The dots live in a transparent, hit-testable strip that fills the bar. While the bar is // minimized this strip is shown, so the whole peek strip can be dragged left/right (the slide // is wired to it in PlaceAnnotationBar) - same as dragging the grip on the expanded bar. var dotsStrip = new Border { Background = Brushes.Transparent, Cursor = Cursors.Hand, Visibility = Visibility.Collapsed, Child = dots }; host.Children.Add(dotsStrip); _annotBarContent = content; _annotBarDots = dotsStrip; return host; } // Drop shadow for the annotate bars: offset straight down with depth >= blur, so it falls on the // sides and bottom but never above the bar (no halo between it and the toolbar). Removed entirely // while minimized. private static System.Windows.Media.Effects.DropShadowEffect AnnotBarShadow() => new() { Color = Colors.Black, BlurRadius = 6, ShadowDepth = 3, Direction = 270, Opacity = Application.Current.TryFindResource("BarShadowOpacity") is double opacity ? opacity : 0.38 }; // Lets the annotation bars slide horizontally along the top via their grip, clamped inside // the document area, with the X position remembered (shared across the draw/text bars). private void EnableBarSlide(FrameworkElement grip, Border bar, FrameworkElement bounds, bool backgroundOnly = false) { grip.MouseLeftButtonDown += (s, e) => { // When wired on a content panel, only act on clicks that hit the panel's OWN background // (empty gaps). A click on a child control (slider, swatch, combo) reports that child as // the source, so we bail and let the control handle it - even controls that don't mark the // event handled never start a drag. The grip / peek strip pass backgroundOnly=false. if (backgroundOnly && !ReferenceEquals(e.OriginalSource, grip)) return; // Double-click any draggable surface (grip, peek strip, or an empty area of the bar) // toggles minimize - same gesture everywhere. if (e.ClickCount == 2) { e.Handled = true; ToggleAnnotBarMinimized(); return; } double w = bar.ActualWidth; // Drag uniformly in left-edge coordinates whatever the current anchor; the edge it // anchors to is decided on release from where it ends up. double curLeft = bar.HorizontalAlignment == HorizontalAlignment.Right ? bounds.ActualWidth - bar.Margin.Right - w : bar.Margin.Left; bar.HorizontalAlignment = HorizontalAlignment.Left; bar.Margin = new Thickness(curLeft, bar.Margin.Top, 0, 0); bar.Tag = (e.GetPosition(bounds).X, curLeft); // (startX, origLeft) grip.CaptureMouse(); e.Handled = true; }; grip.MouseMove += (s, e) => { if (bar.Tag is not (double startX, double origLeft) || !grip.IsMouseCaptured) return; double w = bar.ActualWidth; // Stop the drag at the scrollbar's left edge (not the pane edge) so the bar never // overshoots the scrollbar and then snaps back on release - the "bounce" the user saw. double sb = VerticalScrollBarInset(); double maxLeft = Math.Max(0, bounds.ActualWidth - w - sb); double nl = Math.Max(0, Math.Min(maxLeft, origLeft + (e.GetPosition(bounds).X - startX))); bar.Margin = new Thickness(nl, bar.Margin.Top, 0, 0); // Merge the docked-side border with the pane border live while dragging (no footprint // change), so it doesn't pop in on release. The right side only docks flush when no // scrollbar sits between the bar and the pane edge. SetBarDockedBorder(bar, dockedLeft: nl <= 0.5, dockedRight: sb <= 0 && nl >= maxLeft - 0.5); }; grip.MouseLeftButtonUp += (s, e) => { if (!grip.IsMouseCaptured) return; grip.ReleaseMouseCapture(); double w = bar.ActualWidth; double left = bar.Margin.Left; // Measure the right gap from the scrollbar's left edge (the usable content edge), so a // bar parked against the scrollbar records gap ~0 and PositionAnnotationBar re-adds the // scrollbar width once - no double inset, no jump. double sb = VerticalScrollBarInset(); double rightGap = Math.Max(0, (bounds.ActualWidth - sb) - (left + w)); const double snap = 24; // within this many px of an edge, cling to that edge exactly if (left <= snap) { _annotBarCenterFrac = null; _annotBarAnchorRight = false; _annotBarGap = Math.Max(0, left); } else if (rightGap <= snap) { _annotBarCenterFrac = null; _annotBarAnchorRight = true; _annotBarGap = rightGap; } else { // Away from both edges: remember it as a fraction of the width so resizing scales it // smoothly rather than snapping it to an edge. _annotBarCenterFrac = (left + w / 2) / bounds.ActualWidth; } App.SetSetting("AnnotBarFrac", _annotBarCenterFrac?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? ""); App.SetSetting("AnnotBarGap", ((int)(_annotBarGap ?? 8)).ToString()); App.SetSetting("AnnotBarRightSide", _annotBarAnchorRight ? "1" : "0"); if (PagePreviewPanel?.Parent is Grid area) PositionAnnotationBar(bar, area); e.Handled = true; }; } // Saved horizontal placement for the floating draw/text settings bars (shared by both). The bar // anchors to whichever edge it sits nearer and remembers its gap from that edge, so it clings to // that edge on resize. RepositionAnnotationBars re-applies it - clamped fully inside the document // area - from the same window events that keep the Settings panel in-window, so it can never end // up off-screen regardless of which edge it was parked against. private double? _annotBarGap; private bool _annotBarAnchorRight = true; private double? _annotBarCenterFrac; // set when parked away from both edges: hold this fraction of the width private bool _vScrollVisible; // last-known document vertical scrollbar state, to reposition bars on change private EditTool? _annotBarTool { get => ActiveViewer.AnnotBarToolRef; set => ActiveViewer.AnnotBarToolRef = value; } private bool _annotBarMinimized { get => ActiveViewer.AnnotBarMinimizedRef; set => ActiveViewer.AnnotBarMinimizedRef = value; } private double _annotBarFullHeight; // remembered full height to expand back to private FrameworkElement? _annotBarContent; // the bar's normal content (hidden while minimized) private FrameworkElement? _annotBarDots; // grip-dots strip shown while minimized private List _annotBarDragInners => ActiveViewer.AnnotBarDragInnersRef; // Positions an annotation bar and wires up sliding. If we already know the X (this session or // saved), set it synchronously so the bar appears in place; only the very first time do we // defer to compute the default top-right from the laid-out width. private void PlaceAnnotationBar(Border bar, Border grip, bool fadeIn = false) { if (PagePreviewPanel.Parent is not Grid area) return; if (_annotBarGap is null && _annotBarCenterFrac is null) { if (double.TryParse(App.GetSetting("AnnotBarFrac"), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out double f)) { _annotBarCenterFrac = f; // parked away from both edges last time } else { _annotBarGap = int.TryParse(App.GetSetting("AnnotBarGap"), out int sg) ? sg : 8; _annotBarAnchorRight = App.GetSetting("AnnotBarRightSide") != "0"; // default: right edge } } // Track the bar's own size (first measure, wrap to a second row) so the 98SE scroller // inset and the floating themes' edge clamp stay in step with the real height/width. bar.SizeChanged -= AnnotBarSizeChanged; bar.SizeChanged += AnnotBarSizeChanged; EnableBarSlide(grip, bar, area); // The minimized peek strip drags the bar too, so a collapsed bar can be repositioned. if (_annotBarDots is not null) EnableBarSlide(_annotBarDots, bar, area); // Empty areas of the bar content drag it too (and double-click them to minimize). The content // panel(s) have a Transparent background, so only the gaps between controls trigger this - // clicks that land on a slider/swatch/combo still go to that control. if (_annotBarContent is not null) EnableBarSlide(_annotBarContent, bar, area, backgroundOnly: true); foreach (var inner in _annotBarDragInners) if (inner is not null) EnableBarSlide(inner, bar, area, backgroundOnly: true); // A freshly built bar has no measured width yet, so PositionAnnotationBar can't place it until // layout runs. Hide it for that one frame (Opacity 0 still lays out, so width measures), then // anchor and reveal it - otherwise it renders at its default right edge first and visibly // jumps to its saved spot on every tool switch. bar.Opacity = 0; PositionAnnotationBar(bar, area); // sync position (edge-anchored modes are correct without a width) if (fadeIn) { // Start the fade-in immediately so it overlaps the outgoing bar's fade-out (a true // crossfade). Waiting for the deferred layout pass left a frame where neither bar was // visible, which read as a blink. Final clamp/center still happens once laid out. bar.BeginAnimation(UIElement.OpacityProperty, new DoubleAnimation(0, 1, new Duration(TimeSpan.FromMilliseconds(110))) { EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } }); Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded, (Action)(() => PositionAnnotationBar(bar, area))); } else if (_annotBarCenterFrac is not null) { // Center-parked needs a measured width to place, so stay hidden one layout frame so it // can't render at the default edge first and then jump. Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded, (Action)(() => { PositionAnnotationBar(bar, area); bar.Opacity = 1; })); } else { // Edge-anchored: already placed correctly above (no width needed), so reveal it right away // instead of hiding for a frame - that one hidden frame was the "blink" on a same-tool // refresh / same-family tool switch. bar.Opacity = 1; } } // Subtracts the annotate-bar chrome (padding + border + a gap from the scrollbar) from the document // area's width, giving the wrap panel the width it's actually allowed to occupy before wrapping. private static readonly InsetWidthConverter _barWidthInset = new(); private sealed class InsetWidthConverter : System.Windows.Data.IValueConverter { public object Convert(object value, Type t, object p, System.Globalization.CultureInfo c) => value is double d ? Math.Max(0.0, d - 28.0) : value; public object ConvertBack(object value, Type t, object p, System.Globalization.CultureInfo c) => value; } // Adapts a wrapping annotate bar to very narrow widths: once its content blocks can no longer sit on // a single row, the drag grip is hidden (non-essential at that size) and the blocks gain a little // vertical breathing room as they stack; the inline separators are hidden so they don't float as // stray ticks between stacked rows. The single-row fit test deliberately uses only fixed quantities // (block widths + a constant gap allowance, never the grip or separator visibility we toggle), so a // toggle can never change the test result and oscillate. private void WireBarWrapAdaptation(WrapPanel host, FrameworkElement grip, FrameworkElement primary, FrameworkElement sizeSource) { // The grip only shrinks to a thin draggable nub once the grip + the first block can no longer // share a row (the window's skinniest) - not merely when the bar wraps. The threshold is the // grip + first-block widths, measured ONCE at full size and frozen, so shrinking the grip can // never feed back into the decision and oscillate. Spacing between groups is handled by the // groups' own margins (not separators), so it survives wrapping with no stray ticks. double gripThreshold = 0; Thickness gripFull = (grip as Border)?.Padding ?? new Thickness(); bool? lastGripMin = null; void SetGripMinimized(bool min) { if (grip is not Border gb) return; if (gb.Child is UIElement dots) dots.Visibility = min ? Visibility.Collapsed : Visibility.Visible; gb.Padding = min ? new Thickness(1, 0, 2, 0) : gripFull; // keep a few draggable px } void Apply() { double avail = host.MaxWidth; if (double.IsNaN(avail) || double.IsInfinity(avail) || avail <= 0) return; if (gripThreshold <= 0) { double g = grip.DesiredSize.Width, p = primary.DesiredSize.Width; if (g <= 0 || p <= 0) return; // not measured yet; a later pass will retry gripThreshold = g + p + 8.0; } bool gripMin = avail + 0.5 < gripThreshold; if (lastGripMin == gripMin) return; lastGripMin = gripMin; SetGripMinimized(gripMin); } // Drive off the document area's width (the monotonic signal) rather than the panel's own size: // once the grip shrinks and the bar fits a row again, the panel stops resizing, so only the // source's width change tells us there is room to restore the grip. Unhook on teardown - the bar // is rebuilt often (every swatch click), so a lingering handler would leak. void onSource(object? sender, SizeChangedEventArgs e) => Apply(); sizeSource.SizeChanged += onSource; host.Unloaded += (_, _) => sizeSource.SizeChanged -= onSource; host.SizeChanged += (_, _) => Apply(); // catches the first valid measurement Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded, (Action)Apply); } // Shared overflow chevron + popup for the annotate bars: an E712 "More" button whose popup // (anchored to the button - flyouts anchor to their own button, always) stacks whatever // groups WireBarOverflow sheds. private Border MakeBarOverflow(out Popup popup, out StackPanel stack) { var s = new StackPanel { Margin = new Thickness(10, 6, 10, 6) }; // Family flyout rule: the film grain is the LAST child, OVER the items, non-hit-testable. var inner = new Grid(); inner.Children.Add(s); // SetResourceReference so a theme switch retargets it (98SE clears grain via opacity 0). var popGrain = new Border { CornerRadius = new CornerRadius(4), IsHitTestVisible = false }; popGrain.SetResourceReference(UIElement.OpacityProperty, "GrainOpacity"); popGrain.SetResourceReference(Border.BackgroundProperty, "GrainBrushShared"); inner.Children.Add(popGrain); var border = new Border { BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(4), Child = inner }; border.SetResourceReference(Border.BackgroundProperty, "MenuBackgroundBrush"); border.SetResourceReference(Border.BorderBrushProperty, "MenuBorderBrush"); var glyph = new TextBlock { Text = ((char)0xE712).ToString(), // MDL2 More - the same chevron as the toolbar overflow FontFamily = UiKit.IconFont, FontSize = 13, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; glyph.SetResourceReference(TextBlock.ForegroundProperty, "MutedTextBrush"); var btn = new Border { Width = 22, Height = 20, CornerRadius = new CornerRadius(3), Margin = new Thickness(0, 3, 0, 3), VerticalAlignment = VerticalAlignment.Center, Cursor = Cursors.Hand, Background = Brushes.Transparent, ToolTip = Loc("Str_Bar_More"), Child = glyph, Visibility = Visibility.Collapsed }; var p = new Popup { PlacementTarget = btn, Placement = PlacementMode.Bottom, VerticalOffset = 4, StaysOpen = false, AllowsTransparency = true, Child = border }; // Open on mouse UP, not down: a StaysOpen=false popup opened mid-mouse-down inherits // the press's capture and closes again the moment the button is released, so it only // stayed open while the click was held. Down is still handled so the press can't start // a bar drag. The 200ms guard covers the toggle-close case (the closing click's up // would otherwise instantly reopen it). var closedAt = DateTime.MinValue; p.Closed += (_, _) => closedAt = DateTime.UtcNow; btn.MouseLeftButtonDown += (_, e) => e.Handled = true; btn.MouseLeftButtonUp += (_, e) => { e.Handled = true; if ((DateTime.UtcNow - closedAt).TotalMilliseconds < 200) return; p.IsOpen = true; }; popup = p; stack = s; return btn; } // Caps an annotate bar at two wrapped rows. Group widths are measured ONCE and frozen (the // WireBarWrapAdaptation anti-oscillation rule): the fit test simulates the WrapPanel's own // first-fit line breaking against the available width and, while the visible groups would // need a third row, sheds the next group in shedOrder into the overflow popup - restoring // them in reverse when there is room again. Groups not named in shedOrder never collapse. private void WireBarOverflow(WrapPanel host, Border overflowBtn, Popup popup, StackPanel popupStack, StackPanel[] groups, int[] shedOrder, FrameworkElement sizeSource) { double[] widths = new double[groups.Length]; double btnWidth = 26; // chevron + margin; always reserved in the test (constant input) int shedCount = 0; int LinesNeeded(int shed) { var inPopup = new bool[groups.Length]; for (int i = 0; i < shed; i++) inPopup[shedOrder[i]] = true; double avail = host.MaxWidth; double line = btnWidth; int lines = 1; for (int i = 0; i < groups.Length; i++) { if (inPopup[i]) continue; double w = widths[i]; if (line + w > avail && line > btnWidth) { lines++; line = btnWidth; } line += w; } return lines; } void MoveToPopup(int gi) { host.Children.Remove(groups[gi]); int at = 0; // keep display order inside the popup too foreach (UIElement child in popupStack.Children) if (child is StackPanel sp && Array.IndexOf(groups, sp) is int ci && ci >= 0 && ci < gi) at++; popupStack.Children.Insert(at, groups[gi]); } void MoveToBar(int gi) { popupStack.Children.Remove(groups[gi]); int at = 1; // index 0 is the grip for (int i = 0; i < gi; i++) if (host.Children.Contains(groups[i])) at++; host.Children.Insert(at, groups[gi]); } void Apply() { double avail = host.MaxWidth; if (double.IsNaN(avail) || double.IsInfinity(avail) || avail <= 0) return; if (widths[0] <= 0) { for (int i = 0; i < groups.Length; i++) { double w = groups[i].DesiredSize.Width; if (w <= 0) return; // not measured yet; a later pass retries widths[i] = w; } } int want = shedCount; while (want < shedOrder.Length && LinesNeeded(want) > 2) want++; while (want > 0 && LinesNeeded(want - 1) <= 2) want--; if (want == shedCount) return; if (want > shedCount) for (int i = shedCount; i < want; i++) MoveToPopup(shedOrder[i]); else for (int i = shedCount - 1; i >= want; i--) MoveToBar(shedOrder[i]); shedCount = want; overflowBtn.Visibility = shedCount > 0 ? Visibility.Visible : Visibility.Collapsed; if (shedCount == 0 && popup.IsOpen) popup.IsOpen = false; } void onSource(object? sender, SizeChangedEventArgs e) => Apply(); sizeSource.SizeChanged += onSource; host.Unloaded += (_, _) => sizeSource.SizeChanged -= onSource; host.SizeChanged += (_, _) => Apply(); Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded, (Action)Apply); } private void ShowDrawSettings(EditTool tool) { // Fade the bar in only when it's genuinely appearing (no bar yet, or coming from the text // bar). Switching between tools that share this same draw bar (Highlight / Underline / // Strikethrough / Draw) swaps it in place instantly - otherwise the two near-identical bars // crossfade through ~50% opacity and read as a blink even though nothing visually changed. bool prevWasDrawBar = _annotBarTool is EditTool.Draw or EditTool.Highlight or EditTool.Underline or EditTool.Strikethrough or EditTool.Line or EditTool.Shape; bool appearing = _annotBarTool != tool && !prevWasDrawBar; if (_drawSettingsBar is not null) { // On a switch, fade the old bar out (crossfades with the new one); on a refresh, swap it // out instantly so clicking a swatch doesn't flicker the whole bar. if (appearing) FadeOutAndRemoveBar(_drawSettingsBar); else (PagePreviewPanel.Parent as Grid)?.Children.Remove(_drawSettingsBar); _drawSettingsBar = null; } // WrapPanel so that on a too-narrow window the control groups drop to a second/third row // instead of overflowing. Its MaxWidth is pinned to the document area below so it knows when // to wrap; controls are added in self-contained groups so a wrap never splits a label from // its slider. var panel = new WrapPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(8, 2, 8, 2), Background = Brushes.Transparent }; // Drag grip so the bar can be slid left/right along the top. var drawGrip = MakeBarGrip(); panel.Children.Add(drawGrip); // A small checkbox + label for the bar (Level on the Line tool, Eraser on Highlight / Draw). // Toggling rebuilds the bar to reflect the new state. StackPanel BarCheck(string label, bool active, string tip, Action onClick) { var p = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(2, 0, 18, 0), Cursor = Cursors.Hand, ToolTip = tip }; var box = new Border { Width = 15, Height = 15, CornerRadius = new CornerRadius(3), BorderThickness = new Thickness(active ? 0 : 1), Background = Brushes.Transparent, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 5, 0) }; if (active) { // Live theme reference (not a snapshot) so the checked fill tracks the current theme's // accent - the old AccentBrush() snapshot kept whatever accent was active when the bar // was first built, so it showed the wrong (often green) color after a theme switch. box.SetResourceReference(Border.BackgroundProperty, "SelectionAccent"); box.Child = new TextBlock { Text = "✓", Foreground = Brushes.White, FontSize = 10, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; } else box.BorderBrush = _swatchDimBorder; var lbl = new TextBlock { Text = label, FontFamily = UiKit.UiFont, FontSize = 11, VerticalAlignment = VerticalAlignment.Center }; lbl.SetResourceReference(TextBlock.ForegroundProperty, "MutedTextBrush"); p.Children.Add(box); p.Children.Add(lbl); p.MouseLeftButtonDown += (_, _) => onClick(); return p; } // Built here, added at the END of the bar (after Opacity) where there's more room than // squeezed in before the color swatches. StackPanel? barCheck = tool switch { EditTool.Line => BarCheck(Loc("Str_Bar_Level"), _lineLevel, Loc("Str_Bar_TT_Level"), () => { _lineLevel = !_lineLevel; ShowDrawSettings(tool); }), EditTool.Highlight => BarCheck(Loc("Str_Bar_Eraser"), _highlightErase, Loc("Str_Bar_TT_EraserBox"), () => { _highlightErase = !_highlightErase; ShowDrawSettings(tool); }), EditTool.Draw => BarCheck(Loc("Str_Bar_Eraser"), _drawErase, Loc("Str_Bar_TT_EraserBrush"), () => { _drawErase = !_drawErase; ShowDrawSettings(tool); }), EditTool.Shape => ShapeKindPicker(), _ => null }; // Shapes tool (#127 Phase 3): radio-style buttons whose glyphs are literal mini shapes // (drawn WPF elements, not font glyphs, so they render on every locale/font setup), // plus the Fill toggle. Clicking a shape switches the sub-mode and rebuilds the bar. StackPanel ShapeKindPicker() { Border KindBtn(ShapeKind kind, Shape glyph, string tip) { bool active = _shapeKind == kind; glyph.StrokeThickness = 1.5; glyph.Fill = Brushes.Transparent; glyph.HorizontalAlignment = HorizontalAlignment.Center; glyph.VerticalAlignment = VerticalAlignment.Center; glyph.SetResourceReference(Shape.StrokeProperty, active ? "SelectionAccent" : "MutedTextBrush"); var b = new Border { Width = 26, Height = 20, CornerRadius = new CornerRadius(3), BorderThickness = new Thickness(active ? 1.5 : 1), Background = Brushes.Transparent, Margin = new Thickness(1, 0, 1, 0), Cursor = Cursors.Hand, ToolTip = tip, Child = glyph }; if (active) b.SetResourceReference(Border.BorderBrushProperty, "SelectionAccent"); else b.BorderBrush = _swatchDimBorder; b.MouseLeftButtonDown += (_, _) => { if (_shapeKind == kind) return; _shapeKind = kind; if (kind != ShapeKind.Polygon) CancelShapePolygon(); ShowDrawSettings(tool); }; return b; } var row = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(2, 0, 0, 0) }; row.Children.Add(KindBtn(ShapeKind.Rectangle, new Rectangle { Width = 13, Height = 9 }, Loc("Str_Bar_TT_ShapeBox"))); row.Children.Add(KindBtn(ShapeKind.Ellipse, new Ellipse { Width = 13, Height = 9 }, Loc("Str_Bar_TT_ShapeEllipse"))); var pent = new Polygon { Width = 13, Height = 11, Points = [new Point(6.5, 0), new Point(13, 4.5), new Point(10.5, 11), new Point(2.5, 11), new Point(0, 4.5)] }; row.Children.Add(KindBtn(ShapeKind.Polygon, pent, Loc("Str_Bar_TT_ShapeFreeform"))); var fillCheck = BarCheck(Loc("Str_Bar_ShapeFill"), _shapeFill, Loc("Str_Bar_TT_ShapeFill"), () => { _shapeFill = !_shapeFill; ShowDrawSettings(tool); }); fillCheck.Margin = new Thickness(10, 0, 18, 0); row.Children.Add(fillCheck); return row; } // Color group (label + swatches + more) - one wrap unit. var colorGroup = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 2, 16, 2) }; var colorLbl = new TextBlock { Text = Loc("Str_Bar_Color"), FontFamily = UiKit.UiFont, FontSize = 11, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0) }; colorLbl.SetResourceReference(TextBlock.ForegroundProperty, "MutedTextBrush"); colorGroup.Children.Add(colorLbl); // Color swatches bool isLineTool = tool == EditTool.Strikethrough || tool == EditTool.Underline; var activeColor = (tool is EditTool.Draw or EditTool.Line or EditTool.Shape) ? _drawColor : isLineTool ? Color.FromRgb(_lineAnnotColor.R, _lineAnnotColor.G, _lineAnnotColor.B) : Color.FromRgb(_highlightColor.R, _highlightColor.G, _highlightColor.B); foreach (var color in SwatchColors) { bool isActive = color == activeColor; var swatch = new Border { Width = 18, Height = 18, Background = Freeze(new SolidColorBrush(color)), BorderThickness = new Thickness(isActive ? 2 : 1), CornerRadius = new CornerRadius(3), Margin = new Thickness(1), Cursor = Cursors.Hand, Tag = color }; if (isActive) swatch.SetResourceReference(Border.BorderBrushProperty, "PrimaryBrush"); else swatch.BorderBrush = _swatchDimBorder; swatch.MouseLeftButtonDown += (s, e) => { var c = (Color)((Border)s!).Tag; if ((tool is EditTool.Draw or EditTool.Line or EditTool.Shape)) _drawColor = Color.FromArgb(_drawOpacity, c.R, c.G, c.B); else if (isLineTool) _lineAnnotColor = Color.FromArgb(_lineAnnotColor.A, c.R, c.G, c.B); else _highlightColor = Color.FromArgb(_highlightColor.A, c.R, c.G, c.B); ApplyDrawStyleToSelection(); // edit the selected annotation, if any ShowDrawSettings(tool); // refresh selection }; colorGroup.Children.Add(swatch); } // "More colors..." -> full RGB picker, applied to whichever draw color this bar drives. var moreDraw = new Border { Width = 18, Height = 18, CornerRadius = new CornerRadius(3), Margin = new Thickness(1), Cursor = Cursors.Hand, BorderThickness = new Thickness(1), ToolTip = Loc("Str_Bar_MoreColors"), BorderBrush = _swatchDimBorder, Background = new LinearGradientBrush { StartPoint = new Point(0, 0), EndPoint = new Point(1, 1), GradientStops = { new GradientStop(Colors.Red, 0), new GradientStop(Colors.Yellow, 0.25), new GradientStop(Colors.Lime, 0.5), new GradientStop(Colors.Cyan, 0.7), new GradientStop(Colors.Blue, 1) } } }; moreDraw.MouseLeftButtonDown += (_, _) => OpenColorPicker(activeColor, c => { if ((tool is EditTool.Draw or EditTool.Line or EditTool.Shape)) _drawColor = Color.FromArgb(_drawOpacity, c.R, c.G, c.B); else if (isLineTool) _lineAnnotColor = Color.FromArgb(_lineAnnotColor.A, c.R, c.G, c.B); else _highlightColor = Color.FromArgb(_highlightColor.A, c.R, c.G, c.B); ApplyDrawStyleToSelection(); ShowDrawSettings(tool); }, () => ShowDrawSettings(tool)); colorGroup.Children.Add(moreDraw); panel.Children.Add(colorGroup); // Single-row groups (Color, Size, Opacity+toggle) packed directly by the outer WrapPanel; // Opacity and the Level/Eraser toggle share one unit so the toggle never lands on a row by // itself. When even one group per row would need a third row, the least important groups // collapse into the overflow chevron - see WireBarOverflow below. var dOpacityUnit = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center }; StackPanel? sizeGroupRef = null; _annotBarDragInners.Clear(); // Size slider (draw only) if ((tool is EditTool.Draw or EditTool.Line or EditTool.Shape)) { var sizeGroup = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 2, 16, 2) }; var sizeLbl = new TextBlock { Text = Loc("Str_Bar_Size"), FontFamily = UiKit.UiFont, FontSize = 11, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0) }; sizeLbl.SetResourceReference(TextBlock.ForegroundProperty, "MutedTextBrush"); sizeGroup.Children.Add(sizeLbl); var sizeSlider = new Slider { Minimum = 1, Maximum = 60, Value = _drawWidth, Width = 90, VerticalAlignment = VerticalAlignment.Center, TickFrequency = 1, IsSnapToTickEnabled = true, Style = (Style)FindResource("DarkSlider") }; sizeSlider.ValueChanged += (s, e) => { _drawWidth = e.NewValue; ApplyDrawStyleToSelection(); }; sizeGroup.Children.Add(sizeSlider); var sizeLabel = new TextBlock { Text = $"{_drawWidth:F0}px", FontFamily = UiKit.UiFont, FontSize = 11, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(4, 0, 0, 0), Width = 34, TextAlignment = TextAlignment.Right }; sizeLabel.SetResourceReference(TextBlock.ForegroundProperty, "MutedTextBrush"); sizeSlider.ValueChanged += (s, e) => sizeLabel.Text = $"{e.NewValue:F0}px"; sizeGroup.Children.Add(sizeLabel); panel.Children.Add(sizeGroup); sizeGroupRef = sizeGroup; } // Opacity group (label + slider + value) - one wrap unit. var opacityGroup = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 2, 16, 2) }; var opacityLbl = new TextBlock { Text = Loc("Str_Bar_Opacity"), FontFamily = UiKit.UiFont, FontSize = 11, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0) }; opacityLbl.SetResourceReference(TextBlock.ForegroundProperty, "MutedTextBrush"); opacityGroup.Children.Add(opacityLbl); byte currentOpacity = (tool is EditTool.Draw or EditTool.Line or EditTool.Shape) ? _drawOpacity : isLineTool ? _lineAnnotColor.A : _highlightColor.A; var opacitySlider = new Slider { Minimum = 10, Maximum = 255, Value = currentOpacity, Width = 90, VerticalAlignment = VerticalAlignment.Center, Style = (Style)FindResource("DarkSlider") }; var opacityLabel = new TextBlock { Text = $"{(int)(currentOpacity / 255.0 * 100)}%", FontFamily = UiKit.UiFont, FontSize = 11, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(4, 0, 6, 0), Width = 40, TextAlignment = TextAlignment.Right }; opacityLabel.SetResourceReference(TextBlock.ForegroundProperty, "MutedTextBrush"); opacitySlider.ValueChanged += (s, e) => { byte a = (byte)e.NewValue; opacityLabel.Text = $"{(int)(a / 255.0 * 100)}%"; if ((tool is EditTool.Draw or EditTool.Line or EditTool.Shape)) { _drawOpacity = a; _drawColor = Color.FromArgb(a, _drawColor.R, _drawColor.G, _drawColor.B); } else if (isLineTool) { _lineAnnotColor = Color.FromArgb(a, _lineAnnotColor.R, _lineAnnotColor.G, _lineAnnotColor.B); } else { _highlightColor = Color.FromArgb(a, _highlightColor.R, _highlightColor.G, _highlightColor.B); } ApplyDrawStyleToSelection(); // edit the selected annotation, if any }; opacityGroup.Children.Add(opacitySlider); opacityGroup.Children.Add(opacityLabel); dOpacityUnit.Children.Add(opacityGroup); // Level (Line) / Eraser (Highlight, Draw) toggle rides at the end of the Opacity unit, so it // stays beside Opacity as the bar collapses instead of dropping onto a row by itself. if (barCheck is not null) { barCheck.Margin = new Thickness(0, 2, 2, 2); dOpacityUnit.Children.Add(barCheck); } panel.Children.Add(dOpacityUnit); var drawOverflowBtn = MakeBarOverflow(out var drawOverflowPopup, out var drawOverflowStack); panel.Children.Add(drawOverflowBtn); panel.Children.Add(drawOverflowPopup); // renders nothing; keeps the popup in the tree for DynamicResource _drawSettingsBar = new Border { HorizontalAlignment = HorizontalAlignment.Right, // right-anchored; slid via the grip VerticalAlignment = VerticalAlignment.Top, CornerRadius = new CornerRadius(0), Effect = AnnotBarShadow(), Child = BuildBarHost(panel), Margin = new Thickness(0, 0, 0, 0) }; _drawSettingsBar.SetResourceReference(Border.BackgroundProperty, "BgFlyout"); _drawSettingsBar.SetResourceReference(Border.CornerRadiusProperty, "AnnotationBarCornerRadius"); // Match the Text bar's pane-integrated edge. BarEdgeBrush is white in 98SE and made // this one bar look like a separate raised slab even though both use the same host. _drawSettingsBar.SetResourceReference(Border.BorderBrushProperty, "PaneBorderBrush"); _drawSettingsBar.SetResourceReference(Border.BorderThicknessProperty, "BarEdgeThickness"); _drawSettingsBar.SetResourceReference(Border.PaddingProperty, "BarPadding"); var previewArea = PagePreviewPanel.Parent as Grid; if (previewArea is not null) { Panel.SetZIndex(_drawSettingsBar, 100); previewArea.Children.Add(_drawSettingsBar); // Cap the wrap panel to the document area's width (less the bar chrome) so it wraps the // control groups to new rows once the window is too narrow to hold them on one line. panel.SetBinding(FrameworkElement.MaxWidthProperty, new System.Windows.Data.Binding("ActualWidth") { Source = previewArea, Converter = _barWidthInset }); WireBarWrapAdaptation(panel, drawGrip, colorGroup, previewArea); var drawGroups = sizeGroupRef is null ? new StackPanel[] { colorGroup, dOpacityUnit } : new StackPanel[] { colorGroup, sizeGroupRef, dOpacityUnit }; int[] drawShed = sizeGroupRef is null ? [1] : [2, 1]; // opacity unit first, then size; color anchored WireBarOverflow(panel, drawOverflowBtn, drawOverflowPopup, drawOverflowStack, drawGroups, drawShed, previewArea); PlaceAnnotationBar(_drawSettingsBar, drawGrip, fadeIn: appearing); } _annotBarTool = tool; _annotBarMinimized = false; // a freshly built bar is full-size } private void HideDrawSettings() { FadeOutAndRemoveBar(_drawSettingsBar); _drawSettingsBar = null; if (_annotBarTool is EditTool.Draw or EditTool.Highlight or EditTool.Strikethrough or EditTool.Underline or EditTool.Shape) _annotBarTool = null; } } }