From d4e618cdc90beb83e2fe6e71511beb606211b867 Mon Sep 17 00:00:00 2001 From: estebanm123 <49930791+estebanm123@users.noreply.github.com> Date: Wed, 7 Dec 2022 15:03:50 -0800 Subject: [PATCH] [Peek] Add basic file querying and navigation (#22589) * Refactor to facilitate file data initialization * Extract file-related code to new FileManager class * Add temp basic version * Clean + add todo for cancellations * Fix various nav-related issues * Temp - start moving iteration-related code to bg thread * Minor tweaks * Add FEHelper todo * Rename FileManager + various tweaks * Add basic throttling * Improve bg thread synchronization * Clean * Clean * Rename based on feedback * Rename FileQuery * Rename properties * Rename remaining fields * Add todos for nav success/failures Co-authored-by: Esteban Margaron --- .../Peek.FilePreviewer/FilePreview.xaml.cs | 2 + .../ImagePreviewer/ImagePreviewer.cs | 33 +++- src/modules/peek/Peek.UI/FolderItemsQuery.cs | 170 ++++++++++++++++++ .../Peek.UI/Helpers/FileExplorerHelper.cs | 17 +- src/modules/peek/Peek.UI/MainWindow.xaml | 9 +- src/modules/peek/Peek.UI/MainWindow.xaml.cs | 45 +++-- .../peek/Peek.UI/MainWindowViewModel.cs | 53 +++++- 7 files changed, 286 insertions(+), 43 deletions(-) create mode 100644 src/modules/peek/Peek.UI/FolderItemsQuery.cs diff --git a/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml.cs b/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml.cs index c5f0d0f660f6..5a10b741e268 100644 --- a/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml.cs +++ b/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml.cs @@ -76,6 +76,8 @@ public File File private async Task OnFilePropertyChanged() { + // TODO: track and cancel existing async preview tasks + // https://github.com/microsoft/PowerToys/issues/22480 if (File == null) { return; diff --git a/src/modules/peek/Peek.FilePreviewer/Previewers/ImagePreviewer/ImagePreviewer.cs b/src/modules/peek/Peek.FilePreviewer/Previewers/ImagePreviewer/ImagePreviewer.cs index 5474600806fd..e9d578cb98ce 100644 --- a/src/modules/peek/Peek.FilePreviewer/Previewers/ImagePreviewer/ImagePreviewer.cs +++ b/src/modules/peek/Peek.FilePreviewer/Previewers/ImagePreviewer/ImagePreviewer.cs @@ -6,6 +6,7 @@ namespace Peek.FilePreviewer.Previewers { using System; using System.Collections.Generic; + using System.Diagnostics; using System.Drawing.Imaging; using System.IO; using System.Threading; @@ -83,10 +84,17 @@ private Task LoadLowQualityThumbnailAsync() if (!IsFullImageLoaded && !IsHighQualityThumbnailLoaded) { - // TODO: Handle thumbnail errors - ThumbnailHelper.GetThumbnail(Path.GetFullPath(File.Path), out IntPtr hbitmap, ThumbnailHelper.LowQualityThumbnailSize); - var thumbnailBitmap = await GetBitmapFromHBitmapAsync(hbitmap); - Preview = thumbnailBitmap; + var hr = ThumbnailHelper.GetThumbnail(Path.GetFullPath(File.Path), out IntPtr hbitmap, ThumbnailHelper.LowQualityThumbnailSize); + if (hr == Common.Models.HResult.Ok) + { + var thumbnailBitmap = await GetBitmapFromHBitmapAsync(hbitmap); + Preview = thumbnailBitmap; + } + else + { + // TODO: handle thumbnail errors + Debug.WriteLine("Error loading thumbnail - hresult: " + hr); + } } thumbnailTCS.SetResult(); @@ -108,11 +116,18 @@ private Task LoadHighQualityThumbnailAsync() if (!IsFullImageLoaded) { - // TODO: Handle thumbnail errors - ThumbnailHelper.GetThumbnail(Path.GetFullPath(File.Path), out IntPtr hbitmap, ThumbnailHelper.HighQualityThumbnailSize); - var thumbnailBitmap = await GetBitmapFromHBitmapAsync(hbitmap); - IsHighQualityThumbnailLoaded = true; - Preview = thumbnailBitmap; + var hr = ThumbnailHelper.GetThumbnail(Path.GetFullPath(File.Path), out IntPtr hbitmap, ThumbnailHelper.HighQualityThumbnailSize); + if (hr == Common.Models.HResult.Ok) + { + var thumbnailBitmap = await GetBitmapFromHBitmapAsync(hbitmap); + IsHighQualityThumbnailLoaded = true; + Preview = thumbnailBitmap; + } + else + { + // TODO: handle thumbnail errors + Debug.WriteLine("Error loading thumbnail - hresult: " + hr); + } } thumbnailTCS.SetResult(); diff --git a/src/modules/peek/Peek.UI/FolderItemsQuery.cs b/src/modules/peek/Peek.UI/FolderItemsQuery.cs new file mode 100644 index 000000000000..92c42703788c --- /dev/null +++ b/src/modules/peek/Peek.UI/FolderItemsQuery.cs @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Peek.UI +{ + using System; + using System.Collections.Generic; + using System.Diagnostics; + using System.Threading; + using System.Threading.Tasks; + using CommunityToolkit.Mvvm.ComponentModel; + using Peek.Common.Models; + using Peek.UI.Helpers; + + public partial class FolderItemsQuery : ObservableObject + { + private const int UninitializedItemIndex = -1; + + public void Clear() + { + CurrentFile = null; + + if (InitializeFilesTask != null && InitializeFilesTask.Status == TaskStatus.Running) + { + Debug.WriteLine("Detected existing initializeFilesTask running. Cancelling it.."); + CancellationTokenSource.Cancel(); + } + + InitializeFilesTask = null; + + lock (_mutateQueryDataLock) + { + Files = new List(); + _currentItemIndex = UninitializedItemIndex; + } + } + + public void UpdateCurrentItemIndex(int desiredIndex) + { + if (Files.Count <= 1 || _currentItemIndex == UninitializedItemIndex || + (InitializeFilesTask != null && InitializeFilesTask.Status == TaskStatus.Running)) + { + return; + } + + // Current index wraps around when reaching min/max folder item indices + desiredIndex %= Files.Count; + _currentItemIndex = desiredIndex < 0 ? Files.Count + desiredIndex : desiredIndex; + + if (_currentItemIndex < 0 || _currentItemIndex >= Files.Count) + { + Debug.Assert(false, "Out of bounds folder item index detected."); + _currentItemIndex = 0; + } + + CurrentFile = Files[_currentItemIndex]; + } + + public void Start() + { + var folderView = FileExplorerHelper.GetCurrentFolderView(); + if (folderView == null) + { + return; + } + + Shell32.FolderItems selectedItems = folderView.SelectedItems(); + if (selectedItems == null || selectedItems.Count == 0) + { + return; + } + + // Prioritize setting CurrentFile, which notifies UI + var firstSelectedItem = selectedItems.Item(0); + CurrentFile = new File(firstSelectedItem.Path); + + var items = selectedItems.Count > 1 ? selectedItems : folderView.Folder?.Items(); + if (items == null) + { + return; + } + + try + { + if (InitializeFilesTask != null && InitializeFilesTask.Status == TaskStatus.Running) + { + Debug.WriteLine("Detected unexpected existing initializeFilesTask running. Cancelling it.."); + CancellationTokenSource.Cancel(); + } + + CancellationTokenSource = new CancellationTokenSource(); + InitializeFilesTask = new Task(() => InitializeFiles(items, firstSelectedItem, CancellationTokenSource.Token)); + + // Execute file initialization/querying on background thread + InitializeFilesTask.Start(); + } + catch (Exception e) + { + Debug.WriteLine("Exception trying to run initializeFilesTask:\n" + e.ToString()); + } + } + + // Finds index of firstSelectedItem either amongst folder items, initializing our internal File list + // since storing Shell32.FolderItems as a field isn't reliable. + // Can take a few seconds for folders with 1000s of items; ensure it runs on a background thread. + // + // TODO optimization: + // Handle case where selected items count > 1 separately. Although it'll still be slow for 1000s of items selected, + // we can leverage faster APIs like Windows.Storage when 1 item is selected, and navigation is scoped to + // the entire folder. We can then avoid iterating through all items here, and maintain a dynamic window of + // loaded items around the current item index. + private void InitializeFiles( + Shell32.FolderItems items, + Shell32.FolderItem firstSelectedItem, + CancellationToken cancellationToken) + { + var tempFiles = new List(items.Count); + var tempCurIndex = UninitializedItemIndex; + + for (int i = 0; i < items.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + + var item = items.Item(i); + if (item == null) + { + continue; + } + + if (item.Name == firstSelectedItem.Name) + { + tempCurIndex = i; + } + + tempFiles.Add(new File(item.Path)); + } + + if (tempCurIndex == UninitializedItemIndex) + { + Debug.WriteLine("File query initialization: selectedItem index not found. Navigation remains disabled."); + return; + } + + cancellationToken.ThrowIfCancellationRequested(); + + lock (_mutateQueryDataLock) + { + cancellationToken.ThrowIfCancellationRequested(); + Files = tempFiles; + _currentItemIndex = tempCurIndex; + } + } + + private readonly object _mutateQueryDataLock = new (); + + [ObservableProperty] + private File? _currentFile; + + private List Files { get; set; } = new (); + + private int _currentItemIndex = UninitializedItemIndex; + + public int CurrentItemIndex => _currentItemIndex; + + private CancellationTokenSource CancellationTokenSource { get; set; } = new CancellationTokenSource(); + + private Task? InitializeFilesTask { get; set; } = null; + } +} diff --git a/src/modules/peek/Peek.UI/Helpers/FileExplorerHelper.cs b/src/modules/peek/Peek.UI/Helpers/FileExplorerHelper.cs index 79975307fc0a..08d3dc40b4ae 100644 --- a/src/modules/peek/Peek.UI/Helpers/FileExplorerHelper.cs +++ b/src/modules/peek/Peek.UI/Helpers/FileExplorerHelper.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using System.Collections.Generic; +using System.Diagnostics; using Peek.Common.Models; using Peek.UI.Native; @@ -10,28 +11,22 @@ namespace Peek.UI.Helpers { public static class FileExplorerHelper { - public static List GetSelectedFileExplorerFiles() + public static Shell32.IShellFolderViewDual2? GetCurrentFolderView() { var foregroundWindowHandle = NativeMethods.GetForegroundWindow(); - var selectedItems = new List(); var shell = new Shell32.Shell(); foreach (SHDocVw.InternetExplorer window in shell.Windows()) { + // TODO: figure out which window is the active explorer tab + // https://github.com/microsoft/PowerToys/issues/22507 if (window.HWND == (int)foregroundWindowHandle) { - Shell32.FolderItems items = ((Shell32.IShellFolderViewDual2)window.Document).SelectedItems(); - if (items != null && items.Count > 0) - { - foreach (Shell32.FolderItem item in items) - { - selectedItems.Add(new File(item.Path)); - } - } + return (Shell32.IShellFolderViewDual2)window.Document; } } - return selectedItems; + return null; } } } diff --git a/src/modules/peek/Peek.UI/MainWindow.xaml b/src/modules/peek/Peek.UI/MainWindow.xaml index c261512c7979..437c141fa8e3 100644 --- a/src/modules/peek/Peek.UI/MainWindow.xaml +++ b/src/modules/peek/Peek.UI/MainWindow.xaml @@ -15,7 +15,12 @@ - + + + + + + @@ -25,7 +30,7 @@ diff --git a/src/modules/peek/Peek.UI/MainWindow.xaml.cs b/src/modules/peek/Peek.UI/MainWindow.xaml.cs index 8ee2d66d0395..5953d74ff729 100644 --- a/src/modules/peek/Peek.UI/MainWindow.xaml.cs +++ b/src/modules/peek/Peek.UI/MainWindow.xaml.cs @@ -4,16 +4,15 @@ namespace Peek.UI { - using System.Collections.Generic; - using System.Linq; using interop; using Microsoft.UI.Windowing; - using Peek.Common.Models; + using Microsoft.UI.Xaml.Input; using Peek.FilePreviewer.Models; using Peek.UI.Extensions; - using Peek.UI.Helpers; using Peek.UI.Native; using Windows.Foundation; + using Windows.System; + using Windows.UI.Core; using WinUIEx; /// @@ -49,23 +48,37 @@ private void OnPeekHotkey() { if (AppWindow.IsVisible) { - this.Hide(); - ViewModel.Files = new List(); - ViewModel.CurrentFile = null; + Uninitialize(); } else { - var fileExplorerSelectedFiles = FileExplorerHelper.GetSelectedFileExplorerFiles(); - if (fileExplorerSelectedFiles.Count == 0) - { - return; - } - - ViewModel.Files = fileExplorerSelectedFiles; - ViewModel.CurrentFile = fileExplorerSelectedFiles.First(); + Initialize(); } } + private void LeftNavigationInvoked(KeyboardAccelerator sender, KeyboardAcceleratorInvokedEventArgs args) + { + ViewModel.AttemptLeftNavigation(); + } + + private void RightNavigationInvoked(KeyboardAccelerator sender, KeyboardAcceleratorInvokedEventArgs args) + { + ViewModel.AttemptRightNavigation(); + } + + private void Initialize() + { + ViewModel.FolderItemsQuery.Start(); + } + + private void Uninitialize() + { + this.Hide(); + + // TODO: move into general ViewModel method when needed + ViewModel.FolderItemsQuery.Clear(); + } + /// /// Handle FilePreviewerSizeChanged event to adjust window size and position accordingly. /// @@ -101,7 +114,7 @@ private void FilePreviewer_PreviewSizeChanged(object sender, PreviewSizeChangedA private void AppWindow_Closing(AppWindow sender, AppWindowClosingEventArgs args) { args.Cancel = true; - this.Hide(); + Uninitialize(); } } } diff --git a/src/modules/peek/Peek.UI/MainWindowViewModel.cs b/src/modules/peek/Peek.UI/MainWindowViewModel.cs index 0a923eda31d5..9bdc63ee2214 100644 --- a/src/modules/peek/Peek.UI/MainWindowViewModel.cs +++ b/src/modules/peek/Peek.UI/MainWindowViewModel.cs @@ -4,16 +4,59 @@ namespace Peek.UI { - using System.Collections.Generic; + using System; using CommunityToolkit.Mvvm.ComponentModel; - using Peek.Common.Models; + using Microsoft.UI.Xaml; public partial class MainWindowViewModel : ObservableObject { - [ObservableProperty] - private File? currentFile; + private const int NavigationThrottleDelayMs = 100; + + public MainWindowViewModel() + { + NavigationThrottleTimer.Tick += NavigationThrottleTimer_Tick; + NavigationThrottleTimer.Interval = TimeSpan.FromMilliseconds(NavigationThrottleDelayMs); + } + + public void AttemptLeftNavigation() + { + if (NavigationThrottleTimer.IsEnabled) + { + return; + } + + NavigationThrottleTimer.Start(); + + // TODO: return a bool so UI can give feedback in case navigation is unavailable + FolderItemsQuery.UpdateCurrentItemIndex(FolderItemsQuery.CurrentItemIndex - 1); + } + + public void AttemptRightNavigation() + { + if (NavigationThrottleTimer.IsEnabled) + { + return; + } + + NavigationThrottleTimer.Start(); + + // TODO: return a bool so UI can give feedback in case navigation is unavailable + FolderItemsQuery.UpdateCurrentItemIndex(FolderItemsQuery.CurrentItemIndex + 1); + } + + private void NavigationThrottleTimer_Tick(object? sender, object e) + { + if (sender == null) + { + return; + } + + ((DispatcherTimer)sender).Stop(); + } [ObservableProperty] - private List files = new (); + private FolderItemsQuery _folderItemsQuery = new (); + + private DispatcherTimer NavigationThrottleTimer { get; set; } = new (); } }