From cb9f3ff5e00f6f5b00c1a08d821808fb675d92d1 Mon Sep 17 00:00:00 2001 From: adrbn <128328324+adrbn@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:21:58 +0100 Subject: [PATCH 1/4] perf: the history list draws before its pictures, not after all of them A thumbnail is a read from disk and a decompression, and the list did every one of them on the interface's thread before it drew a single row: a history full of screenshots took seconds to appear, and answered nothing while it did. Decoded off that thread, and drawn as each one arrives. --- windows/ScreenHere/ClipboardController.cs | 31 ++++++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) 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(); } From 05409996cefb87afe713aacd86376cf8ceb2e261 Mon Sep 17 00:00:00 2001 From: adrbn <128328324+adrbn@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:21:58 +0100 Subject: [PATCH 2/4] perf: the list is rebuilt once things have settled, not once per picture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What the list waits for arrives one piece at a time — a picture decoded, the words read in another, a page's title, a file's icon — and each piece rebuilt every row. A list rebuilt a hundred times over cannot be scrolled: the rows go out from under the pointer, and the scroller goes back to the top each time. Rebuilt once they have settled, and put back where the user was reading. --- windows/ScreenHere/HistoryWindow.cs | 41 ++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 7 deletions(-) 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() From 1c5d5d0099e6b79f28acfbe549fe1362a3bb4a48 Mon Sep 17 00:00:00 2001 From: adrbn <128328324+adrbn@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:21:58 +0100 Subject: [PATCH 3/4] perf: the app keeps its pages, so the next shortcut does not read them in again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handing every page back to Windows once things went quiet 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. The collection stays; the trimming goes. --- windows/ScreenHere/Memory.cs | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) 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); } From d14da6f407825512be4350cc4ac2a7e2355d2472 Mon Sep 17 00:00:00 2001 From: adrbn <128328324+adrbn@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:21:58 +0100 Subject: [PATCH 4/4] fix: the ... menu is not left behind by the panel it hangs from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The menu is a window of its own, so choosing something in it is a click outside the panel — which closed the panel from under it, leaving the menu on screen with nothing above it to dismiss it. While it is up, the panel stays; when the panel goes, the menu goes with it. --- windows/ScreenHere/PanelWindow.cs | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) 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));