From 2ee7bab28305551a3b301d36bbc322c2bb5f5d49 Mon Sep 17 00:00:00 2001 From: Dimitar Radenkov Date: Sun, 12 Jul 2026 07:33:48 +0300 Subject: [PATCH 01/17] Add Capture Library: browse, search, and open captures Implements a Capture Library feature with: - `CaptureLibraryService` and `ICaptureLibraryService` for managing and searching captures by file name or OCR text, with date filtering and progress reporting. - `CaptureTextIndex` and `ICaptureTextIndex` for OCR text extraction and LRU caching. - `LibraryViewModel` for MVVM state, search/filter logic, and commands. - New `LibraryWindow` (XAML/UI) for browsing, searching, and opening captures. - Tray menu integration, DI registration, and automation launch support. - `CaptureItem` and `CaptureSearchProgress` records for modeling. - `IOcrService` and `WindowsOcrService` updated for cancellation support. - Comprehensive unit tests for all new components. --- .../Services/CaptureLibraryOcrSearchTests.cs | 187 ++++++++++ .../Services/CaptureLibrarySearchTests.cs | 124 +++++++ .../Services/CaptureLibraryServiceTests.cs | 91 +++++ .../Services/CaptureTextIndexTests.cs | 126 +++++++ .../Handlers/PixelRulerShapeHandlerTests.cs | 89 +++++ .../Services/TrayIconManagerTests.cs | 3 +- .../ViewModels/LibraryViewModelTests.cs | 254 ++++++++++++++ Pointframe/App.xaml.cs | 47 ++- .../Automation/AutomationLaunchOptions.cs | 7 + Pointframe/Models/CaptureItem.cs | 3 + Pointframe/Models/CaptureSearchProgress.cs | 3 + .../Services/Capture/CaptureLibraryService.cs | 105 ++++++ .../Services/Capture/CaptureTextIndex.cs | 82 +++++ .../Capture/ICaptureLibraryService.cs | 15 + .../Services/Capture/ICaptureTextIndex.cs | 6 + .../Services/Infrastructure/IOcrService.cs | 2 +- .../Infrastructure/TrayIconManager.cs | 7 +- .../Infrastructure/WindowsOcrService.cs | 4 +- Pointframe/ViewModels/LibraryViewModel.cs | 151 +++++++++ Pointframe/Views/LibraryWindow.xaml | 320 ++++++++++++++++++ Pointframe/Views/LibraryWindow.xaml.cs | 58 ++++ 21 files changed, 1678 insertions(+), 6 deletions(-) create mode 100644 Pointframe.Tests/Services/CaptureLibraryOcrSearchTests.cs create mode 100644 Pointframe.Tests/Services/CaptureLibrarySearchTests.cs create mode 100644 Pointframe.Tests/Services/CaptureLibraryServiceTests.cs create mode 100644 Pointframe.Tests/Services/CaptureTextIndexTests.cs create mode 100644 Pointframe.Tests/Services/Handlers/PixelRulerShapeHandlerTests.cs create mode 100644 Pointframe.Tests/ViewModels/LibraryViewModelTests.cs create mode 100644 Pointframe/Models/CaptureItem.cs create mode 100644 Pointframe/Models/CaptureSearchProgress.cs create mode 100644 Pointframe/Services/Capture/CaptureLibraryService.cs create mode 100644 Pointframe/Services/Capture/CaptureTextIndex.cs create mode 100644 Pointframe/Services/Capture/ICaptureLibraryService.cs create mode 100644 Pointframe/Services/Capture/ICaptureTextIndex.cs create mode 100644 Pointframe/ViewModels/LibraryViewModel.cs create mode 100644 Pointframe/Views/LibraryWindow.xaml create mode 100644 Pointframe/Views/LibraryWindow.xaml.cs diff --git a/Pointframe.Tests/Services/CaptureLibraryOcrSearchTests.cs b/Pointframe.Tests/Services/CaptureLibraryOcrSearchTests.cs new file mode 100644 index 0000000..3d86e53 --- /dev/null +++ b/Pointframe.Tests/Services/CaptureLibraryOcrSearchTests.cs @@ -0,0 +1,187 @@ +using System.IO; +using Moq; +using Pointframe.Models; +using Pointframe.Services; +using Xunit; + +namespace Pointframe.Tests.Services; + +public sealed class CaptureLibraryOcrSearchTests : IDisposable +{ + private static readonly DateTime Jan = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTime Jun = new(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc); + + private readonly string _tempDirectory = Path.Combine( + Path.GetTempPath(), + "Pointframe.Tests", + Guid.NewGuid().ToString("N")); + + private readonly Mock _textIndex = new(); + + [Fact] + public async Task SearchAsync_MatchesTextInsideImage_EvenWhenFileNameDoesNot() + { + Directory.CreateDirectory(_tempDirectory); + CreateFile("a.png", Jan); + CreateFile("b.png", Jun); + TextFor("a.png", "invoice total 42"); + TextFor("b.png", "unrelated content"); + + var results = await NewService().SearchAsync("invoice", null, null); + + Assert.Equal("a.png", Assert.Single(results).FileName); + } + + [Fact] + public async Task SearchAsync_FileNameMatch_DoesNotRunOcr() + { + Directory.CreateDirectory(_tempDirectory); + CreateFile("invoice.png", Jan); + + var results = await NewService().SearchAsync("invoice", null, null); + + Assert.Single(results); + _textIndex.Verify(t => t.GetText(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task SearchAsync_EmptyQuery_ReturnsAllAndNeverRunsOcr() + { + Directory.CreateDirectory(_tempDirectory); + CreateFile("a.png", Jan); + CreateFile("b.png", Jun); + + var results = await NewService().SearchAsync(null, null, null); + + Assert.Equal(2, results.Count); + _textIndex.Verify(t => t.GetText(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task SearchAsync_DateExcludedItems_AreNeverOcrd() + { + Directory.CreateDirectory(_tempDirectory); + CreateFile("old.png", Jan); + CreateFile("new.png", Jun); + TextFor("new.png", "invoice"); + + var results = await NewService().SearchAsync("invoice", Jun, null); + + Assert.Equal("new.png", Assert.Single(results).FileName); + _textIndex.Verify( + t => t.GetText(It.Is(c => c.FileName == "old.png"), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task SearchAsync_OcrFailureForOneItem_ContinuesAndReturnsOtherMatches() + { + Directory.CreateDirectory(_tempDirectory); + CreateFile("broken.png", Jan); + CreateFile("ok.png", Jun); + + _textIndex + .Setup(t => t.GetText(It.Is(c => c.FileName == "broken.png"), It.IsAny())) + .ThrowsAsync(new IOException("broken")); + _textIndex + .Setup(t => t.GetText(It.Is(c => c.FileName == "ok.png"), It.IsAny())) + .ReturnsAsync("invoice"); + + var results = await NewService().SearchAsync("invoice", null, null); + + Assert.Equal("ok.png", Assert.Single(results).FileName); + } + + [Fact] + public async Task SearchAsync_PassesCancellationTokenToTextIndex() + { + Directory.CreateDirectory(_tempDirectory); + CreateFile("a.png", Jan); + using var cts = new CancellationTokenSource(); + + _textIndex + .Setup(t => t.GetText( + It.Is(c => c.FileName == "a.png"), + It.Is(token => token == cts.Token))) + .ReturnsAsync("invoice"); + + var results = await NewService().SearchAsync("invoice", null, null, cancellationToken: cts.Token); + + Assert.Equal("a.png", Assert.Single(results).FileName); + } + + [Fact] + public async Task SearchAsync_NoMatchInNameOrText_ReturnsEmpty() + { + Directory.CreateDirectory(_tempDirectory); + CreateFile("a.png", Jan); + TextFor("a.png", "something else"); + + var results = await NewService().SearchAsync("invoice", null, null); + + Assert.Empty(results); + } + + [Fact] + public async Task SearchAsync_TextMatchIsCaseInsensitive() + { + Directory.CreateDirectory(_tempDirectory); + CreateFile("a.png", Jan); + TextFor("a.png", "Total Due"); + + var results = await NewService().SearchAsync("total due", null, null); + + Assert.Single(results); + } + + [Fact] + public async Task SearchAsync_ReportsProgressAcrossScannedCandidates() + { + Directory.CreateDirectory(_tempDirectory); + CreateFile("a.png", Jan); + CreateFile("b.png", Jun); + TextFor("a.png", "invoice"); + TextFor("b.png", "other"); + + var reports = new List(); + var progress = new Progress(reports.Add); + + await NewService().SearchAsync("invoice", null, null, progress); + + // Progress marshals asynchronously; give the callbacks a moment to drain. + await Task.Delay(200); + + Assert.NotEmpty(reports); + Assert.All(reports, report => Assert.Equal(2, report.Total)); + Assert.Equal(2, reports[^1].Scanned); + } + + private CaptureLibraryService NewService() + { + var settings = new Mock(); + settings + .SetupGet(s => s.Current) + .Returns(new UserSettings { ScreenshotSavePath = _tempDirectory }); + return new CaptureLibraryService(settings.Object, _textIndex.Object); + } + + private void TextFor(string fileName, string? text) + => _textIndex + .Setup(t => t.GetText(It.Is(c => c.FileName == fileName), It.IsAny())) + .ReturnsAsync(text); + + private void CreateFile(string name, DateTime capturedUtc) + { + var path = Path.Combine(_tempDirectory, name); + File.WriteAllText(path, "stub"); + File.SetLastWriteTimeUtc(path, capturedUtc); + } + + public void Dispose() + { + if (Directory.Exists(_tempDirectory)) + { + Directory.Delete(_tempDirectory, recursive: true); + } + } +} diff --git a/Pointframe.Tests/Services/CaptureLibrarySearchTests.cs b/Pointframe.Tests/Services/CaptureLibrarySearchTests.cs new file mode 100644 index 0000000..dff0e83 --- /dev/null +++ b/Pointframe.Tests/Services/CaptureLibrarySearchTests.cs @@ -0,0 +1,124 @@ +using System.IO; +using Moq; +using Pointframe.Models; +using Pointframe.Services; +using Xunit; + +namespace Pointframe.Tests.Services; + +public sealed class CaptureLibrarySearchTests : IDisposable +{ + private static readonly DateTime Jan = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTime Mar = new(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTime Jun = new(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc); + + private readonly string _tempDirectory = Path.Combine( + Path.GetTempPath(), + "Pointframe.Tests", + Guid.NewGuid().ToString("N")); + + [Fact] + public void Search_NullQueryAndDates_ReturnsAllCaptures() + { + Directory.CreateDirectory(_tempDirectory); + CreateFile("shot-1.png", Jan); + CreateFile("shot-2.png", Jun); + var sut = NewService(); + + var results = sut.Search(null, null, null); + + Assert.Equal(sut.GetCaptures().Count, results.Count); + Assert.Equal(2, results.Count); + } + + [Fact] + public void Search_QueryMatchesFileNameCaseInsensitively() + { + Directory.CreateDirectory(_tempDirectory); + CreateFile("shot-1.png", Jan); + CreateFile("diagram.png", Jun); + + var results = NewService().Search("SHOT", null, null); + + Assert.Equal("shot-1.png", Assert.Single(results).FileName); + } + + [Fact] + public void Search_QueryWithNoMatch_ReturnsEmpty() + { + Directory.CreateDirectory(_tempDirectory); + CreateFile("shot-1.png", Jan); + + Assert.Empty(NewService().Search("nope", null, null)); + } + + [Fact] + public void Search_FromUtc_DropsOlderButIncludesBoundary() + { + Directory.CreateDirectory(_tempDirectory); + CreateFile("older.png", Jan); + CreateFile("boundary.png", Mar); + CreateFile("newer.png", Jun); + + var results = NewService().Search(null, Mar, null); + + Assert.Equal(2, results.Count); + Assert.Contains(results, item => item.FileName == "boundary.png"); + Assert.Contains(results, item => item.FileName == "newer.png"); + Assert.DoesNotContain(results, item => item.FileName == "older.png"); + } + + [Fact] + public void Search_ToUtc_DropsNewerButIncludesBoundary() + { + Directory.CreateDirectory(_tempDirectory); + CreateFile("older.png", Jan); + CreateFile("boundary.png", Mar); + CreateFile("newer.png", Jun); + + var results = NewService().Search(null, null, Mar); + + Assert.Equal(2, results.Count); + Assert.Contains(results, item => item.FileName == "boundary.png"); + Assert.Contains(results, item => item.FileName == "older.png"); + Assert.DoesNotContain(results, item => item.FileName == "newer.png"); + } + + [Fact] + public void Search_QueryAndDateRange_AndTogether() + { + Directory.CreateDirectory(_tempDirectory); + CreateFile("shot-old.png", Jan); + CreateFile("shot-mid.png", Mar); + CreateFile("diagram-mid.png", Mar); + CreateFile("shot-new.png", Jun); + + var results = NewService().Search("shot", Mar, Mar); + + Assert.Equal("shot-mid.png", Assert.Single(results).FileName); + } + + private CaptureLibraryService NewService() + { + var settings = new Mock(); + settings + .SetupGet(s => s.Current) + .Returns(new UserSettings { ScreenshotSavePath = _tempDirectory }); + return new CaptureLibraryService(settings.Object, Mock.Of()); + } + + private void CreateFile(string name, DateTime capturedUtc) + { + var path = Path.Combine(_tempDirectory, name); + File.WriteAllText(path, "stub"); + File.SetLastWriteTimeUtc(path, capturedUtc); + } + + public void Dispose() + { + if (Directory.Exists(_tempDirectory)) + { + Directory.Delete(_tempDirectory, recursive: true); + } + } +} diff --git a/Pointframe.Tests/Services/CaptureLibraryServiceTests.cs b/Pointframe.Tests/Services/CaptureLibraryServiceTests.cs new file mode 100644 index 0000000..ce6438c --- /dev/null +++ b/Pointframe.Tests/Services/CaptureLibraryServiceTests.cs @@ -0,0 +1,91 @@ +using System.IO; +using Moq; +using Pointframe.Models; +using Pointframe.Services; +using Xunit; + +namespace Pointframe.Tests.Services; + +public sealed class CaptureLibraryServiceTests : IDisposable +{ + private readonly string _tempDirectory = Path.Combine( + Path.GetTempPath(), + "Pointframe.Tests", + Guid.NewGuid().ToString("N")); + + [Fact] + public void GetCaptures_WhenFolderMissing_ReturnsEmpty() + { + var sut = NewService(Path.Combine(_tempDirectory, "does-not-exist")); + + Assert.Empty(sut.GetCaptures()); + } + + [Fact] + public void GetCaptures_ReturnsOnlySupportedImageFiles() + { + Directory.CreateDirectory(_tempDirectory); + CreateFile("a.png"); + CreateFile("b.jpg"); + CreateFile("c.jpeg"); + CreateFile("notes.txt"); + CreateFile("clip.mp4"); + + var captures = NewService(_tempDirectory).GetCaptures(); + + Assert.Equal(3, captures.Count); + Assert.DoesNotContain(captures, item => item.FileName == "notes.txt"); + Assert.DoesNotContain(captures, item => item.FileName == "clip.mp4"); + } + + [Fact] + public void GetCaptures_OrdersByCapturedAtDescending() + { + Directory.CreateDirectory(_tempDirectory); + var older = CreateFile("older.png"); + var newer = CreateFile("newer.png"); + File.SetLastWriteTimeUtc(older, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + File.SetLastWriteTimeUtc(newer, new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc)); + + var captures = NewService(_tempDirectory).GetCaptures(); + + Assert.Equal("newer.png", captures[0].FileName); + Assert.Equal("older.png", captures[1].FileName); + } + + [Fact] + public void GetCaptures_PopulatesPathAndName() + { + Directory.CreateDirectory(_tempDirectory); + var path = CreateFile("shot.png"); + + var item = Assert.Single(NewService(_tempDirectory).GetCaptures()); + + Assert.Equal(path, item.FilePath); + Assert.Equal("shot.png", item.FileName); + } + + private string CreateFile(string name) + { + var path = Path.Combine(_tempDirectory, name); + File.WriteAllText(path, "stub"); + return path; + } + + private static CaptureLibraryService NewService(string savePath) + { + var settings = new Mock(); + settings + .SetupGet(s => s.Current) + .Returns(new UserSettings { ScreenshotSavePath = savePath }); + return new CaptureLibraryService(settings.Object, Mock.Of()); + } + + public void Dispose() + { + if (Directory.Exists(_tempDirectory)) + { + Directory.Delete(_tempDirectory, recursive: true); + } + } +} diff --git a/Pointframe.Tests/Services/CaptureTextIndexTests.cs b/Pointframe.Tests/Services/CaptureTextIndexTests.cs new file mode 100644 index 0000000..44e4399 --- /dev/null +++ b/Pointframe.Tests/Services/CaptureTextIndexTests.cs @@ -0,0 +1,126 @@ +using System.Windows.Media; +using System.Windows.Media.Imaging; +using Moq; +using Pointframe.Models; +using Pointframe.Services; +using Xunit; + +namespace Pointframe.Tests.Services; + +public sealed class CaptureTextIndexTests +{ + private static readonly DateTime T1 = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTime T2 = new(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc); + + [Fact] + public async Task GetText_ReturnsRecognizedText() + { + var ocr = new Mock(); + ocr.Setup(o => o.Recognize(It.IsAny(), It.IsAny())).ReturnsAsync("hello"); + var sut = new CaptureTextIndex(ImageFiles(), ocr.Object); + + var text = await sut.GetText(Item("a.png", T1)); + + Assert.Equal("hello", text); + ocr.Verify(o => o.Recognize(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetText_SameItemTwice_UsesCacheAndCallsOcrOnce() + { + var ocr = new Mock(); + ocr.Setup(o => o.Recognize(It.IsAny(), It.IsAny())).ReturnsAsync("hello"); + var sut = new CaptureTextIndex(ImageFiles(), ocr.Object); + var item = Item("a.png", T1); + + var first = await sut.GetText(item); + var second = await sut.GetText(item); + + Assert.Equal("hello", first); + Assert.Equal("hello", second); + ocr.Verify(o => o.Recognize(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetText_StaleCapturedAt_Reindexes() + { + var ocr = new Mock(); + ocr.SetupSequence(o => o.Recognize(It.IsAny(), It.IsAny())) + .ReturnsAsync("v1") + .ReturnsAsync("v2"); + var sut = new CaptureTextIndex(ImageFiles(), ocr.Object); + + var first = await sut.GetText(Item("a.png", T1)); + var second = await sut.GetText(Item("a.png", T2)); + + Assert.Equal("v1", first); + Assert.Equal("v2", second); + ocr.Verify(o => o.Recognize(It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + [Fact] + public async Task GetText_NullResult_IsCached() + { + var ocr = new Mock(); + ocr.Setup(o => o.Recognize(It.IsAny(), It.IsAny())).ReturnsAsync((string?)null); + var sut = new CaptureTextIndex(ImageFiles(), ocr.Object); + var item = Item("a.png", T1); + + var first = await sut.GetText(item); + var second = await sut.GetText(item); + + Assert.Null(first); + Assert.Null(second); + ocr.Verify(o => o.Recognize(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetText_PassesCancellationTokenToOcr() + { + var ocr = new Mock(); + var sut = new CaptureTextIndex(ImageFiles(), ocr.Object); + using var cts = new CancellationTokenSource(); + + ocr.Setup(o => o.Recognize( + It.IsAny(), + It.Is(token => token == cts.Token))) + .ReturnsAsync("hello"); + + var text = await sut.GetText(Item("a.png", T1), cts.Token); + + Assert.Equal("hello", text); + } + + [Fact] + public async Task GetText_WhenCacheLimitExceeded_EvictsLeastRecentlyUsedItem() + { + var ocr = new Mock(); + ocr.SetupSequence(o => o.Recognize(It.IsAny(), It.IsAny())) + .ReturnsAsync("first") + .ReturnsAsync("second") + .ReturnsAsync("first-again"); + + var sut = new CaptureTextIndex(ImageFiles(), ocr.Object, maxCacheEntries: 1); + + await sut.GetText(Item("a.png", T1)); + await sut.GetText(Item("b.png", T1)); + var text = await sut.GetText(Item("a.png", T1)); + + Assert.Equal("first-again", text); + ocr.Verify(o => o.Recognize(It.IsAny(), It.IsAny()), Times.Exactly(3)); + } + + private static CaptureItem Item(string name, DateTime capturedUtc) + => new(name, name, capturedUtc); + + private static IImageFileService ImageFiles() + { + var bitmap = BitmapSource.Create( + 1, 1, 96, 96, PixelFormats.Bgra32, null, new byte[] { 0, 0, 0, 255 }, 4); + bitmap.Freeze(); + + var imageFiles = new Mock(); + imageFiles.Setup(f => f.LoadForAnnotation(It.IsAny())).Returns(bitmap); + return imageFiles.Object; + } +} diff --git a/Pointframe.Tests/Services/Handlers/PixelRulerShapeHandlerTests.cs b/Pointframe.Tests/Services/Handlers/PixelRulerShapeHandlerTests.cs new file mode 100644 index 0000000..d44a022 --- /dev/null +++ b/Pointframe.Tests/Services/Handlers/PixelRulerShapeHandlerTests.cs @@ -0,0 +1,89 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Shapes; +using Pointframe.Models; +using Pointframe.Services.Handlers; +using Xunit; + +namespace Pointframe.Tests.Services.Handlers; + +public sealed class PixelRulerShapeHandlerTests +{ + [Fact] + public void BeginUpdateCommit_RoundTripTracksRulerContainer() + { + StaTestHelper.Run(() => + { + var canvas = new Canvas(); + var tracked = new List(); + var p1 = new Point(0, 0); + var p2 = new Point(100, 0); + ShapeParameters? current = new PixelRulerShapeParameters(p1, p2, Colors.Red, 2, 1.0, 1.0); + + var handler = new PixelRulerShapeHandler(() => current); + + handler.Begin(p1, new SolidColorBrush(Colors.Red), 2, canvas); + + var container = Assert.IsType(Assert.Single(canvas.Children)); + + handler.Update(p2); + + // Shaft line runs exactly from P1 to P2. + Assert.Contains( + container.Children.OfType(), + line => line.X1 == p1.X && line.Y1 == p1.Y && line.X2 == p2.X && line.Y2 == p2.Y); + + // Two endpoint dots. + Assert.Equal(2, container.Children.OfType().Count()); + + handler.Commit(canvas, tracked.Add); + + Assert.Same(container, Assert.Single(tracked)); + }); + } + + [Fact] + public void Update_LabelReportsDpiScaledPixelLength() + { + StaTestHelper.Run(() => + { + var canvas = new Canvas(); + // 100 DIPs at 2.0 horizontal DPI scale => 200 physical pixels. + ShapeParameters? current = new PixelRulerShapeParameters( + new Point(0, 0), new Point(100, 0), Colors.Red, 2, 2.0, 2.0); + + var handler = new PixelRulerShapeHandler(() => current); + handler.Begin(new Point(0, 0), new SolidColorBrush(Colors.Red), 2, canvas); + + handler.Update(new Point(100, 0)); + + var container = Assert.IsType(Assert.Single(canvas.Children)); + var label = container.Children.OfType() + .Select(border => border.Child) + .OfType() + .Single(); + + Assert.Equal("200 px", label.Text); + }); + } + + [Fact] + public void Commit_WithoutShapeParameters_CancelsRuler() + { + StaTestHelper.Run(() => + { + var canvas = new Canvas(); + ShapeParameters? current = new PixelRulerShapeParameters( + new Point(0, 0), new Point(30, 40), Colors.Red, 2, 1.0, 1.0); + var handler = new PixelRulerShapeHandler(() => current); + + handler.Begin(new Point(0, 0), new SolidColorBrush(Colors.Red), 2, canvas); + current = null; + + handler.Commit(canvas, _ => throw new Xunit.Sdk.XunitException("Should not track cancelled ruler.")); + + Assert.Empty(canvas.Children); + }); + } +} diff --git a/Pointframe.Tests/Services/TrayIconManagerTests.cs b/Pointframe.Tests/Services/TrayIconManagerTests.cs index 6839247..7284a3c 100644 --- a/Pointframe.Tests/Services/TrayIconManagerTests.cs +++ b/Pointframe.Tests/Services/TrayIconManagerTests.cs @@ -574,7 +574,8 @@ private static TrayIconManager CreateManager( onOpenImage: static () => { }, onTrimRecording: onTrimRecording ?? (static _ => { }), onShowSettings: static () => { }, - onShowAbout: static () => { }); + onShowAbout: static () => { }, + onShowLibrary: static () => { }); } private static void InvokePrivate(object target, string methodName, params object[] args) diff --git a/Pointframe.Tests/ViewModels/LibraryViewModelTests.cs b/Pointframe.Tests/ViewModels/LibraryViewModelTests.cs new file mode 100644 index 0000000..ac1b670 --- /dev/null +++ b/Pointframe.Tests/ViewModels/LibraryViewModelTests.cs @@ -0,0 +1,254 @@ +using Moq; +using Pointframe.Models; +using Pointframe.Services; +using Pointframe.ViewModels; +using Xunit; + +namespace Pointframe.Tests.ViewModels; + +public sealed class LibraryViewModelTests +{ + private static readonly DateTime T1 = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTime T2 = new(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc); + + [Fact] + public async Task RefreshCommand_LoadsCapturesFromService() + { + var library = LibraryReturning(Item("a.png", T1), Item("b.png", T2)); + var sut = new LibraryViewModel(library.Object); + + await sut.RefreshCommand.ExecuteAsync(null); + + Assert.Equal(2, sut.Captures.Count); + Assert.Equal("a.png", sut.Captures[0].FileName); + } + + [Fact] + public async Task SearchQuery_Changed_RequeriesWithQuery() + { + var library = LibraryReturning(); + var sut = new LibraryViewModel(library.Object); + + sut.SearchQuery = "shot"; + await WaitForRefresh(sut); + + library.Verify( + l => l.SearchAsync("shot", null, null, It.IsAny>(), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task DateFilters_Changed_RequeryWithBounds() + { + var library = LibraryReturning(); + var sut = new LibraryViewModel(library.Object); + + sut.FromUtc = T1; + await WaitForRefresh(sut); + sut.ToUtc = T2; + await WaitForRefresh(sut); + + library.Verify( + l => l.SearchAsync(null, T1, T2, It.IsAny>(), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task RefreshCommand_RepopulatesCaptures() + { + var library = new Mock(); + library.SetupSequence(l => l.SearchAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny())) + .ReturnsAsync(new[] { Item("a.png", T1) }) + .ReturnsAsync(new[] { Item("b.png", T2), Item("c.png", T2) }); + var sut = new LibraryViewModel(library.Object); + + await sut.RefreshCommand.ExecuteAsync(null); + await sut.RefreshCommand.ExecuteAsync(null); + + Assert.Equal(2, sut.Captures.Count); + Assert.Equal("b.png", sut.Captures[0].FileName); + } + + [Fact] + public async Task IsSearching_IsClearedAfterSearchCompletes() + { + var sut = new LibraryViewModel(LibraryReturning().Object); + + await sut.RefreshCommand.ExecuteAsync(null); + + Assert.False(sut.IsSearching); + } + + [Fact] + public void FromDate_MapsToStartOfDayUtc() + { + var sut = new LibraryViewModel(LibraryReturning().Object); + var picked = new DateTime(2026, 3, 5, 13, 45, 0, DateTimeKind.Unspecified); + + sut.FromDate = picked; + + Assert.Equal(picked.Date.ToUniversalTime(), sut.FromUtc); + } + + [Fact] + public void ToDate_MapsToEndOfDayUtc_SoSameDayCapturesStayIncluded() + { + var sut = new LibraryViewModel(LibraryReturning().Object); + var picked = new DateTime(2026, 3, 5, 0, 0, 0, DateTimeKind.Unspecified); + + sut.ToDate = picked; + + var lateThatDay = new DateTime(2026, 3, 5, 23, 30, 0, DateTimeKind.Unspecified).ToUniversalTime(); + Assert.Equal(picked.Date.AddDays(1).AddTicks(-1).ToUniversalTime(), sut.ToUtc); + Assert.True(lateThatDay <= sut.ToUtc); + } + + [Fact] + public void OpenCommand_WithSelection_RaisesRequestOpen() + { + var item = Item("a.png", T1); + var sut = new LibraryViewModel(LibraryReturning(item).Object); + CaptureItem? opened = null; + sut.RequestOpen += capture => opened = capture; + + sut.SelectedItem = item; + sut.OpenCommand.Execute(null); + + Assert.Same(item, opened); + } + + [Fact] + public void OpenCommand_WithoutSelection_DoesNothing() + { + var sut = new LibraryViewModel(LibraryReturning().Object); + var raised = false; + sut.RequestOpen += _ => raised = true; + + sut.OpenCommand.Execute(null); + + Assert.False(raised); + } + + [Fact] + public async Task ShowEmptyState_IsSetWhenSearchReturnsNothing() + { + var sut = new LibraryViewModel(LibraryReturning().Object); + + await sut.RefreshCommand.ExecuteAsync(null); + + Assert.True(sut.ShowEmptyState); + Assert.Equal(0, sut.ResultCount); + } + + [Fact] + public async Task ShowEmptyState_IsClearedWhenResultsExist() + { + var sut = new LibraryViewModel(LibraryReturning(Item("a.png", T1)).Object); + + await sut.RefreshCommand.ExecuteAsync(null); + + Assert.False(sut.ShowEmptyState); + Assert.Equal(1, sut.ResultCount); + } + + [Fact] + public async Task ClearSearchCommand_ResetsQueryAndRequeries() + { + var library = LibraryReturning(); + var sut = new LibraryViewModel(library.Object); + sut.SearchQuery = "shot"; + await WaitForRefresh(sut); + + sut.ClearSearchCommand.Execute(null); + await WaitForRefresh(sut); + + Assert.Null(sut.SearchQuery); + Assert.False(sut.HasSearchQuery); + library.Verify( + l => l.SearchAsync(null, null, null, It.IsAny>(), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ClearFiltersCommand_ResetsQueryAndDates() + { + var sut = new LibraryViewModel(LibraryReturning().Object); + sut.SearchQuery = "shot"; + sut.FromDate = new DateTime(2026, 3, 5, 0, 0, 0, DateTimeKind.Unspecified); + sut.ToDate = new DateTime(2026, 3, 6, 0, 0, 0, DateTimeKind.Unspecified); + await WaitForRefresh(sut); + + sut.ClearFiltersCommand.Execute(null); + await WaitForRefresh(sut); + + Assert.Null(sut.SearchQuery); + Assert.Null(sut.FromDate); + Assert.Null(sut.ToDate); + Assert.Null(sut.FromUtc); + Assert.Null(sut.ToUtc); + Assert.False(sut.HasFilters); + } + + [Fact] + public void HasFilters_TracksQueryAndDates() + { + var sut = new LibraryViewModel(LibraryReturning().Object); + Assert.False(sut.HasFilters); + + sut.FromDate = new DateTime(2026, 3, 5, 0, 0, 0, DateTimeKind.Unspecified); + + Assert.True(sut.HasFilters); + } + + // The getter being right is not enough — the UI only reacts to the notification. + [Fact] + public void SearchQuery_Changed_NotifiesDependentFlags() + { + var sut = new LibraryViewModel(LibraryReturning().Object); + var notified = new List(); + sut.PropertyChanged += (_, e) => notified.Add(e.PropertyName); + + sut.SearchQuery = "shot"; + + Assert.Contains(nameof(LibraryViewModel.HasSearchQuery), notified); + Assert.Contains(nameof(LibraryViewModel.HasFilters), notified); + Assert.True(sut.HasFilters); + } + + [Fact] + public void FromDate_Changed_NotifiesHasFilters() + { + var sut = new LibraryViewModel(LibraryReturning().Object); + var notified = new List(); + sut.PropertyChanged += (_, e) => notified.Add(e.PropertyName); + + sut.FromDate = new DateTime(2026, 3, 5, 0, 0, 0, DateTimeKind.Unspecified); + + Assert.Contains(nameof(LibraryViewModel.HasFilters), notified); + } + + private static async Task WaitForRefresh(LibraryViewModel sut) + { + var task = sut.RefreshCommand.ExecutionTask; + if (task is not null) + { + await task; + } + } + + private static Mock LibraryReturning(params CaptureItem[] results) + { + var library = new Mock(); + library + .Setup(l => l.SearchAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny())) + .ReturnsAsync(results); + return library; + } + + private static CaptureItem Item(string name, DateTime capturedUtc) + => new(name, name, capturedUtc); +} diff --git a/Pointframe/App.xaml.cs b/Pointframe/App.xaml.cs index 809968e..8ed49ef 100644 --- a/Pointframe/App.xaml.cs +++ b/Pointframe/App.xaml.cs @@ -36,6 +36,7 @@ public partial class App : Application private DateTime _sessionStartTime; private SettingsWindow? _settingsWindow; private AboutWindow? _aboutWindow; + private LibraryWindow? _libraryWindow; private const string AutomationOpenImagePathEnvironmentVariable = "SNIPPINGTOOL_AUTOMATION_OPEN_IMAGE_PATH"; @@ -139,7 +140,8 @@ protected override void OnStartup(StartupEventArgs e) onOpenImage: () => Dispatcher.InvokeAsync(OpenImage, System.Windows.Threading.DispatcherPriority.ApplicationIdle), onTrimRecording: ShowTrimWindow, onShowSettings: ShowSettingsWindow, - onShowAbout: ShowAboutWindow); + onShowAbout: ShowAboutWindow, + onShowLibrary: ShowLibraryWindow); _trayIconManager.Initialize(); startupTimer.Stop(); _telemetry.TrackEvent("startup_completed", new Dictionary @@ -177,6 +179,8 @@ private static void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddTransient(); services.AddTransient(); services.AddTransient(); @@ -191,6 +195,7 @@ private static void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(CreateOverlayWindow); services.AddTransient(); @@ -212,6 +217,7 @@ private static void ConfigureServices(IServiceCollection services) sp.GetRequiredService>())); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(sp => new UpdateDownloadViewModel( UpdateDownloadViewModel.SharedHttp, @@ -280,6 +286,12 @@ private void ShowAutomationWindow(AutomationLaunchOptions automationLaunchOption return; } + if (automationLaunchOptions.OpenLibraryWindow) + { + ShowLibraryWindow(); + return; + } + if (automationLaunchOptions.OpenSampleOverlayWindow) { ShowAutomationSampleOverlayWindow(); @@ -377,6 +389,39 @@ private void ShowAboutWindow() _aboutWindow.Show(); } + private void ShowLibraryWindow() + { + if (_libraryWindow is not null) + { + _libraryWindow.Activate(); + return; + } + + _libraryWindow = _host.Services.GetRequiredService(); + _libraryWindow.ViewModel.RequestOpen += OpenCaptureFromLibrary; + RegisterAutomationWindow(_libraryWindow); + _libraryWindow.Closed += (_, _) => _libraryWindow = null; + _libraryWindow.Show(); + } + + private void OpenCaptureFromLibrary(CaptureItem item) + { + try + { + var bitmap = _imageFileService.LoadForAnnotation(item.FilePath); + _telemetry.TrackEvent("library_open_used"); + + // Close the library before the full-screen overlay appears so the two never overlap. + _libraryWindow?.Close(); + ShowOverlayFromImage(bitmap, item.FilePath); + } + catch (Exception ex) when (ex is FileNotFoundException or InvalidDataException or NotSupportedException or IOException or UnauthorizedAccessException) + { + _logger?.LogWarning(ex, "Failed to open capture '{Path}'", item.FilePath); + _messageBox.ShowWarning(ex.Message, "Open Capture"); + } + } + private void ShowAutomationSampleOverlayWindow() { var (bitmap, sourcePath) = AutomationSampleFactory.CreateOpenedImageSample(); diff --git a/Pointframe/Automation/AutomationLaunchOptions.cs b/Pointframe/Automation/AutomationLaunchOptions.cs index accd301..ab88a3a 100644 --- a/Pointframe/Automation/AutomationLaunchOptions.cs +++ b/Pointframe/Automation/AutomationLaunchOptions.cs @@ -4,6 +4,7 @@ internal sealed class AutomationLaunchOptions { private const string OpenSettingsArgument = "--automation-open-settings"; private const string OpenAboutArgument = "--automation-open-about"; + private const string OpenLibraryArgument = "--automation-open-library"; private const string OpenSampleOverlayArgument = "--automation-open-sample-overlay"; private const string OpenSampleRecordingOverlayArgument = "--automation-open-sample-recording-overlay"; private const string OpenTraySampleOverlayArgument = "--automation-open-tray-sample-overlay"; @@ -11,12 +12,14 @@ internal sealed class AutomationLaunchOptions private AutomationLaunchOptions( bool openSettingsWindow, bool openAboutWindow, + bool openLibraryWindow, bool openSampleOverlayWindow, bool openSampleRecordingOverlayWindow, bool openTraySampleOverlayWindow) { OpenSettingsWindow = openSettingsWindow; OpenAboutWindow = openAboutWindow; + OpenLibraryWindow = openLibraryWindow; OpenSampleOverlayWindow = openSampleOverlayWindow; OpenSampleRecordingOverlayWindow = openSampleRecordingOverlayWindow; OpenTraySampleOverlayWindow = openTraySampleOverlayWindow; @@ -25,6 +28,7 @@ private AutomationLaunchOptions( public bool IsAutomationMode => OpenSettingsWindow || OpenAboutWindow + || OpenLibraryWindow || OpenSampleOverlayWindow || OpenSampleRecordingOverlayWindow || OpenTraySampleOverlayWindow; @@ -33,6 +37,8 @@ private AutomationLaunchOptions( public bool OpenAboutWindow { get; } + public bool OpenLibraryWindow { get; } + public bool OpenSampleOverlayWindow { get; } public bool OpenSampleRecordingOverlayWindow { get; } @@ -48,6 +54,7 @@ public static AutomationLaunchOptions Parse(IEnumerable args) return new AutomationLaunchOptions( parsedArguments.Contains(OpenSettingsArgument), parsedArguments.Contains(OpenAboutArgument), + parsedArguments.Contains(OpenLibraryArgument), parsedArguments.Contains(OpenSampleOverlayArgument), parsedArguments.Contains(OpenSampleRecordingOverlayArgument), parsedArguments.Contains(OpenTraySampleOverlayArgument)); diff --git a/Pointframe/Models/CaptureItem.cs b/Pointframe/Models/CaptureItem.cs new file mode 100644 index 0000000..1a01f79 --- /dev/null +++ b/Pointframe/Models/CaptureItem.cs @@ -0,0 +1,3 @@ +namespace Pointframe.Models; + +public sealed record CaptureItem(string FilePath, string FileName, DateTime CapturedAtUtc); diff --git a/Pointframe/Models/CaptureSearchProgress.cs b/Pointframe/Models/CaptureSearchProgress.cs new file mode 100644 index 0000000..e9421fe --- /dev/null +++ b/Pointframe/Models/CaptureSearchProgress.cs @@ -0,0 +1,3 @@ +namespace Pointframe.Models; + +public sealed record CaptureSearchProgress(int Scanned, int Total); diff --git a/Pointframe/Services/Capture/CaptureLibraryService.cs b/Pointframe/Services/Capture/CaptureLibraryService.cs new file mode 100644 index 0000000..5253e4c --- /dev/null +++ b/Pointframe/Services/Capture/CaptureLibraryService.cs @@ -0,0 +1,105 @@ +namespace Pointframe.Services; + +internal sealed class CaptureLibraryService : ICaptureLibraryService +{ + private static readonly HashSet SupportedExtensions = + new(StringComparer.OrdinalIgnoreCase) { ".png", ".jpg", ".jpeg", ".bmp" }; + + private readonly IUserSettingsService _settings; + private readonly ICaptureTextIndex _textIndex; + + public CaptureLibraryService(IUserSettingsService settings, ICaptureTextIndex textIndex) + { + _settings = settings; + _textIndex = textIndex; + } + + public IReadOnlyList GetCaptures() + { + var folder = _settings.Current.ScreenshotSavePath; + if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder)) + { + return Array.Empty(); + } + + return Directory.EnumerateFiles(folder) + .Where(path => SupportedExtensions.Contains(Path.GetExtension(path))) + .Select(path => new CaptureItem( + path, + Path.GetFileName(path), + File.GetLastWriteTimeUtc(path))) + .OrderByDescending(item => item.CapturedAtUtc) + .ToList(); + } + + public IReadOnlyList Search(string? query, DateTime? fromUtc, DateTime? toUtc) + { + var hasQuery = !string.IsNullOrWhiteSpace(query); + + return GetCaptures() + .Where(item => + (!hasQuery || item.FileName.Contains(query!, StringComparison.OrdinalIgnoreCase)) + && InDateRange(item, fromUtc, toUtc)) + .ToList(); + } + + public async Task> SearchAsync( + string? query, + DateTime? fromUtc, + DateTime? toUtc, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + var candidates = GetCaptures() + .Where(item => InDateRange(item, fromUtc, toUtc)) + .ToList(); + + if (string.IsNullOrWhiteSpace(query)) + { + return candidates; + } + + var matches = new List(); + var scanned = 0; + + foreach (var item in candidates) + { + cancellationToken.ThrowIfCancellationRequested(); + + // A file-name hit short-circuits OCR — otherwise every keystroke would + // recognize text in every capture that is only excluded by name. + if (item.FileName.Contains(query, StringComparison.OrdinalIgnoreCase)) + { + matches.Add(item); + } + else + { + try + { + var text = await _textIndex.GetText(item, cancellationToken); + if (text is not null && text.Contains(query, StringComparison.OrdinalIgnoreCase)) + { + matches.Add(item); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception) + { + // A single unreadable image or OCR failure should not fail the whole search. + } + } + + scanned++; + progress?.Report(new CaptureSearchProgress(scanned, candidates.Count)); + } + + return matches; + } + + private static bool InDateRange(CaptureItem item, DateTime? fromUtc, DateTime? toUtc) + => (fromUtc is null || item.CapturedAtUtc >= fromUtc.Value) + && (toUtc is null || item.CapturedAtUtc <= toUtc.Value); +} diff --git a/Pointframe/Services/Capture/CaptureTextIndex.cs b/Pointframe/Services/Capture/CaptureTextIndex.cs new file mode 100644 index 0000000..7c29cc4 --- /dev/null +++ b/Pointframe/Services/Capture/CaptureTextIndex.cs @@ -0,0 +1,82 @@ +using System.Collections.Concurrent; + +namespace Pointframe.Services; + +internal sealed class CaptureTextIndex : ICaptureTextIndex +{ + private const int DefaultMaxCacheEntries = 2000; + + private readonly IImageFileService _imageFiles; + private readonly IOcrService _ocr; + private readonly int _maxCacheEntries; + private readonly object _cacheGate = new(); + private readonly ConcurrentDictionary _cache = new(); + private readonly LinkedList _lru = []; + private readonly Dictionary> _nodes = + new(StringComparer.OrdinalIgnoreCase); + + public CaptureTextIndex(IImageFileService imageFiles, IOcrService ocr, int maxCacheEntries = DefaultMaxCacheEntries) + { + _imageFiles = imageFiles; + _ocr = ocr; + _maxCacheEntries = Math.Max(1, maxCacheEntries); + } + + public async Task GetText(CaptureItem item, CancellationToken cancellationToken = default) + { + if (_cache.TryGetValue(item.FilePath, out var entry) + && entry.CapturedAtUtc == item.CapturedAtUtc) + { + Touch(item.FilePath); + return entry.Text; + } + + var bitmap = _imageFiles.LoadForAnnotation(item.FilePath); + var text = await _ocr.Recognize(bitmap, cancellationToken); + + lock (_cacheGate) + { + _cache[item.FilePath] = new CacheEntry(item.CapturedAtUtc, text); + Touch(item.FilePath); + TrimToLimit(); + } + + return text; + } + + private void Touch(string key) + { + lock (_cacheGate) + { + if (_nodes.TryGetValue(key, out var existingNode)) + { + _lru.Remove(existingNode); + } + else + { + existingNode = new LinkedListNode(key); + _nodes[key] = existingNode; + } + + _lru.AddFirst(existingNode); + } + } + + private void TrimToLimit() + { + while (_cache.Count > _maxCacheEntries) + { + var leastRecentlyUsed = _lru.Last; + if (leastRecentlyUsed is null) + { + break; + } + + _lru.RemoveLast(); + _nodes.Remove(leastRecentlyUsed.Value); + _cache.TryRemove(leastRecentlyUsed.Value, out _); + } + } + + private readonly record struct CacheEntry(DateTime CapturedAtUtc, string? Text); +} diff --git a/Pointframe/Services/Capture/ICaptureLibraryService.cs b/Pointframe/Services/Capture/ICaptureLibraryService.cs new file mode 100644 index 0000000..ce63e27 --- /dev/null +++ b/Pointframe/Services/Capture/ICaptureLibraryService.cs @@ -0,0 +1,15 @@ +namespace Pointframe.Services; + +public interface ICaptureLibraryService +{ + IReadOnlyList GetCaptures(); + + IReadOnlyList Search(string? query, DateTime? fromUtc, DateTime? toUtc); + + Task> SearchAsync( + string? query, + DateTime? fromUtc, + DateTime? toUtc, + IProgress? progress = null, + CancellationToken cancellationToken = default); +} diff --git a/Pointframe/Services/Capture/ICaptureTextIndex.cs b/Pointframe/Services/Capture/ICaptureTextIndex.cs new file mode 100644 index 0000000..2854807 --- /dev/null +++ b/Pointframe/Services/Capture/ICaptureTextIndex.cs @@ -0,0 +1,6 @@ +namespace Pointframe.Services; + +public interface ICaptureTextIndex +{ + Task GetText(CaptureItem item, CancellationToken cancellationToken = default); +} diff --git a/Pointframe/Services/Infrastructure/IOcrService.cs b/Pointframe/Services/Infrastructure/IOcrService.cs index b5581c1..8a04e21 100644 --- a/Pointframe/Services/Infrastructure/IOcrService.cs +++ b/Pointframe/Services/Infrastructure/IOcrService.cs @@ -2,5 +2,5 @@ namespace Pointframe.Services; public interface IOcrService { - Task Recognize(BitmapSource bitmap); + Task Recognize(BitmapSource bitmap, CancellationToken cancellationToken = default); } diff --git a/Pointframe/Services/Infrastructure/TrayIconManager.cs b/Pointframe/Services/Infrastructure/TrayIconManager.cs index 0d679a9..807e0a8 100644 --- a/Pointframe/Services/Infrastructure/TrayIconManager.cs +++ b/Pointframe/Services/Infrastructure/TrayIconManager.cs @@ -26,6 +26,7 @@ internal sealed class TrayIconManager : ITrayIconManager private readonly Action _onTrimRecording; private readonly Action _onShowSettings; private readonly Action _onShowAbout; + private readonly Action _onShowLibrary; private const int MaxRecentItems = 5; @@ -53,8 +54,10 @@ public TrayIconManager( Action onOpenImage, Action onTrimRecording, Action onShowSettings, - Action onShowAbout) + Action onShowAbout, + Action onShowLibrary) { + _onShowLibrary = onShowLibrary; _logger = logger; _messageBox = messageBox; _processService = processService; @@ -171,6 +174,7 @@ private WpfContextMenu CreateTrayContextMenu() contextMenu.Items.Add(CreateTrayMenuItem("Whole screen snip", WholeScreenSnip_Click)); contextMenu.Items.Add(CreateTrayMenuItem("Clean window snip", CleanWindowSnip_Click)); contextMenu.Items.Add(CreateTrayMenuItem("Open image...", OpenImage_Click)); + contextMenu.Items.Add(CreateTrayMenuItem("Library", Library_Click)); contextMenu.Items.Add(CreateOpenFoldersMenuItem()); contextMenu.Items.Add(new WpfSeparator()); contextMenu.Items.Add(CreateTrayMenuItem("Settings", Settings_Click)); @@ -210,6 +214,7 @@ internal static WpfMenuItem CreateTrayMenuItem(string header, RoutedEventHandler private void CleanWindowSnip_Click(object sender, RoutedEventArgs e) => _onCleanWindowSnip(); private void Settings_Click(object sender, RoutedEventArgs e) => _onShowSettings(); private void About_Click(object sender, RoutedEventArgs e) => _onShowAbout(); + private void Library_Click(object sender, RoutedEventArgs e) => _onShowLibrary(); private void OpenImage_Click(object sender, RoutedEventArgs e) => _onOpenImage(); private void Exit_Click(object sender, RoutedEventArgs e) => WpfApplication.Current.Shutdown(); diff --git a/Pointframe/Services/Infrastructure/WindowsOcrService.cs b/Pointframe/Services/Infrastructure/WindowsOcrService.cs index 5d9e835..dfb9f8b 100644 --- a/Pointframe/Services/Infrastructure/WindowsOcrService.cs +++ b/Pointframe/Services/Infrastructure/WindowsOcrService.cs @@ -6,7 +6,7 @@ namespace Pointframe.Services; internal sealed class WindowsOcrService : IOcrService { - public async Task Recognize(BitmapSource bitmap) + public async Task Recognize(BitmapSource bitmap, CancellationToken cancellationToken = default) { var engine = OcrEngine.TryCreateFromUserProfileLanguages(); if (engine is null) @@ -15,7 +15,7 @@ internal sealed class WindowsOcrService : IOcrService } using var softwareBitmap = ConvertToSoftwareBitmap(bitmap); - var result = await engine.RecognizeAsync(softwareBitmap); + var result = await engine.RecognizeAsync(softwareBitmap).AsTask(cancellationToken); if (result.Lines.Count == 0) { diff --git a/Pointframe/ViewModels/LibraryViewModel.cs b/Pointframe/ViewModels/LibraryViewModel.cs new file mode 100644 index 0000000..1d590ac --- /dev/null +++ b/Pointframe/ViewModels/LibraryViewModel.cs @@ -0,0 +1,151 @@ +using System.Collections.ObjectModel; +using Pointframe.Services; + +namespace Pointframe.ViewModels; + +public partial class LibraryViewModel : ObservableObject +{ + private readonly ICaptureLibraryService _library; + + private CancellationTokenSource? _searchCts; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasSearchQuery))] + [NotifyPropertyChangedFor(nameof(HasFilters))] + private string? _searchQuery; + + [ObservableProperty] + private DateTime? _fromUtc; + + [ObservableProperty] + private DateTime? _toUtc; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasFilters))] + private DateTime? _fromDate; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasFilters))] + private DateTime? _toDate; + + [ObservableProperty] + private CaptureItem? _selectedItem; + + [ObservableProperty] + private bool _isSearching; + + [ObservableProperty] + private string? _searchStatus; + + [ObservableProperty] + private bool _showEmptyState; + + [ObservableProperty] + private int _resultCount; + + public LibraryViewModel(ICaptureLibraryService library) + => _library = library; + + public event Action? RequestOpen; + + public event Action? RequestClose; + + public ObservableCollection Captures { get; } = new(); + + public bool HasSearchQuery => !string.IsNullOrWhiteSpace(SearchQuery); + + public bool HasFilters => HasSearchQuery || FromDate is not null || ToDate is not null; + + [RelayCommand(AllowConcurrentExecutions = true)] + private async Task Refresh() + { + // Supersede any in-flight search; OCR over a large library is slow and + // each keystroke would otherwise queue another full pass. + _searchCts?.Cancel(); + _searchCts?.Dispose(); + + var cts = new CancellationTokenSource(); + _searchCts = cts; + + IsSearching = true; + ShowEmptyState = false; + SearchStatus = null; + + var progress = new Progress(report => + { + if (ReferenceEquals(_searchCts, cts) && report.Scanned < report.Total) + { + SearchStatus = $"Searching image text… {report.Scanned} of {report.Total}"; + } + }); + + try + { + var results = await _library.SearchAsync(SearchQuery, FromUtc, ToUtc, progress, cts.Token); + + if (cts.Token.IsCancellationRequested) + { + return; + } + + Captures.Clear(); + + foreach (var item in results) + { + Captures.Add(item); + } + + ResultCount = Captures.Count; + ShowEmptyState = Captures.Count == 0; + } + catch (OperationCanceledException) + { + // A newer search replaced this one. + } + finally + { + if (ReferenceEquals(_searchCts, cts)) + { + IsSearching = false; + SearchStatus = null; + } + } + } + + [RelayCommand] + private void ClearSearch() => SearchQuery = null; + + [RelayCommand] + private void ClearFilters() + { + SearchQuery = null; + FromDate = null; + ToDate = null; + } + + [RelayCommand] + private void Open() + { + if (SelectedItem is not null) + { + RequestOpen?.Invoke(SelectedItem); + } + } + + [RelayCommand] + private void Close() => RequestClose?.Invoke(); + + partial void OnSearchQueryChanged(string? value) => RefreshCommand.Execute(null); + + partial void OnFromUtcChanged(DateTime? value) => RefreshCommand.Execute(null); + + partial void OnToUtcChanged(DateTime? value) => RefreshCommand.Execute(null); + + // DatePicker yields a local calendar day; SearchAsync compares against UTC instants. + // Map the picked day to its full local-day span so same-day captures are not excluded. + partial void OnFromDateChanged(DateTime? value) + => FromUtc = value?.Date.ToUniversalTime(); + + partial void OnToDateChanged(DateTime? value) + => ToUtc = value?.Date.AddDays(1).AddTicks(-1).ToUniversalTime(); +} diff --git a/Pointframe/Views/LibraryWindow.xaml b/Pointframe/Views/LibraryWindow.xaml new file mode 100644 index 0000000..4ff6006 --- /dev/null +++ b/Pointframe/Views/LibraryWindow.xaml @@ -0,0 +1,320 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +