diff --git a/windows/ScreenHere/ClipboardController.cs b/windows/ScreenHere/ClipboardController.cs index f0853e7..75caba4 100644 --- a/windows/ScreenHere/ClipboardController.cs +++ b/windows/ScreenHere/ClipboardController.cs @@ -127,6 +127,9 @@ internal sealed class ClipboardController private readonly ClipboardHistoryStore store = new(); private readonly ClipboardImageStore images = new(); private readonly Dictionary thumbnails = new(); + /// The pictures whose thumbnail is being decoded, so a list that is + /// rebuilt while they arrive does not ask for the same one again. + private readonly HashSet thumbnailsAsked = []; private readonly DispatcherTimer saveTimer = new() { Interval = TimeSpan.FromSeconds(2) }; private readonly DispatcherTimer readTimer = new() { Interval = TimeSpan.FromMilliseconds(90) }; private HwndSource? listener; @@ -449,12 +452,27 @@ private void ReadNextCaption() }); } + /// The small picture a row leads with, once it has been decoded. A + /// thumbnail is a read from disk and a decompression, and a history full of + /// screenshots did every one of them on the interface's thread before the + /// list drew a single row — which is why it took so long to appear and + /// answered nothing while it did. Asked for here, drawn when it is there. public BitmapSource? Thumbnail(ClipImage image) { if (thumbnails.TryGetValue(image.Digest, out var cached)) return cached; - var small = images.Thumbnail(image); - if (small != null) thumbnails[image.Digest] = small; - return small; + if (posed || !thumbnailsAsked.Add(image.Digest)) return null; + Task.Run(() => + { + // Frozen where it is decoded, so the interface can use it as it is. + var small = images.Thumbnail(image); + if (small == null) return; + Application.Current?.Dispatcher.BeginInvoke(() => + { + thumbnails[image.Digest] = small; + Changed?.Invoke(); + }); + }); + return null; } /// Where the picture sits on disk, so a row can be dragged into any app. @@ -479,6 +497,7 @@ public void Clear() captionsAsked.Clear(); store.Delete(); thumbnails.Clear(); + thumbnailsAsked.Clear(); FileThumbnails.Shared.Clear(); LinkPreviews.Shared.Clear(); Task.Run(images.DeleteAll); @@ -518,7 +537,11 @@ private void Apply(ClipboardHistory next) saveTimer.Stop(); saveTimer.Start(); } - foreach (var digest in dropped) thumbnails.Remove(digest); + foreach (var digest in dropped) + { + thumbnails.Remove(digest); + thumbnailsAsked.Remove(digest); + } if (dropped.Count > 0 && !posed) Task.Run(() => images.Remove(dropped)); Changed?.Invoke(); } diff --git a/windows/ScreenHere/HistoryWindow.cs b/windows/ScreenHere/HistoryWindow.cs index fe36dbc..294b7c4 100644 --- a/windows/ScreenHere/HistoryWindow.cs +++ b/windows/ScreenHere/HistoryWindow.cs @@ -4,6 +4,7 @@ using System.Windows.Media; using System.Windows.Media.Animation; using System.Windows.Media.Imaging; +using System.Windows.Threading; namespace ScreenHere; @@ -44,6 +45,13 @@ internal sealed class HistoryWindow : Window private Point lastPointer; /// The window that had the keyboard before the list took it. private IntPtr previous; + /// Rebuilding the list is the expensive part of it, and what the list is + /// waiting for arrives one piece at a time: a picture decoded, the words + /// read in another, a page's title, a file's icon. Rebuilt once they have + /// settled rather than once per piece — a history full of screenshots + /// rebuilt itself a hundred times over, and a list rebuilt under the + /// pointer is a list that cannot be scrolled. + private readonly DispatcherTimer redraw = new() { Interval = TimeSpan.FromMilliseconds(200) }; public static bool IsOpen => current != null; @@ -84,14 +92,20 @@ public HistoryWindow() Content = Ui.Surface(Build(), radius: 14, margin: ShadowRoom, shadow: 0.4); lastPointer = Displays.Pointer(); - clipboard.Changed += Reload; - links.Changed += Reload; - filePictures.Changed += Reload; + redraw.Tick += (_, _) => + { + redraw.Stop(); + Reload(keepPlace: true); + }; + clipboard.Changed += ReloadSoon; + links.Changed += ReloadSoon; + filePictures.Changed += ReloadSoon; Closed += (_, _) => { - clipboard.Changed -= Reload; - links.Changed -= Reload; - filePictures.Changed -= Reload; + redraw.Stop(); + clipboard.Changed -= ReloadSoon; + links.Changed -= ReloadSoon; + filePictures.Changed -= ReloadSoon; if (current == this) { current = null; @@ -193,8 +207,17 @@ private UIElement Build() private static Border Divider() => new Border { Height = 1 }.With(b => b.SetResourceReference(Border.BackgroundProperty, "P10")); - private void Reload() + private void ReloadSoon() + { + redraw.Stop(); + redraw.Start(); + } + + /// `keepPlace` for a rebuild the user did not ask for: what arrived while + /// they were reading is no reason to take them back to the top of the list. + private void Reload(bool keepPlace = false) { + var place = keepPlace ? scroller.VerticalOffset : 0; results = clipboard.History.Matching(search.Text); selection = Math.Clamp(selection, 0, Math.Max(0, results.Count - 1)); if (clipboard.History.Items.Count == 0) confirmingClear = false; @@ -209,6 +232,10 @@ private void Reload() } body.Content = results.Count == 0 ? EmptyState() : scroller; RefreshFooter(); + // Once the new rows have been measured: until then the list is one row + // tall as far as the scroller knows, and anywhere but the top is out + // of its reach. + if (place > 0) Dispatcher.BeginInvoke(() => scroller.ScrollToVerticalOffset(place), DispatcherPriority.Loaded); } private UIElement EmptyState() diff --git a/windows/ScreenHere/Memory.cs b/windows/ScreenHere/Memory.cs index 28d2300..44afe9d 100644 --- a/windows/ScreenHere/Memory.cs +++ b/windows/ScreenHere/Memory.cs @@ -1,13 +1,17 @@ -using System.Diagnostics; using System.Runtime; -using System.Runtime.InteropServices; using System.Windows.Threading; namespace ScreenHere; /// A tray app spends nearly all its time doing nothing, and should weigh /// next to nothing while it does. A capture is tens of megabytes of pixels for -/// a second; once things have gone quiet, they are handed back to Windows. +/// a second; once things have gone quiet, they are collected. +/// +/// What is no longer done here is emptying the working set. Handing every page +/// back to Windows read well in Task Manager, but the next panel or list then +/// read itself back from disk — with an antivirus between the two, that is +/// seconds of nothing before anything appears, which is what the app being +/// slow to open looked like from the outside. internal static class Memory { private static DispatcherTimer? timer; @@ -39,17 +43,7 @@ private static DispatcherTimer Make() GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); - try - { - using var process = Process.GetCurrentProcess(); - EmptyWorkingSet(process.Handle); - } - catch - { - } }; return made; } - - [DllImport("psapi.dll")] private static extern bool EmptyWorkingSet(IntPtr process); } diff --git a/windows/ScreenHere/PanelWindow.cs b/windows/ScreenHere/PanelWindow.cs index 61511a9..4867e85 100644 --- a/windows/ScreenHere/PanelWindow.cs +++ b/windows/ScreenHere/PanelWindow.cs @@ -44,6 +44,9 @@ internal sealed class PanelWindow : Window private readonly ContentControl update = new() { Focusable = false }; private readonly DispatcherTimer poll = new() { Interval = TimeSpan.FromMilliseconds(100) }; private bool closing; + /// The ··· menu while it is up. It is a window of its own, so a click in + /// it is a click outside the panel. + private ContextMenu? openMenu; private string held = ""; private string? problem; private Func? anchor; @@ -136,8 +139,13 @@ public PanelWindow(PanelPose? pose) } Memory.SettleSoon(3); }; - // Clicking anywhere else dismisses it, like any flyout. - Deactivated += (_, _) => Dismiss(); + // Clicking anywhere else dismisses it, like any flyout — but the ··· + // menu is a window of its own, and the panel handing the keyboard to + // its own menu is not the user going elsewhere. + Deactivated += (_, _) => + { + if (openMenu?.IsOpen != true) Dismiss(); + }; // The panel grows and shrinks with what is switched on; it stays // attached to the edge it opened from. SizeChanged += (_, _) => @@ -161,6 +169,9 @@ private void Dismiss() { if (closing) return; closing = true; + // Its placement target is about to go: a menu left open over nothing + // has no panel left to close it, and stays on screen. + if (openMenu != null) openMenu.IsOpen = false; Tray.Shared.PanelClosedAt = DateTime.Now; Close(); } @@ -170,6 +181,11 @@ private void Dismiss() private void ClickedAt(int x, int y) { if (closing) return; + // Choosing something in the ··· menu is a click outside the panel, and + // closing the panel from under an open menu left the menu behind, + // with nothing above it to dismiss it. The menu closes itself; the + // click that closes it is not the panel's to act on. + if (openMenu?.IsOpen == true) return; var handle = new System.Windows.Interop.WindowInteropHelper(this).Handle; if (handle == IntPtr.Zero || !Native.GetWindowRect(handle, out var frame)) return; var shadow = (int)(ShadowRoom * VisualTreeHelper.GetDpi(this).DpiScaleX); @@ -307,6 +323,11 @@ private UIElement Footer() Dismiss(); Tray.Shared.SetHidden(true); })); + menu.Closed += (_, _) => + { + if (openMenu == menu) openMenu = null; + }; + openMenu = menu; menu.IsOpen = true; }, "More"); footer.Children.Add(more.Docked(Dock.Right));