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.Features; using MmdPdf.Services; using PdfPigDoc = UglyToad.PdfPig.PdfDocument; namespace MmdPdf { public partial class MainWindow : ISearchHost { // The search state machine (match list, cursor, next/prev) lives in // Features/Search/SearchController.cs; the bar UI, debounce, and highlight painting stay // here. Forwarders keep the bar buttons' and KeyboardShortcuts' call sites unchanged. private SearchController? _searchController; private SearchController Search => _searchController ??= new SearchController(this); private void SearchNextResult() => Search.Next(); private void SearchPrevResult() => Search.Prev(); // ---- ISearchHost ----------------------------------------------------------------------- // (IShellServices - Window, Loc, SetStatus - is implemented once for the class in // Shell/About.cs.) string? ISearchHost.CurrentFile => _currentFile; int ISearchHost.CurrentPageIndex => PageList.SelectedIndex; void ISearchHost.GoToPage(int pageIdx) => PageList.SelectedIndex = pageIdx; void ISearchHost.SetResultText(string text) { if (_searchStatus is not null) _searchStatus.Text = text; } void ISearchHost.SetResultCount(string text, string? tooltip) { if (_searchStatus is null) return; _searchStatus.Text = text; _searchStatus.ToolTip = tooltip; } void ISearchHost.ClearHighlights() => ClearSearchHighlights(); void ISearchHost.RepaintHighlights() => HighlightSearchResultsOnCurrentPage(); private void Search_Click(object sender, RoutedEventArgs e) => ToggleSearchBar(); private void ToggleSearchBar() { if (_searchBar is not null && _searchBar.Visibility == Visibility.Visible) { CloseSearchBar(); return; } ShowSearchBar(); } private void ShowSearchBar() { if (_searchBar is null) { // Build search bar programmatically and inject into the preview area grid _searchBox = new TextBox { Width = 200, Height = 26, FontFamily = UiKit.UiFont, FontSize = 13, SelectionBrush = AccentBrush(), BorderThickness = new Thickness(1), Padding = new Thickness(6, 2, 6, 2), VerticalContentAlignment = VerticalAlignment.Center }; // Live (DynamicResource-style) brushes so the box recolors on a theme switch while the // bar is open, instead of baking colors in at build time. Background uses the dark // toolbar/titlebar tone (BgSidebar). _searchBox.SetResourceReference(Control.BackgroundProperty, "BackgroundBrush"); _searchBox.SetResourceReference(Control.ForegroundProperty, "TextBrush"); _searchBox.SetResourceReference(System.Windows.Controls.Primitives.TextBoxBase.CaretBrushProperty, "TextBrush"); _searchBox.SetResourceReference(Control.BorderBrushProperty, "CardBorderBrush"); _searchBox.KeyDown += SearchBox_KeyDown; _searchBox.TextChanged += SearchBox_TextChanged; // Custom template so the default WPF blue focus/hover border never shows; keep our themed border. var tbTemplate = new ControlTemplate(typeof(TextBox)); var tbBorder = new FrameworkElementFactory(typeof(Border)); tbBorder.SetValue(Border.BackgroundProperty, new System.Windows.TemplateBindingExtension(Control.BackgroundProperty)); tbBorder.SetValue(Border.BorderBrushProperty, new System.Windows.TemplateBindingExtension(Control.BorderBrushProperty)); tbBorder.SetValue(Border.BorderThicknessProperty, new System.Windows.TemplateBindingExtension(Control.BorderThicknessProperty)); tbBorder.SetValue(Border.CornerRadiusProperty, new CornerRadius(3)); var tbHost = new FrameworkElementFactory(typeof(ScrollViewer)) { Name = "PART_ContentHost" }; tbHost.SetValue(ScrollViewer.PaddingProperty, new System.Windows.TemplateBindingExtension(Control.PaddingProperty)); tbHost.SetValue(ScrollViewer.VerticalAlignmentProperty, VerticalAlignment.Center); tbBorder.AppendChild(tbHost); tbTemplate.VisualTree = tbBorder; _searchBox.Template = tbTemplate; _searchBox.FocusVisualStyle = null; // Wide enough for translated empty/error states while keeping the count stable. _searchStatus = new TextBlock { FontFamily = UiKit.UiFont, FontSize = 11, VerticalAlignment = VerticalAlignment.Center, TextAlignment = TextAlignment.Center, Width = 96, Margin = new Thickness(2, 0, 2, 0) }; _searchStatus.SetResourceReference(TextBlock.ForegroundProperty, "MutedTextBrush"); // Small VSCode-style prev / next / close buttons. Hover tooltips carry the shortcuts. Button SearchNavBtn(string glyph, string tip, Action onClick, bool danger = false) { var b = new Button { Content = glyph, FontFamily = UiKit.IconFont, FontSize = 12, Width = 26, Height = 24, Padding = new Thickness(0), // ToolbarButton's 10,6 padding clips the glyph in a 26px button // The close X uses the shared danger style so its glyph turns red on hover like // every other close X (window chrome, tabs, overlay headers). Style = (Style)FindResource(danger ? "DangerCloseButton" : "ToolbarButton"), ToolTip = tip }; b.Click += (_, _) => onClick(); return b; } var prevBtn = SearchNavBtn("", Loc("Str_Search_PreviousTT"), SearchPrevResult); // ChevronUp var nextBtn = SearchNavBtn("", Loc("Str_Search_NextTT"), SearchNextResult); // ChevronDown var closeBtn = SearchNavBtn("", Loc("Str_Search_CloseTT"), CloseSearchBar, danger: true); // Cancel var searchIcon = new TextBlock { Text = "", // Segoe MDL2 Search / magnifying glass FontFamily = UiKit.IconFont, FontSize = 12, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0), IsHitTestVisible = false }; searchIcon.SetResourceReference(TextBlock.ForegroundProperty, "MutedTextBrush"); // Drag grip: two columns of three dots on the left (same look as the sidebar splitter and // the annotate bars). Grabbing it moves the whole bar anywhere in the document area. var gripBrush = TryFindResource("MutedTextBrush") as Brush ?? Brushes.Gray; var gripDots = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(2, 0, 6, 0) }; for (int gcol = 0; gcol < 2; gcol++) { var colDots = new StackPanel { Orientation = Orientation.Vertical, VerticalAlignment = VerticalAlignment.Center }; for (int grow = 0; grow < 3; grow++) colDots.Children.Add(new Ellipse { Width = 3, Height = 3, Margin = new Thickness(1.5), Fill = gripBrush }); gripDots.Children.Add(colDots); } var searchGrip = new Border { Background = Brushes.Transparent, // transparent yet hit-testable, so it can be grabbed Cursor = Cursors.SizeAll, VerticalAlignment = VerticalAlignment.Stretch, Child = gripDots, ToolTip = Loc("Str_Search_DragTT") }; var panel = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(8, 6, 8, 6) }; panel.Children.Add(searchGrip); panel.Children.Add(searchIcon); panel.Children.Add(_searchBox); panel.Children.Add(_searchStatus); panel.Children.Add(prevBtn); panel.Children.Add(nextBtn); panel.Children.Add(closeBtn); _searchBar = new Border { BorderThickness = new Thickness(1), // Free-floating like the Signatures popup: positioned by Left/Top margin (set after // layout from the saved spot) and draggable by the grip, so it can sit anywhere. HorizontalAlignment = HorizontalAlignment.Left, VerticalAlignment = VerticalAlignment.Top, CornerRadius = new CornerRadius(6), Padding = new Thickness(4), Child = GrainWrap(panel), Margin = new Thickness(0), Effect = new System.Windows.Media.Effects.DropShadowEffect { Color = Colors.Black, BlurRadius = 16, ShadowDepth = 3, Direction = 270, Opacity = 0.55 } }; _searchBar.SetResourceReference(Border.BackgroundProperty, "BgFlyout"); // PaneBorderBrush, the same key the annotate settings bars use (AnnotationBars.cs). // The find bar is the same class of floating tool surface, so it takes the same // edge; on MenuBorderBrush the two sat side by side drawing different borders. _searchBar.SetResourceReference(Border.BorderBrushProperty, "PaneBorderBrush"); // Add to the preview area grid (parent of ScrollViewer) var previewGrid = PagePreviewPanel.Parent as Grid; if (previewGrid is not null) { // Above the annotate settings bars (ZIndex 100) so Ctrl+F is never hidden under // the highlight/draw/text toolbar when both are open. Panel.SetZIndex(_searchBar, 200); previewGrid.Children.Add(_searchBar); // Keep the whole bar on screen: when the preview area is resized, shrink the text box // (not the buttons) so it never overflows, and re-clamp the floating bar back inside. previewGrid.SizeChanged += (_, _) => { FitSearchBox(); if (_searchBar is { Visibility: Visibility.Visible }) { double cl = _searchBar.Margin.Left, ct = _searchBar.Margin.Top; ClampPanelToBounds(_searchBar, previewGrid, ref cl, ref ct); _searchBar.Margin = new Thickness(cl, ct, 0, 0); } }; // Place it (saved spot, or default near the old top-right position) and wire the grip // for dragging once it's laid out and has a real width - mirrors the Signatures popup. var bar = _searchBar; var grip = searchGrip; Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded, (Action)(() => { ApplySavedPanelPosition(bar, previewGrid, "SearchBar", fallbackRightInset: 16, fallbackTop: 6); EnablePanelDrag(grip, bar, previewGrid, "SearchBar"); })); } } _searchBar.Visibility = Visibility.Visible; _searchBox!.Text = ""; if (_searchStatus != null) _searchStatus.Text = ""; FitSearchBox(); _searchBox.Focus(); Keyboard.Focus(_searchBox); } // Sizes the search text box to whatever width is left after the icon, status, and nav // buttons, capped at a comfortable 200px, so the full bar always fits the preview area. private void FitSearchBox() { if (_searchBar is null || _searchBox is null) return; double avail = (PagePreviewPanel.Parent as Grid)?.ActualWidth ?? 0; const double reserved = 232; // grip + icon + status + 3 buttons + paddings/margins _searchBox.Width = Math.Max(60, Math.Min(200, avail - reserved)); } private void CloseSearchBar() { if (_searchBar is { Visibility: Visibility.Visible } bar) { // Fade out rather than blink away. On completion, collapse and restore opacity so the // next open shows it cleanly. var fade = new DoubleAnimation(bar.Opacity, 0, new Duration(TimeSpan.FromMilliseconds(150))) { EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } }; fade.Completed += (_, _) => { bar.Visibility = Visibility.Collapsed; bar.BeginAnimation(UIElement.OpacityProperty, null); bar.Opacity = 1; }; bar.BeginAnimation(UIElement.OpacityProperty, fade); } ClearSearchHighlights(); } private void SearchBox_KeyDown(object sender, KeyEventArgs e) { if (e.Key == Key.Escape) { CloseSearchBar(); e.Handled = true; } else if (e.Key == Key.Enter) { if (Keyboard.Modifiers == ModifierKeys.Shift) SearchPrevResult(); else SearchNextResult(); e.Handled = true; } } private System.Windows.Threading.DispatcherTimer? _searchDebounce; private void SearchBox_TextChanged(object sender, TextChangedEventArgs e) { var text = _searchBox?.Text ?? ""; if (text.Length < 2) { _searchDebounce?.Stop(); ClearSearchHighlights(); Search.ClearMatches(); if (_searchStatus is not null) _searchStatus.Text = ""; return; } // Debounce: wait for a brief pause in typing before searching, so the first keystrokes // on a large document don't lock the UI while it searches partial queries. if (_searchDebounce is null) { _searchDebounce = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) }; _searchDebounce.Tick += (_, _) => { _searchDebounce!.Stop(); var q = _searchBox?.Text ?? ""; if (q.Length >= 2) Search.Run(q); }; } _searchDebounce.Stop(); _searchDebounce.Start(); } // Paints match highlights onto EVERY page that's currently on screen and has results - // the primary tile and all per-page overlays alike. Single Page shows the one page; Two-Page // and Grid show every visible tile with hits; Continuous shows them down the whole scroll. // Re-paints one page's match highlights onto its overlay using the in-memory page size (no // file I/O), so it's cheap enough to call at the tail of every RenderAllAnnotations - which is // what keeps highlights alive instead of being wiped by re-renders and continuous scrolling. private void ApplySearchHighlights(int page, Canvas canvas) { if (_searchBar is null || _searchBar.Visibility != Visibility.Visible) return; if (_doc is null || page < 0 || page >= _doc.PageCount) return; if (!Search.TryGetPageRects(page, out var rects)) return; if (!_renderDims.TryGetValue(page, out var rd)) return; var (renderW, renderH) = rd; double pdfW = _doc.Pages[page].Width.Point; double pdfH = _doc.Pages[page].Height.Point; if (pdfW <= 0 || pdfH <= 0) return; double sx = renderW / pdfW; double sy = renderH / pdfH; var cur = Search.CurrentMatch; foreach (var (left, bottom, right, top) in rects) { bool isCurrent = cur is { } c && c.page == page && c.left == left && c.bottom == bottom && c.right == right && c.top == top; AddSearchHighlight(canvas, left, bottom, right, top, sx, sy, renderH, isCurrent); } } // Repaints highlights on every page on screen right now (called when a search runs or the // current page changes); per-page re-renders keep them alive via ApplySearchHighlights. private void HighlightSearchResultsOnCurrentPage() { ClearSearchHighlights(); foreach (var page in Search.PagesWithResults) { var canvas = VisibleCanvasForPage(page); if (canvas is not null) ApplySearchHighlights(page, canvas); } } private void AddSearchHighlight(Canvas canvas, double left, double bottom, double right, double top, double sx, double sy, double renderH, bool isCurrent) { double cw = (right - left) * sx; double ch = (top - bottom) * sy; // A little breathing room so the box (and the current-match outline) wraps the whole word - // PdfPig's glyph bounds sit tight against the letters. Scales with text height so it looks // consistent across font sizes. double pad = ch * 0.30; double cx = left * sx - pad; double cy = renderH - (top * sy) - pad; var rect = new Rectangle { // The current match (search cursor) gets a brighter, more opaque fill; the others are dim. Fill = new SolidColorBrush(isCurrent ? Color.FromArgb(150, 255, 190, 0) : Color.FromArgb(70, 255, 165, 0)), StrokeThickness = isCurrent ? 3.5 : 1, RadiusX = pad * 0.6, RadiusY = pad * 0.6, Width = Math.Max(cw + pad * 2, 4), Height = Math.Max(ch + pad * 2, 4), IsHitTestVisible = false, Tag = "SearchHighlight" }; if (isCurrent) // Bind the current-match outline to the on-page accent resource so it recolors live on a // theme switch (matching the selection chrome), rather than baking the color in at paint time. rect.SetResourceReference(Shape.StrokeProperty, "SelectionAccent"); else rect.Stroke = new SolidColorBrush(Color.FromArgb(140, 255, 165, 0)); Canvas.SetLeft(rect, cx); Canvas.SetTop(rect, cy); canvas.Children.Add(rect); } // Removes only the highlight rectangles from every page overlay. Deliberately does NOT touch // the result counter - that's owned by SearchController's status updates and the empty-query path - so a // repaint (which clears then re-adds highlights) can't wipe the "3 / 14" count. private void ClearSearchHighlights() { foreach (var canvas in AllPageCanvases()) { var toRemove = canvas.Children.OfType() .Where(r => r.Tag is string s && s == "SearchHighlight").ToList(); foreach (var r in toRemove) canvas.Children.Remove(r); } } } }