From 13d439c122cafc0898cc14d6058ddc807a10a858 Mon Sep 17 00:00:00 2001 From: Tatsuro Shibamura Date: Wed, 27 May 2026 21:35:10 +0900 Subject: [PATCH 1/2] Add WinUI 3 app (WinQuickLook.App.WinUI) Introduce a new WinUI 3 app project (src/WinQuickLook.App.WinUI) implementing the vNext UI and shell integration. Adds App.xaml/App.xaml.cs with tray/keyboard hook wiring, a native notify icon implementation, and a rich PreviewWindow (XAML + code-behind) with image/text/media/web/file previews and animations. Updates solution to include the new WinUI, Preview and Shell projects, and documents development/run instructions in README. Also adds the WinUI project file, manifest and package lock; minor package/project file updates in existing projects and test project adjustments. --- README.md | 16 + WinQuickLook.slnx | 12 + src/WinQuickLook.App.WinUI/App.xaml | 13 + src/WinQuickLook.App.WinUI/App.xaml.cs | 155 ++++ src/WinQuickLook.App.WinUI/AppParameters.cs | 6 + .../NativeNotifyIcon.cs | 242 ++++++ src/WinQuickLook.App.WinUI/PreviewWindow.xaml | 89 ++ .../PreviewWindow.xaml.cs | 781 ++++++++++++++++++ .../WinQuickLook.App.WinUI.csproj | 29 + src/WinQuickLook.App.WinUI/app.manifest | 15 + src/WinQuickLook.App.WinUI/packages.lock.json | 200 +++++ src/WinQuickLook.App/App.xaml.cs | 5 +- src/WinQuickLook.App/WinQuickLook.App.csproj | 2 +- src/WinQuickLook.App/packages.lock.json | 32 +- .../WinQuickLook.Core.csproj | 4 +- src/WinQuickLook.Core/packages.lock.json | 12 +- .../AggregatePreviewProvider.cs | 49 ++ .../FileSystemPreviewPayload.cs | 10 + .../GenericPreviewProvider.cs | 87 ++ src/WinQuickLook.Preview/IPreviewProvider.cs | 11 + .../ImagePreviewPayload.cs | 3 + .../ImagePreviewProvider.cs | 44 + .../MarkdownPreviewProvider.cs | 98 +++ .../MediaPreviewPayload.cs | 3 + .../MediaPreviewProvider.cs | 62 ++ src/WinQuickLook.Preview/PreviewKind.cs | 15 + src/WinQuickLook.Preview/PreviewRequest.cs | 8 + .../PreviewRequestFactory.cs | 73 ++ src/WinQuickLook.Preview/PreviewResult.cs | 7 + .../TextPreviewPayload.cs | 7 + .../TextPreviewProvider.cs | 169 ++++ src/WinQuickLook.Preview/WebDocumentKind.cs | 9 + .../WebDocumentPreviewPayload.cs | 6 + .../WebDocumentPreviewProvider.cs | 53 ++ .../WinQuickLook.Preview.csproj | 11 + src/WinQuickLook.Preview/packages.lock.json | 13 + .../Messaging/LowLevelKeyboardHook.cs | 26 + .../Messaging/WindowsHook.cs | 58 ++ .../Providers/ShellExplorerProvider.cs | 152 ++++ .../WinQuickLook.Shell.csproj | 11 + src/WinQuickLook.Shell/packages.lock.json | 10 + .../Preview/ImagePreviewProviderTests.cs | 179 ++++ .../WinQuickLook.Core.Tests.csproj | 5 +- .../packages.lock.json | 48 +- 44 files changed, 2791 insertions(+), 49 deletions(-) create mode 100644 src/WinQuickLook.App.WinUI/App.xaml create mode 100644 src/WinQuickLook.App.WinUI/App.xaml.cs create mode 100644 src/WinQuickLook.App.WinUI/AppParameters.cs create mode 100644 src/WinQuickLook.App.WinUI/NativeNotifyIcon.cs create mode 100644 src/WinQuickLook.App.WinUI/PreviewWindow.xaml create mode 100644 src/WinQuickLook.App.WinUI/PreviewWindow.xaml.cs create mode 100644 src/WinQuickLook.App.WinUI/WinQuickLook.App.WinUI.csproj create mode 100644 src/WinQuickLook.App.WinUI/app.manifest create mode 100644 src/WinQuickLook.App.WinUI/packages.lock.json create mode 100644 src/WinQuickLook.Preview/AggregatePreviewProvider.cs create mode 100644 src/WinQuickLook.Preview/FileSystemPreviewPayload.cs create mode 100644 src/WinQuickLook.Preview/GenericPreviewProvider.cs create mode 100644 src/WinQuickLook.Preview/IPreviewProvider.cs create mode 100644 src/WinQuickLook.Preview/ImagePreviewPayload.cs create mode 100644 src/WinQuickLook.Preview/ImagePreviewProvider.cs create mode 100644 src/WinQuickLook.Preview/MarkdownPreviewProvider.cs create mode 100644 src/WinQuickLook.Preview/MediaPreviewPayload.cs create mode 100644 src/WinQuickLook.Preview/MediaPreviewProvider.cs create mode 100644 src/WinQuickLook.Preview/PreviewKind.cs create mode 100644 src/WinQuickLook.Preview/PreviewRequest.cs create mode 100644 src/WinQuickLook.Preview/PreviewRequestFactory.cs create mode 100644 src/WinQuickLook.Preview/PreviewResult.cs create mode 100644 src/WinQuickLook.Preview/TextPreviewPayload.cs create mode 100644 src/WinQuickLook.Preview/TextPreviewProvider.cs create mode 100644 src/WinQuickLook.Preview/WebDocumentKind.cs create mode 100644 src/WinQuickLook.Preview/WebDocumentPreviewPayload.cs create mode 100644 src/WinQuickLook.Preview/WebDocumentPreviewProvider.cs create mode 100644 src/WinQuickLook.Preview/WinQuickLook.Preview.csproj create mode 100644 src/WinQuickLook.Preview/packages.lock.json create mode 100644 src/WinQuickLook.Shell/Messaging/LowLevelKeyboardHook.cs create mode 100644 src/WinQuickLook.Shell/Messaging/WindowsHook.cs create mode 100644 src/WinQuickLook.Shell/Providers/ShellExplorerProvider.cs create mode 100644 src/WinQuickLook.Shell/WinQuickLook.Shell.csproj create mode 100644 src/WinQuickLook.Shell/packages.lock.json create mode 100644 tests/WinQuickLook.Core.Tests/Preview/ImagePreviewProviderTests.cs diff --git a/README.md b/README.md index 334d555d..abb78e60 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,22 @@ winget install WinQuickLook --source msstore That's it! WinQuickLook will run in the background, ready whenever you need it. +## 🛠️ Development + +The repository keeps the current WPF app and the Windows App SDK / WinUI 3 app separated: + +- `src/WinQuickLook.App` - WPF app entry point and WPF preview UI. +- `src/WinQuickLook.Core` - WPF app preview handlers and shared WPF-era services. +- `src/WinQuickLook.App.WinUI` - WinUI app entry point, tray integration, Explorer selection, and WinUI preview window. +- `src/WinQuickLook.Preview` - UI-independent preview provider layer used by the WinUI app. +- `src/WinQuickLook.Shell` - WPF-free shell integration used by the WinUI app. + +Run the WinUI 3 app during vNext development: + +```bash +dotnet run --project src/WinQuickLook.App.WinUI/WinQuickLook.App.WinUI.csproj -p:Platform=x64 +``` + ## 💡 Use Cases - **Quick Document Review** - Scan through multiple documents without opening Word, PDF readers, etc. diff --git a/WinQuickLook.slnx b/WinQuickLook.slnx index 5cb84720..04782237 100644 --- a/WinQuickLook.slnx +++ b/WinQuickLook.slnx @@ -8,10 +8,22 @@ + + + + + + + + + + + + diff --git a/src/WinQuickLook.App.WinUI/App.xaml b/src/WinQuickLook.App.WinUI/App.xaml new file mode 100644 index 00000000..bff833eb --- /dev/null +++ b/src/WinQuickLook.App.WinUI/App.xaml @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/src/WinQuickLook.App.WinUI/App.xaml.cs b/src/WinQuickLook.App.WinUI/App.xaml.cs new file mode 100644 index 00000000..0681cc6c --- /dev/null +++ b/src/WinQuickLook.App.WinUI/App.xaml.cs @@ -0,0 +1,155 @@ +using System; +using System.IO; +using System.Threading; + +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; + +using Windows.Win32.UI.Input.KeyboardAndMouse; + +using WinQuickLook.Messaging; +using WinQuickLook.Preview; +using WinQuickLook.Providers; + +using WinRT.Interop; + +namespace WinQuickLook.App.WinUI; + +public partial class App +{ + private readonly Mutex _mutex = new(false, AppParameters.Title); + private readonly LowLevelKeyboardHook _keyboardHook = new(); + private readonly ShellExplorerProvider _shellExplorerProvider = new(); + private readonly IPreviewProvider _previewProvider = AggregatePreviewProvider.CreateDefault(); + private readonly DispatcherQueue _dispatcherQueue; + + private Window? _messageWindow; + private PreviewWindow? _previewWindow; + private NativeNotifyIcon? _notifyIcon; + private bool _mutexAcquired; + private bool _disposed; + + public App() + { + InitializeComponent(); + + _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + AppDomain.CurrentDomain.ProcessExit += (_, _) => DisposeServices(); + } + + protected override void OnLaunched(LaunchActivatedEventArgs args) + { + if (!_mutex.WaitOne(0, false)) + { + Exit(); + + return; + } + + _mutexAcquired = true; + + ConfigureNotifyIcon(); + ConfigureKeyboardHook(); + } + + private void ConfigureNotifyIcon() + { + _messageWindow = new Window(); + _messageWindow.Activate(); + NativeNotifyIcon.HideWindow(WindowNative.GetWindowHandle(_messageWindow)); + + _notifyIcon = new NativeNotifyIcon( + WindowNative.GetWindowHandle(_messageWindow), + AppParameters.Title, + () => _dispatcherQueue.TryEnqueue(PerformPreview), + () => _dispatcherQueue.TryEnqueue(ExitApplication)); + } + + private void ConfigureKeyboardHook() + { + _keyboardHook.PerformKeyDown = vkCode => + { + switch (vkCode) + { + case VIRTUAL_KEY.VK_SPACE: + _dispatcherQueue.TryEnqueue(PerformPreview); + break; + case VIRTUAL_KEY.VK_ESCAPE: + _dispatcherQueue.TryEnqueue(ClosePreview); + break; + } + }; + + _keyboardHook.Start(); + } + + private void PerformPreview() + { + if (_previewWindow is not null) + { + ClosePreview(); + + return; + } + + var fileSystemInfo = _shellExplorerProvider.GetSelectedItem(); + + if (fileSystemInfo is null || !_previewProvider.CanPreview(fileSystemInfo)) + { + return; + } + + var request = fileSystemInfo is FileInfo fileInfo + ? PreviewRequestFactory.CreateWithDirectorySiblings(fileInfo.FullName, _previewProvider) + : PreviewRequestFactory.Create(fileSystemInfo.FullName); + + _previewWindow = new PreviewWindow(_previewProvider, request); + _previewWindow.Closed += (_, _) => _previewWindow = null; + _previewWindow.Activate(); + } + + private void ClosePreview() + { + var previewWindow = _previewWindow; + + if (previewWindow is null) + { + return; + } + + _previewWindow = null; + previewWindow.Close(); + } + + private void ExitApplication() + { + DisposeServices(); + Exit(); + } + + private void DisposeServices() + { + if (_disposed) + { + return; + } + + ClosePreview(); + _keyboardHook.Dispose(); + + if (_notifyIcon is not null) + { + _notifyIcon.Dispose(); + _notifyIcon = null; + } + + if (_mutexAcquired) + { + _mutex.ReleaseMutex(); + _mutexAcquired = false; + } + + _mutex.Dispose(); + _disposed = true; + } +} diff --git a/src/WinQuickLook.App.WinUI/AppParameters.cs b/src/WinQuickLook.App.WinUI/AppParameters.cs new file mode 100644 index 00000000..03e9c474 --- /dev/null +++ b/src/WinQuickLook.App.WinUI/AppParameters.cs @@ -0,0 +1,6 @@ +namespace WinQuickLook.App.WinUI; + +public static class AppParameters +{ + public const string Title = "WinQuickLook"; +} diff --git a/src/WinQuickLook.App.WinUI/NativeNotifyIcon.cs b/src/WinQuickLook.App.WinUI/NativeNotifyIcon.cs new file mode 100644 index 00000000..30266e14 --- /dev/null +++ b/src/WinQuickLook.App.WinUI/NativeNotifyIcon.cs @@ -0,0 +1,242 @@ +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; + +namespace WinQuickLook.App.WinUI; + +internal sealed class NativeNotifyIcon : IDisposable +{ + private const uint CallbackMessage = 0x8000 + 1; + private const uint IconId = 1; + private const uint PreviewCommandId = 100; + private const uint ExitCommandId = 101; + + private const uint NIM_ADD = 0x00000000; + private const uint NIM_DELETE = 0x00000002; + private const uint NIM_SETVERSION = 0x00000004; + private const uint NIF_MESSAGE = 0x00000001; + private const uint NIF_ICON = 0x00000002; + private const uint NIF_TIP = 0x00000004; + private const uint NOTIFYICON_VERSION_4 = 4; + private const uint WM_LBUTTONDBLCLK = 0x0203; + private const uint WM_RBUTTONUP = 0x0205; + private const int GWLP_WNDPROC = -4; + private const uint MF_STRING = 0x00000000; + private const uint MF_SEPARATOR = 0x00000800; + private const uint TPM_RETURNCMD = 0x00000100; + private const uint TPM_RIGHTBUTTON = 0x00000002; + private const int SW_HIDE = 0; + + private readonly nint _hwnd; + private readonly Action _previewSelected; + private readonly Action _exitSelected; + private readonly WndProc _wndProc; + private readonly nint _oldWndProc; + private readonly nint _icon; + private readonly bool _destroyIcon; + + private bool _disposed; + + public NativeNotifyIcon(nint hwnd, string tooltip, Action previewSelected, Action exitSelected) + { + _hwnd = hwnd; + _previewSelected = previewSelected; + _exitSelected = exitSelected; + _wndProc = WndProcCore; + _oldWndProc = SetWindowLongPtr(_hwnd, GWLP_WNDPROC, Marshal.GetFunctionPointerForDelegate(_wndProc)); + _icon = ExtractIcon(nint.Zero, Environment.ProcessPath!, 0); + + if (_icon == nint.Zero) + { + _icon = LoadIcon(nint.Zero, new IntPtr(32512)); + } + else + { + _destroyIcon = true; + } + + var data = CreateNotifyIconData(tooltip); + + if (!Shell_NotifyIcon(NIM_ADD, ref data)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + data.uVersion = NOTIFYICON_VERSION_4; + Shell_NotifyIcon(NIM_SETVERSION, ref data); + } + + public static void HideWindow(nint hwnd) + { + ShowWindow(hwnd, SW_HIDE); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + var data = CreateNotifyIconData(string.Empty); + + Shell_NotifyIcon(NIM_DELETE, ref data); + SetWindowLongPtr(_hwnd, GWLP_WNDPROC, _oldWndProc); + if (_destroyIcon) + { + DestroyIcon(_icon); + } + + _disposed = true; + } + + private NotifyIconData CreateNotifyIconData(string tooltip) + { + return new NotifyIconData + { + cbSize = Marshal.SizeOf(), + hWnd = _hwnd, + uID = IconId, + uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP, + uCallbackMessage = CallbackMessage, + hIcon = _icon, + szTip = tooltip, + szInfo = string.Empty, + szInfoTitle = string.Empty + }; + } + + private nint WndProcCore(nint hwnd, uint message, nuint wParam, nint lParam) + { + if (message == CallbackMessage) + { + switch ((uint)lParam.ToInt64() & 0xFFFF) + { + case WM_LBUTTONDBLCLK: + _previewSelected(); + return 0; + case WM_RBUTTONUP: + ShowContextMenu(); + return 0; + } + } + + return CallWindowProc(_oldWndProc, hwnd, message, wParam, lParam); + } + + private void ShowContextMenu() + { + if (!GetCursorPos(out var point)) + { + return; + } + + var menu = CreatePopupMenu(); + + if (menu == nint.Zero) + { + return; + } + + try + { + AppendMenu(menu, MF_STRING, PreviewCommandId, "Preview selected"); + AppendMenu(menu, MF_SEPARATOR, 0, null); + AppendMenu(menu, MF_STRING, ExitCommandId, "Exit"); + SetForegroundWindow(_hwnd); + + var command = TrackPopupMenu(menu, TPM_RETURNCMD | TPM_RIGHTBUTTON, point.X, point.Y, 0, _hwnd, nint.Zero); + + switch (command) + { + case PreviewCommandId: + _previewSelected(); + break; + case ExitCommandId: + _exitSelected(); + break; + } + } + finally + { + DestroyMenu(menu); + } + } + + [DllImport("shell32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool Shell_NotifyIcon(uint dwMessage, ref NotifyIconData lpData); + + [DllImport("user32.dll", EntryPoint = "SetWindowLongPtrW", SetLastError = true)] + private static extern nint SetWindowLongPtr(nint hWnd, int nIndex, nint dwNewLong); + + [DllImport("user32.dll")] + private static extern nint CallWindowProc(nint lpPrevWndFunc, nint hWnd, uint msg, nuint wParam, nint lParam); + + [DllImport("user32.dll", SetLastError = true)] + private static extern nint CreatePopupMenu(); + + [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool AppendMenu(nint hMenu, uint uFlags, uint uIDNewItem, string? lpNewItem); + + [DllImport("user32.dll", SetLastError = true)] + private static extern uint TrackPopupMenu(nint hMenu, uint uFlags, int x, int y, int nReserved, nint hWnd, nint prcRect); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool DestroyMenu(nint hMenu); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool GetCursorPos(out Point point); + + [DllImport("user32.dll")] + private static extern bool SetForegroundWindow(nint hWnd); + + [DllImport("user32.dll")] + private static extern bool ShowWindow(nint hWnd, int nCmdShow); + + [DllImport("shell32.dll", CharSet = CharSet.Unicode)] + private static extern nint ExtractIcon(nint hInst, string lpszExeFileName, uint nIconIndex); + + [DllImport("user32.dll")] + private static extern nint LoadIcon(nint hInstance, nint lpIconName); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool DestroyIcon(nint hIcon); + + private delegate nint WndProc(nint hwnd, uint message, nuint wParam, nint lParam); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct NotifyIconData + { + public int cbSize; + public nint hWnd; + public uint uID; + public uint uFlags; + public uint uCallbackMessage; + public nint hIcon; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] + public string szTip; + + public uint dwState; + public uint dwStateMask; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] + public string szInfo; + + public uint uVersion; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)] + public string szInfoTitle; + + public uint dwInfoFlags; + public Guid guidItem; + public nint hBalloonIcon; + } + + [StructLayout(LayoutKind.Sequential)] + private struct Point + { + public int X; + public int Y; + } +} diff --git a/src/WinQuickLook.App.WinUI/PreviewWindow.xaml b/src/WinQuickLook.App.WinUI/PreviewWindow.xaml new file mode 100644 index 00000000..5b69d017 --- /dev/null +++ b/src/WinQuickLook.App.WinUI/PreviewWindow.xaml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/WinQuickLook.App.WinUI/PreviewWindow.xaml.cs b/src/WinQuickLook.App.WinUI/PreviewWindow.xaml.cs new file mode 100644 index 00000000..d4352a6e --- /dev/null +++ b/src/WinQuickLook.App.WinUI/PreviewWindow.xaml.cs @@ -0,0 +1,781 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +using Microsoft.UI; +using Microsoft.UI.Composition.SystemBackdrops; +using Microsoft.UI.Windowing; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Animation; +using Microsoft.UI.Xaml.Media.Imaging; + +using Windows.Graphics; +using Windows.Media.Core; +using Windows.Storage; +using Windows.System; + +using WinQuickLook.Preview; + +namespace WinQuickLook.App.WinUI; + +public sealed partial class PreviewWindow +{ + private static readonly TimeSpan OpenAnimationDuration = TimeSpan.FromMilliseconds(180); + private static readonly TimeSpan CrossfadeDuration = TimeSpan.FromMilliseconds(150); + private static readonly TimeSpan WindowResizeDuration = TimeSpan.FromMilliseconds(180); + private static readonly TimeSpan WindowAnimationFrameDelay = TimeSpan.FromMilliseconds(16); + private static readonly SizeInt32 FallbackWindowSize = new(760, 520); + + private readonly IPreviewProvider _previewProvider; + private readonly IReadOnlyList _filePaths; + + private int _currentIndex; + private bool _isPrimaryContentActive = true; + private bool _isSwitching; + + public PreviewWindow(IPreviewProvider previewProvider, PreviewRequest request) + { + InitializeComponent(); + + _previewProvider = previewProvider; + _filePaths = NormalizeFilePaths(request); + _currentIndex = Math.Clamp(request.CurrentIndex, 0, Math.Max(_filePaths.Count - 1, 0)); + + ConfigureWindow(); + + Root.Loaded += Root_Loaded; + } + + private async void Root_Loaded(object sender, RoutedEventArgs e) + { + Root.Focus(FocusState.Programmatic); + + await ShowCurrentAsync(false); + await PlayOpenAnimationAsync(); + } + + private void ConfigureWindow() + { + Title = "WinQuickLook Preview"; + SystemBackdrop = new MicaBackdrop { Kind = MicaKind.BaseAlt }; + ExtendsContentIntoTitleBar = true; + SetTitleBar(DragRegion); + + AppWindow.Title = Title; + AppWindow.IsShownInSwitchers = false; + AppWindow.Resize(FallbackWindowSize); + + if (AppWindow.Presenter is OverlappedPresenter presenter) + { + presenter.IsMaximizable = false; + presenter.IsMinimizable = false; + presenter.IsResizable = true; + presenter.SetBorderAndTitleBar(true, false); + } + + AppWindow.MoveAndResize(GetCenteredBounds(FallbackWindowSize)); + } + + private async Task ShowCurrentAsync(bool crossfade) + { + if (_filePaths.Count == 0) + { + ShowUnsupported("No file was provided."); + + return; + } + + var request = new PreviewRequest(_filePaths[_currentIndex], _filePaths, _currentIndex); + + if (!_previewProvider.TryCreatePreview(request, out var result)) + { + ShowUnsupported("This file is not supported by the WinUI preview yet."); + + return; + } + + try + { + var renderedPreview = await CreateRenderedPreviewAsync(result); + + UpdateTitle(result, result.FilePath); + + if (crossfade) + { + await Task.WhenAll( + AnimateWindowBoundsAsync(renderedPreview.WindowBounds, WindowResizeDuration), + CrossfadeToAsync(renderedPreview.Content)); + } + else + { + PrimaryContent.Content = renderedPreview.Content; + PrimaryContent.Opacity = 1; + PrimaryContent.Visibility = Visibility.Visible; + SecondaryContent.Content = null; + SecondaryContent.Opacity = 0; + SecondaryContent.Visibility = Visibility.Collapsed; + _isPrimaryContentActive = true; + + await AnimateWindowBoundsAsync(renderedPreview.WindowBounds, WindowResizeDuration); + } + } + catch (Exception ex) when (ex is FileNotFoundException or UnauthorizedAccessException or IOException or ArgumentException) + { + ShowUnsupported("The image could not be opened."); + } + } + + private async Task CreateRenderedPreviewAsync(PreviewResult result) + { + switch (result.Payload) + { + case ImagePreviewPayload imagePreviewPayload: + var image = await LoadImageAsync(imagePreviewPayload.FilePath); + + return new RenderedPreview(CreateImageView(image), GetWindowBoundsForImage(image.PixelWidth, image.PixelHeight)); + case TextPreviewPayload textPreviewPayload: + return new RenderedPreview(CreateTextView(textPreviewPayload), GetWindowBoundsForText(textPreviewPayload)); + case WebDocumentPreviewPayload webDocumentPreviewPayload: + return new RenderedPreview(CreateWebDocumentView(webDocumentPreviewPayload), GetWindowBoundsForDocument()); + case MediaPreviewPayload mediaPreviewPayload: + return new RenderedPreview(await CreateMediaViewAsync(mediaPreviewPayload), await GetWindowBoundsForMediaAsync(mediaPreviewPayload)); + case FileSystemPreviewPayload fileSystemPreviewPayload: + return new RenderedPreview(CreateFileSystemView(fileSystemPreviewPayload), GetWindowBoundsForGenericPreview()); + default: + return new RenderedPreview( + CreateUnsupportedView("This file is not supported by the WinUI preview yet."), + GetCenteredBounds(FallbackWindowSize)); + } + } + + private async Task LoadImageAsync(string filePath) + { + var file = await StorageFile.GetFileFromPathAsync(filePath); + var properties = await file.Properties.GetImagePropertiesAsync(); + var decodeSize = GetDecodeSize(properties.Width, properties.Height); + + var bitmapImage = new BitmapImage + { + DecodePixelWidth = decodeSize.Width, + DecodePixelHeight = decodeSize.Height + }; + + using var stream = await file.OpenReadAsync(); + await bitmapImage.SetSourceAsync(stream); + + return bitmapImage; + } + + private async Task CrossfadeToAsync(UIElement content) + { + var incoming = _isPrimaryContentActive ? SecondaryContent : PrimaryContent; + var outgoing = _isPrimaryContentActive ? PrimaryContent : SecondaryContent; + + incoming.Content = content; + incoming.Opacity = 0; + incoming.Visibility = Visibility.Visible; + + await Task.WhenAll( + AnimateOpacityAsync(outgoing, 0), + AnimateOpacityAsync(incoming, 1)); + + outgoing.Content = null; + outgoing.Visibility = Visibility.Collapsed; + _isPrimaryContentActive = !_isPrimaryContentActive; + } + + private void ShowUnsupported(string message) + { + AppWindow.MoveAndResize(GetCenteredBounds(FallbackWindowSize)); + + TitleText.Text = "Unsupported preview"; + PathText.Text = _filePaths.Count == 0 ? string.Empty : _filePaths[_currentIndex]; + CounterText.Text = string.Empty; + PrimaryContent.Content = CreateUnsupportedView(message); + PrimaryContent.Opacity = 1; + PrimaryContent.Visibility = Visibility.Visible; + SecondaryContent.Content = null; + SecondaryContent.Opacity = 0; + SecondaryContent.Visibility = Visibility.Collapsed; + _isPrimaryContentActive = true; + } + + private void UpdateTitle(PreviewResult result, string filePath) + { + var title = result.Title ?? Path.GetFileName(filePath); + + Title = title; + AppWindow.Title = title; + TitleText.Text = title; + PathText.Text = filePath; + CounterText.Text = _filePaths.Count > 1 ? $"{_currentIndex + 1} / {_filePaths.Count}" : string.Empty; + } + + private async void Root_KeyDown(object sender, KeyRoutedEventArgs e) + { + switch (e.Key) + { + case VirtualKey.Escape: + case VirtualKey.Space: + e.Handled = true; + Close(); + break; + case VirtualKey.Left: + e.Handled = true; + await NavigateAsync(-1); + break; + case VirtualKey.Right: + e.Handled = true; + await NavigateAsync(1); + break; + } + } + + private async Task NavigateAsync(int offset) + { + if (_isSwitching) + { + return; + } + + var nextIndex = _currentIndex + offset; + + if (nextIndex < 0 || nextIndex >= _filePaths.Count) + { + return; + } + + _isSwitching = true; + + try + { + _currentIndex = nextIndex; + await ShowCurrentAsync(true); + } + finally + { + _isSwitching = false; + } + } + + private void CloseButton_Click(object sender, RoutedEventArgs e) + { + Close(); + } + + private async Task PlayOpenAnimationAsync() + { + await Task.WhenAll( + AnimateOpacityAsync(PreviewSurface, 1, OpenAnimationDuration), + AnimateScaleAsync(1, OpenAnimationDuration)); + } + + private static Task AnimateOpacityAsync(UIElement target, double to) + { + return AnimateOpacityAsync(target, to, CrossfadeDuration); + } + + private static Task AnimateOpacityAsync(UIElement target, double to, TimeSpan duration) + { + var storyboard = new Storyboard(); + var animation = new DoubleAnimation + { + To = to, + Duration = duration, + EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } + }; + + Storyboard.SetTarget(animation, target); + Storyboard.SetTargetProperty(animation, nameof(UIElement.Opacity)); + storyboard.Children.Add(animation); + + return BeginStoryboardAsync(storyboard); + } + + private Task AnimateScaleAsync(double to, TimeSpan duration) + { + var storyboard = new Storyboard(); + + foreach (var propertyName in new[] { nameof(ScaleTransform.ScaleX), nameof(ScaleTransform.ScaleY) }) + { + var animation = new DoubleAnimation + { + To = to, + Duration = duration, + EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } + }; + + Storyboard.SetTarget(animation, PreviewSurfaceScale); + Storyboard.SetTargetProperty(animation, propertyName); + storyboard.Children.Add(animation); + } + + return BeginStoryboardAsync(storyboard); + } + + private static Task BeginStoryboardAsync(Storyboard storyboard) + { + var completion = new TaskCompletionSource(); + + storyboard.Completed += (_, _) => completion.TrySetResult(); + storyboard.Begin(); + + return completion.Task; + } + + private static UIElement CreateImageView(BitmapImage image) + { + return new Image + { + Source = image, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Stretch = Stretch.Uniform + }; + } + + private static UIElement CreateTextView(TextPreviewPayload payload) + { + var text = payload.IsTruncated + ? payload.Text + Environment.NewLine + Environment.NewLine + "[Preview truncated at 1 MB]" + : payload.Text; + var fontFamily = new FontFamily("Cascadia Mono, Consolas"); + var lineCount = CountLines(text); + var grid = new Grid + { + ColumnSpacing = 14 + }; + + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + + var lineNumbers = new TextBlock + { + Text = string.Join(Environment.NewLine, Enumerable.Range(1, lineCount)), + FontFamily = fontFamily, + FontSize = 13, + IsHitTestVisible = false, + Opacity = 0.42, + TextAlignment = TextAlignment.Right + }; + var textBlock = new TextBlock + { + Text = text, + FontFamily = fontFamily, + FontSize = 13, + IsTextSelectionEnabled = true, + TextWrapping = TextWrapping.NoWrap + }; + + Grid.SetColumn(textBlock, 1); + grid.Children.Add(lineNumbers); + grid.Children.Add(textBlock); + + return new ScrollViewer + { + HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + Padding = new Thickness(10), + Content = grid + }; + } + + private static UIElement CreateUnsupportedView(string message) + { + var stackPanel = new StackPanel + { + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Spacing = 8 + }; + + stackPanel.Children.Add(new FontIcon + { + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 32, + Glyph = "\uE8A5", + Opacity = 0.7 + }); + stackPanel.Children.Add(new TextBlock + { + Text = message, + FontSize = 14, + Opacity = 0.82, + TextAlignment = TextAlignment.Center + }); + + return stackPanel; + } + + private static UIElement CreateWebDocumentView(WebDocumentPreviewPayload payload) + { + var webView = new WebView2 + { + DefaultBackgroundColor = Microsoft.UI.Colors.Transparent + }; + + if (payload.Html is not null) + { + webView.Loaded += async (_, _) => + { + await webView.EnsureCoreWebView2Async(); + webView.CoreWebView2.NavigateToString(payload.Html); + }; + } + else + { + webView.Source = new Uri(payload.FilePath); + } + + return webView; + } + + private static async Task CreateMediaViewAsync(MediaPreviewPayload payload) + { + var mediaPlayerElement = new MediaPlayerElement + { + AreTransportControlsEnabled = true, + AutoPlay = true, + Source = MediaSource.CreateFromUri(new Uri(payload.FilePath)), + Stretch = Stretch.Uniform + }; + + if (payload.HasVideo) + { + return mediaPlayerElement; + } + + var file = await StorageFile.GetFileFromPathAsync(payload.FilePath); + var properties = await file.Properties.GetMusicPropertiesAsync(); + var title = string.IsNullOrWhiteSpace(properties.Title) ? Path.GetFileName(payload.FilePath) : properties.Title; + var artist = string.IsNullOrWhiteSpace(properties.Artist) ? "Unknown artist" : properties.Artist; + var album = string.IsNullOrWhiteSpace(properties.Album) ? string.Empty : properties.Album; + var grid = new Grid + { + Padding = new Thickness(28) + }; + + grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); + grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + + var stackPanel = new StackPanel + { + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Spacing = 10 + }; + + stackPanel.Children.Add(new FontIcon + { + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 54, + Glyph = "\uE8D6", + Opacity = 0.72 + }); + stackPanel.Children.Add(new TextBlock + { + Text = title, + FontSize = 24, + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + TextAlignment = TextAlignment.Center, + TextWrapping = TextWrapping.Wrap + }); + stackPanel.Children.Add(new TextBlock + { + Text = string.IsNullOrWhiteSpace(album) ? artist : $"{artist} · {album}", + FontSize = 14, + Opacity = 0.72, + TextAlignment = TextAlignment.Center, + TextWrapping = TextWrapping.Wrap + }); + + Grid.SetRow(mediaPlayerElement, 1); + grid.Children.Add(stackPanel); + grid.Children.Add(mediaPlayerElement); + + return grid; + } + + private static UIElement CreateFileSystemView(FileSystemPreviewPayload payload) + { + var grid = new Grid + { + Padding = new Thickness(28), + ColumnSpacing = 24 + }; + + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(0.42, GridUnitType.Star) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(0.58, GridUnitType.Star) }); + + var icon = new FontIcon + { + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 96, + Glyph = payload.IsDirectory ? "\uE8B7" : "\uE7C3", + Opacity = 0.72, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + }; + var stackPanel = new StackPanel + { + VerticalAlignment = VerticalAlignment.Center, + Spacing = 12 + }; + + stackPanel.Children.Add(new TextBlock + { + Text = Path.GetFileName(payload.Path), + FontSize = 24, + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + TextWrapping = TextWrapping.Wrap + }); + stackPanel.Children.Add(CreateMetadataText(payload.TypeName)); + stackPanel.Children.Add(CreateMetadataText(payload.Detail)); + stackPanel.Children.Add(CreateMetadataText(payload.LastWriteTime.ToString("G"))); + + Grid.SetColumn(stackPanel, 1); + grid.Children.Add(icon); + grid.Children.Add(stackPanel); + + return grid; + } + + private static TextBlock CreateMetadataText(string text) + { + return new TextBlock + { + Text = text, + FontSize = 15, + Opacity = 0.76, + TextWrapping = TextWrapping.Wrap + }; + } + + private RectInt32 GetWindowBoundsForImage(int pixelWidth, int pixelHeight) + { + if (pixelWidth <= 0 || pixelHeight <= 0) + { + return GetCenteredBounds(FallbackWindowSize); + } + + var scale = Root.XamlRoot?.RasterizationScale ?? 1.0; + var displayArea = DisplayArea.GetFromWindowId(AppWindow.Id, DisplayAreaFallback.Primary); + var maxWidth = Math.Min(displayArea.WorkArea.Width / scale * 0.82, 1220); + var maxHeight = Math.Min(displayArea.WorkArea.Height / scale * 0.82, 840); + var maxImageWidth = Math.Max(maxWidth - 68, 360); + var maxImageHeight = Math.Max(maxHeight - 118, 260); + var imageScale = Math.Min(Math.Min(maxImageWidth / pixelWidth, maxImageHeight / pixelHeight), 1.0); + var contentWidth = Math.Round(pixelWidth * imageScale); + var contentHeight = Math.Round(pixelHeight * imageScale); + var windowWidth = Math.Clamp(contentWidth + 68, 500, maxWidth); + var windowHeight = Math.Clamp(contentHeight + 118, 360, maxHeight); + var windowSize = new SizeInt32( + (int)Math.Round(windowWidth * scale), + (int)Math.Round(windowHeight * scale)); + + return GetCenteredBounds(windowSize); + } + + private RectInt32 GetWindowBoundsForDocument() + { + return GetCenteredBounds(GetConstrainedWindowSize(980, 720)); + } + + private RectInt32 GetWindowBoundsForGenericPreview() + { + return GetCenteredBounds(GetConstrainedWindowSize(640, 380)); + } + + private async Task GetWindowBoundsForMediaAsync(MediaPreviewPayload payload) + { + if (!payload.HasVideo) + { + return GetCenteredBounds(GetConstrainedWindowSize(700, 420)); + } + + try + { + var file = await StorageFile.GetFileFromPathAsync(payload.FilePath); + var properties = await file.Properties.GetVideoPropertiesAsync(); + + if (properties.Width > 0 && properties.Height > 0) + { + return GetWindowBoundsForImage((int)properties.Width, (int)properties.Height); + } + } + catch (FileNotFoundException) + { + } + catch (UnauthorizedAccessException) + { + } + + return GetCenteredBounds(GetConstrainedWindowSize(960, 620)); + } + + private SizeInt32 GetConstrainedWindowSize(double requestedWidth, double requestedHeight) + { + var scale = Root.XamlRoot?.RasterizationScale ?? 1.0; + var displayArea = DisplayArea.GetFromWindowId(AppWindow.Id, DisplayAreaFallback.Primary); + var maxWidth = displayArea.WorkArea.Width / scale * 0.82; + var maxHeight = displayArea.WorkArea.Height / scale * 0.82; + + return new SizeInt32( + (int)Math.Round(Math.Min(requestedWidth, maxWidth) * scale), + (int)Math.Round(Math.Min(requestedHeight, maxHeight) * scale)); + } + + private RectInt32 GetWindowBoundsForText(TextPreviewPayload payload) + { + var scale = Root.XamlRoot?.RasterizationScale ?? 1.0; + var displayArea = DisplayArea.GetFromWindowId(AppWindow.Id, DisplayAreaFallback.Primary); + var maxWidth = Math.Min(displayArea.WorkArea.Width / scale * 0.82, 1180); + var maxHeight = Math.Min(displayArea.WorkArea.Height / scale * 0.82, 820); + var lineCount = CountLines(payload.Text); + var longestLineLength = GetLongestLineLength(payload.Text); + var windowWidth = Math.Clamp((longestLineLength * 7.4) + 190, 720, maxWidth); + var windowHeight = Math.Clamp((Math.Min(lineCount, 34) * 19.0) + 132, 500, maxHeight); + var windowSize = new SizeInt32( + (int)Math.Round(windowWidth * scale), + (int)Math.Round(windowHeight * scale)); + + return GetCenteredBounds(windowSize); + } + + private RectInt32 GetCenteredBounds(SizeInt32 windowSize) + { + var displayArea = DisplayArea.GetFromWindowId(AppWindow.Id, DisplayAreaFallback.Primary); + var workArea = displayArea.WorkArea; + var x = workArea.X + ((workArea.Width - windowSize.Width) / 2); + var y = workArea.Y + ((workArea.Height - windowSize.Height) / 2); + + return new RectInt32(x, y, windowSize.Width, windowSize.Height); + } + + private async Task AnimateWindowBoundsAsync(RectInt32 targetBounds, TimeSpan duration) + { + var startPosition = AppWindow.Position; + var startSize = AppWindow.Size; + var startBounds = new RectInt32(startPosition.X, startPosition.Y, startSize.Width, startSize.Height); + + if (duration <= TimeSpan.Zero || AreBoundsEqual(startBounds, targetBounds)) + { + AppWindow.MoveAndResize(targetBounds); + + return; + } + + var stopwatch = Stopwatch.StartNew(); + + while (true) + { + var progress = Math.Min(stopwatch.Elapsed.TotalMilliseconds / duration.TotalMilliseconds, 1.0); + var easedProgress = EaseOutCubic(progress); + + AppWindow.MoveAndResize(InterpolateBounds(startBounds, targetBounds, easedProgress)); + + if (progress >= 1.0) + { + break; + } + + await Task.Delay(WindowAnimationFrameDelay); + } + + AppWindow.MoveAndResize(targetBounds); + } + + private static RectInt32 InterpolateBounds(RectInt32 from, RectInt32 to, double progress) + { + return new RectInt32( + Interpolate(from.X, to.X, progress), + Interpolate(from.Y, to.Y, progress), + Interpolate(from.Width, to.Width, progress), + Interpolate(from.Height, to.Height, progress)); + } + + private static int Interpolate(int from, int to, double progress) + { + return (int)Math.Round(from + ((to - from) * progress)); + } + + private static double EaseOutCubic(double progress) + { + return 1.0 - Math.Pow(1.0 - progress, 3); + } + + private static bool AreBoundsEqual(RectInt32 left, RectInt32 right) + { + return left.X == right.X + && left.Y == right.Y + && left.Width == right.Width + && left.Height == right.Height; + } + + private static int CountLines(string text) + { + return string.IsNullOrEmpty(text) ? 1 : text.Count(x => x == '\n') + 1; + } + + private static int GetLongestLineLength(string text) + { + var longestLineLength = 0; + var currentLineLength = 0; + + foreach (var character in text) + { + if (character == '\r') + { + continue; + } + + if (character == '\n') + { + longestLineLength = Math.Max(longestLineLength, currentLineLength); + currentLineLength = 0; + + continue; + } + + currentLineLength++; + } + + return Math.Max(longestLineLength, currentLineLength); + } + + private static SizeInt32 GetDecodeSize(uint pixelWidth, uint pixelHeight) + { + const double maxDecodedPixels = 1800; + + if (pixelWidth == 0 || pixelHeight == 0) + { + return new SizeInt32(0, 0); + } + + var scale = Math.Min(Math.Min(maxDecodedPixels / pixelWidth, maxDecodedPixels / pixelHeight), 1.0); + + return new SizeInt32( + Math.Max(1, (int)Math.Round(pixelWidth * scale)), + Math.Max(1, (int)Math.Round(pixelHeight * scale))); + } + + private static IReadOnlyList NormalizeFilePaths(PreviewRequest request) + { + if (request.SiblingFilePaths is { Count: > 0 }) + { + return request.SiblingFilePaths; + } + + return string.IsNullOrWhiteSpace(request.FilePath) + ? [] + : [request.FilePath]; + } + + private sealed record RenderedPreview(UIElement Content, RectInt32 WindowBounds); +} diff --git a/src/WinQuickLook.App.WinUI/WinQuickLook.App.WinUI.csproj b/src/WinQuickLook.App.WinUI/WinQuickLook.App.WinUI.csproj new file mode 100644 index 00000000..bbd50ab1 --- /dev/null +++ b/src/WinQuickLook.App.WinUI/WinQuickLook.App.WinUI.csproj @@ -0,0 +1,29 @@ + + + + WinExe + true + WinQuickLook.App + WinQuickLook.App.WinUI + win-x64;win-arm64 + None + app.manifest + ..\WinQuickLook.App\Icon.ico + win-$(Platform).pubxml + + + + + + + + + + + + + + + + + diff --git a/src/WinQuickLook.App.WinUI/app.manifest b/src/WinQuickLook.App.WinUI/app.manifest new file mode 100644 index 00000000..aa36b8c8 --- /dev/null +++ b/src/WinQuickLook.App.WinUI/app.manifest @@ -0,0 +1,15 @@ + + + + + + PerMonitorV2 + true + + + + + + + + diff --git a/src/WinQuickLook.App.WinUI/packages.lock.json b/src/WinQuickLook.App.WinUI/packages.lock.json new file mode 100644 index 00000000..c7169428 --- /dev/null +++ b/src/WinQuickLook.App.WinUI/packages.lock.json @@ -0,0 +1,200 @@ +{ + "version": 1, + "dependencies": { + "net10.0-windows10.0.26100": { + "Microsoft.WindowsAppSDK": { + "type": "Direct", + "requested": "[2.1.3, )", + "resolved": "2.1.3", + "contentHash": "46KCntcmWPS0nbV3a8iEMslpYy0W9wTsmkANrXFxDWuiAnm8jXytFNvwNVN5HfoA3d+Cxb3pO+JE2UNnB5wn7w==", + "dependencies": { + "Microsoft.WindowsAppSDK.AI": "2.1.10", + "Microsoft.WindowsAppSDK.Base": "2.0.4", + "Microsoft.WindowsAppSDK.DWrite": "2.1.0", + "Microsoft.WindowsAppSDK.Foundation": "2.0.21", + "Microsoft.WindowsAppSDK.InteractiveExperiences": "2.0.13", + "Microsoft.WindowsAppSDK.ML": "2.1.1", + "Microsoft.WindowsAppSDK.Runtime": "[2.1.3]", + "Microsoft.WindowsAppSDK.Widgets": "2.0.5", + "Microsoft.WindowsAppSDK.WinUI": "2.1.0" + } + }, + "Markdig": { + "type": "Transitive", + "resolved": "1.2.0", + "contentHash": "LoBVOgeDWfHthWTPzQJkjDkxImqBXRA5XpuTyjdM4SAURyWQGG1CbEEbfPbmvAavggnAZPaTYpbhIPUurWvIXA==" + }, + "Microsoft.Web.WebView2": { + "type": "Transitive", + "resolved": "1.0.3719.77", + "contentHash": "t+ucyKw5NTwMjsUrDF6R9Lk40lpcKQD1/HgyGFxl49tdA4h9dKlsj6FYGEmDRpFNfnTpENuTypMcdbrlkqBdDA==" + }, + "Microsoft.Windows.AI.MachineLearning": { + "type": "Transitive", + "resolved": "2.1.1", + "contentHash": "A5BbYPLVrcaRJvpwx0nv7aMDOMqR20qaw9EaLcUe9pkSArzKoSk6cilRsSioU+eRa9+HahidBZTegawdswKqIQ==", + "dependencies": { + "System.Numerics.Tensors": "9.0.0" + } + }, + "Microsoft.Windows.SDK.BuildTools": { + "type": "Transitive", + "resolved": "10.0.26100.4654", + "contentHash": "2mgcOlj/t2RfSyyw+pVESfO+Tk1RkfQzto9Vrq42M1lUQIfQEwbi8QLha9GXWIOj+TFzeHIEJckIoF25mgiM8A==" + }, + "Microsoft.Windows.SDK.BuildTools.MSIX": { + "type": "Transitive", + "resolved": "1.7.251221100", + "contentHash": "f4aIZJ0NUth2403oxrpR+9rxVzZVI6dabqB21u8ncnk8eJAKCs9m77E4iYAnvP1YwrRe4axz3f8+yUcttNSfEA==" + }, + "Microsoft.WindowsAppSDK.AI": { + "type": "Transitive", + "resolved": "2.1.10", + "contentHash": "4rOxfQbOZkdGK/wzajqyXZeCl9WW1qdtb63hTBFEA7pZ14iyF3iV64VNg6KOXuRRUL64/wyHHsiBwLlvFSFgcw==", + "dependencies": { + "Microsoft.WindowsAppSDK.Base": "2.0.4", + "Microsoft.WindowsAppSDK.Foundation": "2.0.21" + } + }, + "Microsoft.WindowsAppSDK.Base": { + "type": "Transitive", + "resolved": "2.0.4", + "contentHash": "QXSy2llX2/Bx9dYWGUEsb10F40q94ZiDnPMzqa2/qRmTG4y9EXxGp6uHITb7c0b4FCa+6KFBlgh6D53M0QEMEg==", + "dependencies": { + "Microsoft.Windows.SDK.BuildTools": "10.0.26100.4654", + "Microsoft.Windows.SDK.BuildTools.MSIX": "1.7.251221100" + } + }, + "Microsoft.WindowsAppSDK.DWrite": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "dgix7NSeo7Z8SZPyrsOqYm/6OUvBveHCiVyorYecmtDYoZW26dJ6/i9yveIxgWvmFpgfKXPeIuQBCvhbetHiYg==", + "dependencies": { + "Microsoft.WindowsAppSDK.Base": "2.0.3" + } + }, + "Microsoft.WindowsAppSDK.Foundation": { + "type": "Transitive", + "resolved": "2.0.21", + "contentHash": "Wzr4c9dSXXcNQw67wVmy+32FXW6+TLyrQHD5eZXAjNydMsLRMZjUXzD1qQgBg1bdgzjlkYnOyYqoXbOL6lfqkw==", + "dependencies": { + "Microsoft.WindowsAppSDK.Base": "2.0.4", + "Microsoft.WindowsAppSDK.InteractiveExperiences": "2.0.13" + } + }, + "Microsoft.WindowsAppSDK.InteractiveExperiences": { + "type": "Transitive", + "resolved": "2.0.13", + "contentHash": "jxzb2F0thCzUhxX3EMthKyxMaWINGSAf1/uMfS3pyCODCzHoR39LUQE22DZDAmMJEM4pebCnIj2PYRQjSuy65Q==", + "dependencies": { + "Microsoft.WindowsAppSDK.Base": "2.0.3" + } + }, + "Microsoft.WindowsAppSDK.ML": { + "type": "Transitive", + "resolved": "2.1.1", + "contentHash": "q+luYBGlPdzMwhOsZCUmWbIZMvmc0pn5+nBvrKyQzvd/mC2+YBEaqfN2/zkfkFKsmJUuPvnG73RouAP/1oKPaA==", + "dependencies": { + "Microsoft.Windows.AI.MachineLearning": "[2.1.1, 3.0.0)", + "Microsoft.WindowsAppSDK.Base": "2.0.4", + "Microsoft.WindowsAppSDK.Foundation": "2.0.21" + } + }, + "Microsoft.WindowsAppSDK.Runtime": { + "type": "Transitive", + "resolved": "2.1.3", + "contentHash": "dbddzTTnePoiFEw2h9QeGxJ8jyFtx7OEMIt053vjd/FQU4hzdnK9/LGPF5cwCkCD8gLvrXqyfR8AhCjz5Ni9sQ==", + "dependencies": { + "Microsoft.WindowsAppSDK.Base": "2.0.4" + } + }, + "Microsoft.WindowsAppSDK.Widgets": { + "type": "Transitive", + "resolved": "2.0.5", + "contentHash": "lRz+8+QU65gV8hRzLVE+GRsPKZ42lnzqkmsw96HzQd/DuFYfr4JYpUU7ZZ5YrkV2EfQe4BCmoRKAeZ3WwalMcg==", + "dependencies": { + "Microsoft.WindowsAppSDK.Base": "2.0.3" + } + }, + "Microsoft.WindowsAppSDK.WinUI": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "HXFdlOESmUM7RPPWO5z4lpJX7zIPhDDPngfEP1itAUG/1SWFWKxYdEpt6nciMGITj92OpSK70R8/h06j5tfhIg==", + "dependencies": { + "Microsoft.Web.WebView2": "1.0.3719.77", + "Microsoft.WindowsAppSDK.Base": "2.0.4", + "Microsoft.WindowsAppSDK.Foundation": "2.0.21", + "Microsoft.WindowsAppSDK.InteractiveExperiences": "2.0.13" + } + }, + "System.Numerics.Tensors": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "hyJB4UlpAi19Xr9AXzu2NuagKC4lPfHObNMEAA0HmqFz2rX7wKgzeYzO/jM/eBHDhnUGFFEjk5cOoJaxqg5J4A==" + }, + "winquicklook.cswin32": { + "type": "Project" + }, + "winquicklook.preview": { + "type": "Project", + "dependencies": { + "Markdig": "[1.2.0, )" + } + }, + "winquicklook.shell": { + "type": "Project", + "dependencies": { + "WinQuickLook.CsWin32": "[1.0.0, )" + } + } + }, + "net10.0-windows10.0.26100/win-arm64": { + "Microsoft.Web.WebView2": { + "type": "Transitive", + "resolved": "1.0.3719.77", + "contentHash": "t+ucyKw5NTwMjsUrDF6R9Lk40lpcKQD1/HgyGFxl49tdA4h9dKlsj6FYGEmDRpFNfnTpENuTypMcdbrlkqBdDA==" + }, + "Microsoft.Windows.AI.MachineLearning": { + "type": "Transitive", + "resolved": "2.1.1", + "contentHash": "A5BbYPLVrcaRJvpwx0nv7aMDOMqR20qaw9EaLcUe9pkSArzKoSk6cilRsSioU+eRa9+HahidBZTegawdswKqIQ==", + "dependencies": { + "System.Numerics.Tensors": "9.0.0" + } + }, + "Microsoft.WindowsAppSDK.Foundation": { + "type": "Transitive", + "resolved": "2.0.21", + "contentHash": "Wzr4c9dSXXcNQw67wVmy+32FXW6+TLyrQHD5eZXAjNydMsLRMZjUXzD1qQgBg1bdgzjlkYnOyYqoXbOL6lfqkw==", + "dependencies": { + "Microsoft.WindowsAppSDK.Base": "2.0.4", + "Microsoft.WindowsAppSDK.InteractiveExperiences": "2.0.13" + } + } + }, + "net10.0-windows10.0.26100/win-x64": { + "Microsoft.Web.WebView2": { + "type": "Transitive", + "resolved": "1.0.3719.77", + "contentHash": "t+ucyKw5NTwMjsUrDF6R9Lk40lpcKQD1/HgyGFxl49tdA4h9dKlsj6FYGEmDRpFNfnTpENuTypMcdbrlkqBdDA==" + }, + "Microsoft.Windows.AI.MachineLearning": { + "type": "Transitive", + "resolved": "2.1.1", + "contentHash": "A5BbYPLVrcaRJvpwx0nv7aMDOMqR20qaw9EaLcUe9pkSArzKoSk6cilRsSioU+eRa9+HahidBZTegawdswKqIQ==", + "dependencies": { + "System.Numerics.Tensors": "9.0.0" + } + }, + "Microsoft.WindowsAppSDK.Foundation": { + "type": "Transitive", + "resolved": "2.0.21", + "contentHash": "Wzr4c9dSXXcNQw67wVmy+32FXW6+TLyrQHD5eZXAjNydMsLRMZjUXzD1qQgBg1bdgzjlkYnOyYqoXbOL6lfqkw==", + "dependencies": { + "Microsoft.WindowsAppSDK.Base": "2.0.4", + "Microsoft.WindowsAppSDK.InteractiveExperiences": "2.0.13" + } + } + } + } +} \ No newline at end of file diff --git a/src/WinQuickLook.App/App.xaml.cs b/src/WinQuickLook.App/App.xaml.cs index c4de3917..9584e2c5 100644 --- a/src/WinQuickLook.App/App.xaml.cs +++ b/src/WinQuickLook.App/App.xaml.cs @@ -86,5 +86,8 @@ private void PerformPreview() } } - private void ClosePreview() => _mainWindow.ClosePreviewIfActive(); + private void ClosePreview() + { + _mainWindow.ClosePreviewIfActive(); + } } diff --git a/src/WinQuickLook.App/WinQuickLook.App.csproj b/src/WinQuickLook.App/WinQuickLook.App.csproj index 9f6ddb38..bf5d7916 100644 --- a/src/WinQuickLook.App/WinQuickLook.App.csproj +++ b/src/WinQuickLook.App/WinQuickLook.App.csproj @@ -18,7 +18,7 @@ - + diff --git a/src/WinQuickLook.App/packages.lock.json b/src/WinQuickLook.App/packages.lock.json index 3b4e03e7..49a809f8 100644 --- a/src/WinQuickLook.App/packages.lock.json +++ b/src/WinQuickLook.App/packages.lock.json @@ -10,11 +10,11 @@ }, "Microsoft.Extensions.DependencyInjection": { "type": "Direct", - "requested": "[10.0.7, )", - "resolved": "10.0.7", - "contentHash": "91F/o3emPV/+xY/ip3s2LqDNF14kjttlVtq0BXgg6p4MnCzeSZxnUJm+t6WRrtD3JdGo88/oX+z7OwK4y8PZuw==", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "daf62xHIrq8pnE709hgaZZN9tSam9TGGepWe1+bE6V3GEuVwJiMs6ib+38lfMCyAJAHiX0vapxBhsuMSV7U+cg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" } }, "AvalonEdit": { @@ -29,26 +29,26 @@ }, "Markdig": { "type": "Transitive", - "resolved": "1.1.3", - "contentHash": "wboYc6YToID7koq+onej7rjd/kv4urZaZ4QMiJoF/jXt/ZlXe2Af9gWG4nOe5tk654UaOPYvKL64iFum7qWEYA==" + "resolved": "1.2.0", + "contentHash": "LoBVOgeDWfHthWTPzQJkjDkxImqBXRA5XpuTyjdM4SAURyWQGG1CbEEbfPbmvAavggnAZPaTYpbhIPUurWvIXA==" }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "10.0.7", - "contentHash": "Z6mfFEaFcwCfSboxJwOLfu7/31npCY9q70WUamHW/vRQhDvBKOT4Vf9YkZj5J6hLvJpb0oDEYfHunQZj0xxvKw==" + "resolved": "10.0.8", + "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" }, "Microsoft.Web.WebView2": { "type": "Transitive", - "resolved": "1.0.3912.50", - "contentHash": "mEIKZDSmb6CxGWKoxaxTSJ0yi0DD2zp29TA7kJY2GdanuH3c5aYruwfqsm3OsYn/h2hUhybQZvfbxyZe4O+X4Q==" + "resolved": "1.0.3967.48", + "contentHash": "b7dZ5kZ9bfU5Yo11M8feCOoiyalXCN+2Gsw674RBDBmQ2dJV5EOXb8/ivN2LuLJ+VO2V7FmK6l6LsyfEnG1lXw==" }, "winquicklook.core": { "type": "Project", "dependencies": { "AvalonEdit": "[6.3.1.120, )", "Cylinder.WPF": "[1.0.0-preview.2, )", - "Markdig": "[1.1.3, )", - "Microsoft.Web.WebView2": "[1.0.3912.50, )", + "Markdig": "[1.2.0, )", + "Microsoft.Web.WebView2": "[1.0.3967.48, )", "WinQuickLook.CsWin32": "[1.0.0, )" } }, @@ -59,15 +59,15 @@ "net10.0-windows10.0.26100/win-arm64": { "Microsoft.Web.WebView2": { "type": "Transitive", - "resolved": "1.0.3912.50", - "contentHash": "mEIKZDSmb6CxGWKoxaxTSJ0yi0DD2zp29TA7kJY2GdanuH3c5aYruwfqsm3OsYn/h2hUhybQZvfbxyZe4O+X4Q==" + "resolved": "1.0.3967.48", + "contentHash": "b7dZ5kZ9bfU5Yo11M8feCOoiyalXCN+2Gsw674RBDBmQ2dJV5EOXb8/ivN2LuLJ+VO2V7FmK6l6LsyfEnG1lXw==" } }, "net10.0-windows10.0.26100/win-x64": { "Microsoft.Web.WebView2": { "type": "Transitive", - "resolved": "1.0.3912.50", - "contentHash": "mEIKZDSmb6CxGWKoxaxTSJ0yi0DD2zp29TA7kJY2GdanuH3c5aYruwfqsm3OsYn/h2hUhybQZvfbxyZe4O+X4Q==" + "resolved": "1.0.3967.48", + "contentHash": "b7dZ5kZ9bfU5Yo11M8feCOoiyalXCN+2Gsw674RBDBmQ2dJV5EOXb8/ivN2LuLJ+VO2V7FmK6l6LsyfEnG1lXw==" } } } diff --git a/src/WinQuickLook.Core/WinQuickLook.Core.csproj b/src/WinQuickLook.Core/WinQuickLook.Core.csproj index d21b063e..33661097 100644 --- a/src/WinQuickLook.Core/WinQuickLook.Core.csproj +++ b/src/WinQuickLook.Core/WinQuickLook.Core.csproj @@ -8,8 +8,8 @@ - - + + diff --git a/src/WinQuickLook.Core/packages.lock.json b/src/WinQuickLook.Core/packages.lock.json index 0e26d4c7..95622b0a 100644 --- a/src/WinQuickLook.Core/packages.lock.json +++ b/src/WinQuickLook.Core/packages.lock.json @@ -16,15 +16,15 @@ }, "Markdig": { "type": "Direct", - "requested": "[1.1.3, )", - "resolved": "1.1.3", - "contentHash": "wboYc6YToID7koq+onej7rjd/kv4urZaZ4QMiJoF/jXt/ZlXe2Af9gWG4nOe5tk654UaOPYvKL64iFum7qWEYA==" + "requested": "[1.2.0, )", + "resolved": "1.2.0", + "contentHash": "LoBVOgeDWfHthWTPzQJkjDkxImqBXRA5XpuTyjdM4SAURyWQGG1CbEEbfPbmvAavggnAZPaTYpbhIPUurWvIXA==" }, "Microsoft.Web.WebView2": { "type": "Direct", - "requested": "[1.0.3912.50, )", - "resolved": "1.0.3912.50", - "contentHash": "mEIKZDSmb6CxGWKoxaxTSJ0yi0DD2zp29TA7kJY2GdanuH3c5aYruwfqsm3OsYn/h2hUhybQZvfbxyZe4O+X4Q==" + "requested": "[1.0.3967.48, )", + "resolved": "1.0.3967.48", + "contentHash": "b7dZ5kZ9bfU5Yo11M8feCOoiyalXCN+2Gsw674RBDBmQ2dJV5EOXb8/ivN2LuLJ+VO2V7FmK6l6LsyfEnG1lXw==" }, "winquicklook.cswin32": { "type": "Project" diff --git a/src/WinQuickLook.Preview/AggregatePreviewProvider.cs b/src/WinQuickLook.Preview/AggregatePreviewProvider.cs new file mode 100644 index 00000000..63a5c2b2 --- /dev/null +++ b/src/WinQuickLook.Preview/AggregatePreviewProvider.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; + +namespace WinQuickLook.Preview; + +public sealed class AggregatePreviewProvider : IPreviewProvider +{ + private readonly IReadOnlyList _previewProviders; + + public AggregatePreviewProvider(IEnumerable previewProviders) + { + _previewProviders = previewProviders.ToArray(); + } + + public static AggregatePreviewProvider CreateDefault() + { + return new AggregatePreviewProvider( + [ + new ImagePreviewProvider(), + new MarkdownPreviewProvider(), + new WebDocumentPreviewProvider(), + new MediaPreviewProvider(), + new TextPreviewProvider(), + new GenericPreviewProvider() + ]); + } + + public bool CanPreview(FileSystemInfo fileSystemInfo) + { + return _previewProviders.Any(x => x.CanPreview(fileSystemInfo)); + } + + public bool TryCreatePreview(PreviewRequest request, [NotNullWhen(true)] out PreviewResult? result) + { + foreach (var previewProvider in _previewProviders) + { + if (previewProvider.TryCreatePreview(request, out result)) + { + return true; + } + } + + result = null; + + return false; + } +} diff --git a/src/WinQuickLook.Preview/FileSystemPreviewPayload.cs b/src/WinQuickLook.Preview/FileSystemPreviewPayload.cs new file mode 100644 index 00000000..4c9ff965 --- /dev/null +++ b/src/WinQuickLook.Preview/FileSystemPreviewPayload.cs @@ -0,0 +1,10 @@ +using System; + +namespace WinQuickLook.Preview; + +public sealed record FileSystemPreviewPayload( + string Path, + bool IsDirectory, + string TypeName, + string Detail, + DateTime LastWriteTime); diff --git a/src/WinQuickLook.Preview/GenericPreviewProvider.cs b/src/WinQuickLook.Preview/GenericPreviewProvider.cs new file mode 100644 index 00000000..15633115 --- /dev/null +++ b/src/WinQuickLook.Preview/GenericPreviewProvider.cs @@ -0,0 +1,87 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; + +namespace WinQuickLook.Preview; + +public sealed class GenericPreviewProvider : IPreviewProvider +{ + public bool CanPreview(FileSystemInfo fileSystemInfo) + { + return fileSystemInfo.Exists; + } + + public bool TryCreatePreview(PreviewRequest request, [NotNullWhen(true)] out PreviewResult? result) + { + if (Directory.Exists(request.FilePath)) + { + var directoryInfo = new DirectoryInfo(request.FilePath); + + result = new PreviewResult( + PreviewKind.Directory, + directoryInfo.FullName, + directoryInfo.Name, + new FileSystemPreviewPayload(directoryInfo.FullName, true, "Directory", GetDirectoryDetail(directoryInfo), directoryInfo.LastWriteTime)); + + return true; + } + + var fileInfo = new FileInfo(request.FilePath); + + if (!fileInfo.Exists) + { + result = null; + + return false; + } + + result = new PreviewResult( + PreviewKind.Generic, + fileInfo.FullName, + fileInfo.Name, + new FileSystemPreviewPayload(fileInfo.FullName, false, GetFileTypeName(fileInfo), FormatFileSize(fileInfo.Length), fileInfo.LastWriteTime)); + + return true; + } + + private static string GetDirectoryDetail(DirectoryInfo directoryInfo) + { + try + { + var count = directoryInfo.EnumerateFileSystemInfos().Take(10_001).Count(); + + return count > 10_000 ? "10,000+ items" : $"{count:N0} items"; + } + catch (IOException) + { + return "Count unavailable"; + } + catch (UnauthorizedAccessException) + { + return "Count unavailable"; + } + } + + private static string GetFileTypeName(FileInfo fileInfo) + { + return string.IsNullOrEmpty(fileInfo.Extension) + ? "File" + : $"{fileInfo.Extension.TrimStart('.').ToUpperInvariant()} file"; + } + + private static string FormatFileSize(long size) + { + string[] units = ["B", "KB", "MB", "GB", "TB"]; + var value = (double)size; + var unitIndex = 0; + + while (value >= 1024 && unitIndex < units.Length - 1) + { + value /= 1024; + unitIndex++; + } + + return unitIndex == 0 ? $"{size:N0} {units[unitIndex]}" : $"{value:N1} {units[unitIndex]}"; + } +} diff --git a/src/WinQuickLook.Preview/IPreviewProvider.cs b/src/WinQuickLook.Preview/IPreviewProvider.cs new file mode 100644 index 00000000..ff9565dc --- /dev/null +++ b/src/WinQuickLook.Preview/IPreviewProvider.cs @@ -0,0 +1,11 @@ +using System.Diagnostics.CodeAnalysis; +using System.IO; + +namespace WinQuickLook.Preview; + +public interface IPreviewProvider +{ + bool CanPreview(FileSystemInfo fileSystemInfo); + + bool TryCreatePreview(PreviewRequest request, [NotNullWhen(true)] out PreviewResult? result); +} diff --git a/src/WinQuickLook.Preview/ImagePreviewPayload.cs b/src/WinQuickLook.Preview/ImagePreviewPayload.cs new file mode 100644 index 00000000..9a13e5dd --- /dev/null +++ b/src/WinQuickLook.Preview/ImagePreviewPayload.cs @@ -0,0 +1,3 @@ +namespace WinQuickLook.Preview; + +public sealed record ImagePreviewPayload(string FilePath); diff --git a/src/WinQuickLook.Preview/ImagePreviewProvider.cs b/src/WinQuickLook.Preview/ImagePreviewProvider.cs new file mode 100644 index 00000000..b11f8522 --- /dev/null +++ b/src/WinQuickLook.Preview/ImagePreviewProvider.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Frozen; +using System.Diagnostics.CodeAnalysis; +using System.IO; + +namespace WinQuickLook.Preview; + +public sealed class ImagePreviewProvider : IPreviewProvider +{ + public static readonly FrozenSet SupportedExtensions = new[] + { + ".png", + ".jpg", + ".jpeg", + ".bmp", + ".gif", + ".webp" + }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); + + public bool CanPreview(FileSystemInfo fileSystemInfo) + { + return fileSystemInfo is FileInfo fileInfo && fileInfo.Exists && SupportedExtensions.Contains(fileInfo.Extension); + } + + public bool TryCreatePreview(PreviewRequest request, [NotNullWhen(true)] out PreviewResult? result) + { + var fileInfo = new FileInfo(request.FilePath); + + if (!CanPreview(fileInfo)) + { + result = null; + + return false; + } + + result = new PreviewResult( + PreviewKind.Image, + fileInfo.FullName, + fileInfo.Name, + new ImagePreviewPayload(fileInfo.FullName)); + + return true; + } +} diff --git a/src/WinQuickLook.Preview/MarkdownPreviewProvider.cs b/src/WinQuickLook.Preview/MarkdownPreviewProvider.cs new file mode 100644 index 00000000..1556bcc8 --- /dev/null +++ b/src/WinQuickLook.Preview/MarkdownPreviewProvider.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Frozen; +using System.Diagnostics.CodeAnalysis; +using System.IO; + +using Markdig; + +namespace WinQuickLook.Preview; + +public sealed class MarkdownPreviewProvider : IPreviewProvider +{ + private static readonly MarkdownPipeline s_pipeline = new MarkdownPipelineBuilder() + .UseAdvancedExtensions() + .Build(); + + public static readonly FrozenSet SupportedExtensions = new[] + { + ".md", + ".markdown" + }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); + + public bool CanPreview(FileSystemInfo fileSystemInfo) + { + return fileSystemInfo is FileInfo fileInfo && fileInfo.Exists && SupportedExtensions.Contains(fileInfo.Extension); + } + + public bool TryCreatePreview(PreviewRequest request, [NotNullWhen(true)] out PreviewResult? result) + { + var fileInfo = new FileInfo(request.FilePath); + + if (!CanPreview(fileInfo)) + { + result = null; + + return false; + } + + try + { + var markdown = File.ReadAllText(fileInfo.FullName); + var html = Markdown.ToHtml(markdown, s_pipeline); + + result = new PreviewResult( + PreviewKind.Markdown, + fileInfo.FullName, + fileInfo.Name, + new WebDocumentPreviewPayload(fileInfo.FullName, WebDocumentKind.Markdown, CreateHtmlDocument(html))); + + return true; + } + catch (IOException) + { + result = null; + + return false; + } + catch (UnauthorizedAccessException) + { + result = null; + + return false; + } + } + + private static string CreateHtmlDocument(string body) + { + return $$""" + + + + + + + + {{body}} + + + """; + } +} diff --git a/src/WinQuickLook.Preview/MediaPreviewPayload.cs b/src/WinQuickLook.Preview/MediaPreviewPayload.cs new file mode 100644 index 00000000..a0b93391 --- /dev/null +++ b/src/WinQuickLook.Preview/MediaPreviewPayload.cs @@ -0,0 +1,3 @@ +namespace WinQuickLook.Preview; + +public sealed record MediaPreviewPayload(string FilePath, bool HasVideo); diff --git a/src/WinQuickLook.Preview/MediaPreviewProvider.cs b/src/WinQuickLook.Preview/MediaPreviewProvider.cs new file mode 100644 index 00000000..573f3259 --- /dev/null +++ b/src/WinQuickLook.Preview/MediaPreviewProvider.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Frozen; +using System.Diagnostics.CodeAnalysis; +using System.IO; + +namespace WinQuickLook.Preview; + +public sealed class MediaPreviewProvider : IPreviewProvider +{ + public static readonly FrozenSet VideoExtensions = new[] + { + ".avi", + ".m4v", + ".mkv", + ".mov", + ".mp4", + ".mpeg", + ".mpg", + ".webm", + ".wmv" + }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); + + public static readonly FrozenSet AudioExtensions = new[] + { + ".aac", + ".flac", + ".m4a", + ".mp3", + ".ogg", + ".wav", + ".wma" + }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); + + public bool CanPreview(FileSystemInfo fileSystemInfo) + { + return fileSystemInfo is FileInfo fileInfo + && fileInfo.Exists + && (VideoExtensions.Contains(fileInfo.Extension) || AudioExtensions.Contains(fileInfo.Extension)); + } + + public bool TryCreatePreview(PreviewRequest request, [NotNullWhen(true)] out PreviewResult? result) + { + var fileInfo = new FileInfo(request.FilePath); + + if (!CanPreview(fileInfo)) + { + result = null; + + return false; + } + + var hasVideo = VideoExtensions.Contains(fileInfo.Extension); + + result = new PreviewResult( + hasVideo ? PreviewKind.Video : PreviewKind.Audio, + fileInfo.FullName, + fileInfo.Name, + new MediaPreviewPayload(fileInfo.FullName, hasVideo)); + + return true; + } +} diff --git a/src/WinQuickLook.Preview/PreviewKind.cs b/src/WinQuickLook.Preview/PreviewKind.cs new file mode 100644 index 00000000..7064404f --- /dev/null +++ b/src/WinQuickLook.Preview/PreviewKind.cs @@ -0,0 +1,15 @@ +namespace WinQuickLook.Preview; + +public enum PreviewKind +{ + Image, + Text, + Markdown, + Web, + Video, + Audio, + Pdf, + Directory, + Generic, + Unsupported +} diff --git a/src/WinQuickLook.Preview/PreviewRequest.cs b/src/WinQuickLook.Preview/PreviewRequest.cs new file mode 100644 index 00000000..4911525a --- /dev/null +++ b/src/WinQuickLook.Preview/PreviewRequest.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; + +namespace WinQuickLook.Preview; + +public sealed record PreviewRequest( + string FilePath, + IReadOnlyList? SiblingFilePaths = null, + int CurrentIndex = 0); diff --git a/src/WinQuickLook.Preview/PreviewRequestFactory.cs b/src/WinQuickLook.Preview/PreviewRequestFactory.cs new file mode 100644 index 00000000..c43be033 --- /dev/null +++ b/src/WinQuickLook.Preview/PreviewRequestFactory.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace WinQuickLook.Preview; + +public static class PreviewRequestFactory +{ + public static PreviewRequest Create(string filePath, IReadOnlyList? siblingFilePaths = null) + { + var fullPath = Path.GetFullPath(filePath); + + if (siblingFilePaths is null || siblingFilePaths.Count == 0) + { + return new PreviewRequest(fullPath); + } + + var siblings = siblingFilePaths + .Select(Path.GetFullPath) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var currentIndex = Array.FindIndex(siblings, x => string.Equals(x, fullPath, StringComparison.OrdinalIgnoreCase)); + + if (currentIndex >= 0) + { + return new PreviewRequest(fullPath, siblings, currentIndex); + } + + var expandedSiblings = siblings + .Append(fullPath) + .ToArray(); + + return new PreviewRequest(fullPath, expandedSiblings, expandedSiblings.Length - 1); + } + + public static PreviewRequest CreateWithDirectorySiblings(string filePath, IPreviewProvider provider) + { + if (Directory.Exists(filePath)) + { + return Create(filePath); + } + + var fileInfo = new FileInfo(filePath); + + if (fileInfo.Directory is null || !fileInfo.Directory.Exists) + { + return Create(fileInfo.FullName); + } + + try + { + var siblings = fileInfo.Directory + .EnumerateFiles() + .Where(provider.CanPreview) + .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(x => x.FullName, StringComparer.OrdinalIgnoreCase) + .Select(x => x.FullName) + .ToArray(); + + return Create(fileInfo.FullName, siblings); + } + catch (IOException) + { + return Create(fileInfo.FullName); + } + catch (UnauthorizedAccessException) + { + return Create(fileInfo.FullName); + } + } +} diff --git a/src/WinQuickLook.Preview/PreviewResult.cs b/src/WinQuickLook.Preview/PreviewResult.cs new file mode 100644 index 00000000..ca6ee857 --- /dev/null +++ b/src/WinQuickLook.Preview/PreviewResult.cs @@ -0,0 +1,7 @@ +namespace WinQuickLook.Preview; + +public sealed record PreviewResult( + PreviewKind Kind, + string FilePath, + string? Title = null, + object? Payload = null); diff --git a/src/WinQuickLook.Preview/TextPreviewPayload.cs b/src/WinQuickLook.Preview/TextPreviewPayload.cs new file mode 100644 index 00000000..ace5eb2f --- /dev/null +++ b/src/WinQuickLook.Preview/TextPreviewPayload.cs @@ -0,0 +1,7 @@ +namespace WinQuickLook.Preview; + +public sealed record TextPreviewPayload( + string FilePath, + string Text, + string? Language = null, + bool IsTruncated = false); diff --git a/src/WinQuickLook.Preview/TextPreviewProvider.cs b/src/WinQuickLook.Preview/TextPreviewProvider.cs new file mode 100644 index 00000000..49b03520 --- /dev/null +++ b/src/WinQuickLook.Preview/TextPreviewProvider.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Frozen; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Text; + +namespace WinQuickLook.Preview; + +public sealed class TextPreviewProvider : IPreviewProvider +{ + private const int MaxPreviewBytes = 1024 * 1024; + + public static readonly FrozenSet SupportedExtensions = new[] + { + ".bat", + ".c", + ".cmd", + ".config", + ".cpp", + ".cs", + ".csproj", + ".css", + ".csv", + ".fs", + ".fsproj", + ".go", + ".h", + ".hpp", + ".htm", + ".html", + ".ini", + ".java", + ".js", + ".json", + ".jsx", + ".kt", + ".log", + ".markdown", + ".md", + ".mjs", + ".props", + ".ps1", + ".psd1", + ".psm1", + ".py", + ".rb", + ".rs", + ".scss", + ".sh", + ".sln", + ".slnx", + ".sql", + ".swift", + ".targets", + ".ts", + ".tsx", + ".txt", + ".vb", + ".vbproj", + ".vue", + ".xaml", + ".xml", + ".yaml", + ".yml" + }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); + + public bool CanPreview(FileSystemInfo fileSystemInfo) + { + return fileSystemInfo is FileInfo fileInfo && fileInfo.Exists && SupportedExtensions.Contains(fileInfo.Extension); + } + + public bool TryCreatePreview(PreviewRequest request, [NotNullWhen(true)] out PreviewResult? result) + { + var fileInfo = new FileInfo(request.FilePath); + + if (!CanPreview(fileInfo)) + { + result = null; + + return false; + } + + try + { + var bytes = File.ReadAllBytes(fileInfo.FullName); + var isTruncated = bytes.Length > MaxPreviewBytes; + + if (isTruncated) + { + Array.Resize(ref bytes, MaxPreviewBytes); + } + + if (ContainsNullByte(bytes)) + { + result = null; + + return false; + } + + result = new PreviewResult( + PreviewKind.Text, + fileInfo.FullName, + fileInfo.Name, + new TextPreviewPayload(fileInfo.FullName, DecodeText(bytes), GetLanguage(fileInfo.Extension), isTruncated)); + + return true; + } + catch (IOException) + { + result = null; + + return false; + } + catch (UnauthorizedAccessException) + { + result = null; + + return false; + } + } + + private static bool ContainsNullByte(byte[] bytes) + { + return bytes.Contains((byte)0); + } + + private static string DecodeText(byte[] bytes) + { + using var stream = new MemoryStream(bytes); + using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + + return reader.ReadToEnd(); + } + + private static string? GetLanguage(string extension) + { + return extension.ToLowerInvariant() switch + { + ".bat" or ".cmd" => "Batch", + ".c" or ".h" => "C", + ".config" or ".props" or ".targets" or ".xml" or ".xaml" or ".csproj" or ".vbproj" or ".fsproj" => "XML", + ".cpp" or ".hpp" => "C++", + ".cs" => "C#", + ".css" or ".scss" => "CSS", + ".csv" => "CSV", + ".fs" => "F#", + ".go" => "Go", + ".htm" or ".html" or ".vue" => "HTML", + ".java" => "Java", + ".js" or ".jsx" or ".mjs" => "JavaScript", + ".json" => "JSON", + ".kt" => "Kotlin", + ".md" or ".markdown" => "Markdown", + ".ps1" or ".psd1" or ".psm1" => "PowerShell", + ".py" => "Python", + ".rb" => "Ruby", + ".rs" => "Rust", + ".sh" => "Shell", + ".sln" or ".slnx" => "Solution", + ".sql" => "SQL", + ".swift" => "Swift", + ".ts" or ".tsx" => "TypeScript", + ".vb" => "Visual Basic", + ".yaml" or ".yml" => "YAML", + _ => null + }; + } +} diff --git a/src/WinQuickLook.Preview/WebDocumentKind.cs b/src/WinQuickLook.Preview/WebDocumentKind.cs new file mode 100644 index 00000000..a3aa5637 --- /dev/null +++ b/src/WinQuickLook.Preview/WebDocumentKind.cs @@ -0,0 +1,9 @@ +namespace WinQuickLook.Preview; + +public enum WebDocumentKind +{ + Html, + Markdown, + Pdf, + Svg +} diff --git a/src/WinQuickLook.Preview/WebDocumentPreviewPayload.cs b/src/WinQuickLook.Preview/WebDocumentPreviewPayload.cs new file mode 100644 index 00000000..70f48589 --- /dev/null +++ b/src/WinQuickLook.Preview/WebDocumentPreviewPayload.cs @@ -0,0 +1,6 @@ +namespace WinQuickLook.Preview; + +public sealed record WebDocumentPreviewPayload( + string FilePath, + WebDocumentKind Kind, + string? Html = null); diff --git a/src/WinQuickLook.Preview/WebDocumentPreviewProvider.cs b/src/WinQuickLook.Preview/WebDocumentPreviewProvider.cs new file mode 100644 index 00000000..7e306039 --- /dev/null +++ b/src/WinQuickLook.Preview/WebDocumentPreviewProvider.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Frozen; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; + +namespace WinQuickLook.Preview; + +public sealed class WebDocumentPreviewProvider : IPreviewProvider +{ + private static readonly FrozenDictionary s_supportedExtensions = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [".htm"] = WebDocumentKind.Html, + [".html"] = WebDocumentKind.Html, + [".xhtml"] = WebDocumentKind.Html, + [".pdf"] = WebDocumentKind.Pdf, + [".svg"] = WebDocumentKind.Svg + }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + + public bool CanPreview(FileSystemInfo fileSystemInfo) + { + return fileSystemInfo is FileInfo fileInfo && fileInfo.Exists && s_supportedExtensions.ContainsKey(fileInfo.Extension); + } + + public bool TryCreatePreview(PreviewRequest request, [NotNullWhen(true)] out PreviewResult? result) + { + var fileInfo = new FileInfo(request.FilePath); + + if (!CanPreview(fileInfo) || !s_supportedExtensions.TryGetValue(fileInfo.Extension, out var kind)) + { + result = null; + + return false; + } + + result = new PreviewResult( + GetPreviewKind(kind), + fileInfo.FullName, + fileInfo.Name, + new WebDocumentPreviewPayload(fileInfo.FullName, kind)); + + return true; + } + + private static PreviewKind GetPreviewKind(WebDocumentKind kind) + { + return kind switch + { + WebDocumentKind.Pdf => PreviewKind.Pdf, + _ => PreviewKind.Web + }; + } +} diff --git a/src/WinQuickLook.Preview/WinQuickLook.Preview.csproj b/src/WinQuickLook.Preview/WinQuickLook.Preview.csproj new file mode 100644 index 00000000..d0b04581 --- /dev/null +++ b/src/WinQuickLook.Preview/WinQuickLook.Preview.csproj @@ -0,0 +1,11 @@ + + + + WinQuickLook.Preview + + + + + + + diff --git a/src/WinQuickLook.Preview/packages.lock.json b/src/WinQuickLook.Preview/packages.lock.json new file mode 100644 index 00000000..500914c5 --- /dev/null +++ b/src/WinQuickLook.Preview/packages.lock.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "dependencies": { + "net10.0-windows10.0.26100": { + "Markdig": { + "type": "Direct", + "requested": "[1.2.0, )", + "resolved": "1.2.0", + "contentHash": "LoBVOgeDWfHthWTPzQJkjDkxImqBXRA5XpuTyjdM4SAURyWQGG1CbEEbfPbmvAavggnAZPaTYpbhIPUurWvIXA==" + } + } + } +} \ No newline at end of file diff --git a/src/WinQuickLook.Shell/Messaging/LowLevelKeyboardHook.cs b/src/WinQuickLook.Shell/Messaging/LowLevelKeyboardHook.cs new file mode 100644 index 00000000..828670d4 --- /dev/null +++ b/src/WinQuickLook.Shell/Messaging/LowLevelKeyboardHook.cs @@ -0,0 +1,26 @@ +using System; +using System.Runtime.InteropServices; + +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.UI.Input.KeyboardAndMouse; +using Windows.Win32.UI.WindowsAndMessaging; + +namespace WinQuickLook.Messaging; + +public class LowLevelKeyboardHook() : WindowsHook(WINDOWS_HOOK_ID.WH_KEYBOARD_LL) +{ + public Action? PerformKeyDown { get; set; } + + protected override LRESULT HookProc(int code, WPARAM wParam, LPARAM lParam) + { + if (code == PInvoke.HC_ACTION && wParam == PInvoke.WM_KEYDOWN) + { + var kbdllhook = Marshal.PtrToStructure(lParam); + + PerformKeyDown?.Invoke((VIRTUAL_KEY)kbdllhook.vkCode); + } + + return base.HookProc(code, wParam, lParam); + } +} diff --git a/src/WinQuickLook.Shell/Messaging/WindowsHook.cs b/src/WinQuickLook.Shell/Messaging/WindowsHook.cs new file mode 100644 index 00000000..78988b8d --- /dev/null +++ b/src/WinQuickLook.Shell/Messaging/WindowsHook.cs @@ -0,0 +1,58 @@ +using System; + +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.UI.WindowsAndMessaging; + +namespace WinQuickLook.Messaging; + +public abstract class WindowsHook(WINDOWS_HOOK_ID idHook) : IDisposable +{ + private HOOKPROC? _hookProc; + private UnhookWindowsHookExSafeHandle? _hook; + private bool _disposed; + + public void Start() + { + if (_hook is not null) + { + return; + } + + _hookProc = HookProc; + _hook = PInvoke.SetWindowsHookEx(idHook, _hookProc, PInvoke.GetModuleHandle((string)null!), 0); + } + + public void Stop() + { + if (_hook is null) + { + return; + } + + _hook.Close(); + _hook = null; + } + + protected virtual LRESULT HookProc(int code, WPARAM wParam, LPARAM lParam) => PInvoke.CallNextHookEx(_hook, code, wParam, lParam); + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + Stop(); + + _disposed = true; + } + + ~WindowsHook() => Dispose(false); +} diff --git a/src/WinQuickLook.Shell/Providers/ShellExplorerProvider.cs b/src/WinQuickLook.Shell/Providers/ShellExplorerProvider.cs new file mode 100644 index 00000000..25ca41b7 --- /dev/null +++ b/src/WinQuickLook.Shell/Providers/ShellExplorerProvider.cs @@ -0,0 +1,152 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; + +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.UI.Shell; +using Windows.Win32.UI.WindowsAndMessaging; + +using IServiceProvider = Windows.Win32.System.Com.IServiceProvider; + +namespace WinQuickLook.Providers; + +public class ShellExplorerProvider +{ + public FileSystemInfo? GetSelectedItem() + { + var foregroundHwnd = PInvoke.GetForegroundWindow(); + + if (foregroundHwnd == nint.Zero) + { + return null; + } + + var shellWindows = (IShellWindows)new ShellWindows(); + var fileSystemInfo = default(FileSystemInfo); + + if (IsDesktopWindow(foregroundHwnd)) + { + var pvarLoc = new object(); + + shellWindows.FindWindowSW(pvarLoc, pvarLoc, (int)ShellWindowTypeConstants.SWC_DESKTOP, out var desktopHwnd, (int)ShellWindowFindWindowOptions.SWFO_NEEDDISPATCH, out IWebBrowserApp webBrowserApp); + + if (!IsCaretActive(desktopHwnd)) + { + fileSystemInfo = GetSelectedItemCore(webBrowserApp); + } + + Marshal.ReleaseComObject(webBrowserApp); + } + else + { + shellWindows.get_Count(out var count); + + for (var i = 0; i < count; i++) + { + shellWindows.Item(i, out IWebBrowserApp webBrowserApp); + + try + { + webBrowserApp.get_HWND(out var hwnd); + + if (hwnd != foregroundHwnd || IsCaretActive(hwnd)) + { + continue; + } + + fileSystemInfo = GetSelectedItemCore(webBrowserApp); + + break; + } + finally + { + Marshal.ReleaseComObject(webBrowserApp); + } + } + } + + Marshal.ReleaseComObject(shellWindows); + + return fileSystemInfo; + } + + private FileSystemInfo? GetSelectedItemCore(IWebBrowserApp webBrowserApp) + { + var serviceProvider = (IServiceProvider)webBrowserApp; + + serviceProvider.QueryService(PInvoke.SID_STopLevelBrowser, out IShellBrowser shellBrowser); + shellBrowser.QueryActiveShellView(out var shellView); + + var folderView = (IFolderView)shellView; + + folderView.GetFocusedItem(out var index); + folderView.Item(index, out nint ppidl); + folderView.GetFolder(out IPersistFolder2 persistFolder2); + + var shellFolder = (IShellFolder)persistFolder2; + + shellFolder.GetDisplayNameOf(ppidl, SHGDNF.SHGDN_FORPARSING, out var pName); + + Span pszBuf = new char[PInvoke.MAX_PATH]; + + PInvoke.StrRetToBuf(ref pName, ppidl, pszBuf); + + try + { + var selectedPath = new string(pszBuf.TrimEnd('\0')); + + if (File.Exists(selectedPath)) + { + return new FileInfo(selectedPath); + } + + if (Directory.Exists(selectedPath)) + { + return new DirectoryInfo(selectedPath); + } + + return null; + } + finally + { + Marshal.FreeCoTaskMem(ppidl); + + Marshal.ReleaseComObject(shellFolder); + Marshal.ReleaseComObject(persistFolder2); + Marshal.ReleaseComObject(folderView); + Marshal.ReleaseComObject(shellView); + Marshal.ReleaseComObject(shellBrowser); + Marshal.ReleaseComObject(serviceProvider); + } + } + + private static bool IsCaretActive(HWND hwnd) + { + var threadId = PInvoke.GetWindowThreadProcessId(hwnd); + var info = new GUITHREADINFO + { + cbSize = (uint)Marshal.SizeOf() + }; + + PInvoke.GetGUIThreadInfo(threadId, ref info); + + return info.flags != 0 || info.hwndCaret != nint.Zero; + } + + private static bool IsDesktopWindow(HWND hwnd) + { + Span classNameBuf = new char[64]; + + PInvoke.GetClassName(hwnd, classNameBuf); + + var className = new string(classNameBuf); + + if (className != "Progman" && className != "WorkerW") + { + return false; + } + + return PInvoke.FindWindowEx(hwnd, new HWND(), "SHELLDLL_DefView", null) != nint.Zero; + } +} diff --git a/src/WinQuickLook.Shell/WinQuickLook.Shell.csproj b/src/WinQuickLook.Shell/WinQuickLook.Shell.csproj new file mode 100644 index 00000000..8383a02f --- /dev/null +++ b/src/WinQuickLook.Shell/WinQuickLook.Shell.csproj @@ -0,0 +1,11 @@ + + + + WinQuickLook + + + + + + + diff --git a/src/WinQuickLook.Shell/packages.lock.json b/src/WinQuickLook.Shell/packages.lock.json new file mode 100644 index 00000000..ccc4eec1 --- /dev/null +++ b/src/WinQuickLook.Shell/packages.lock.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "dependencies": { + "net10.0-windows10.0.26100": { + "winquicklook.cswin32": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/tests/WinQuickLook.Core.Tests/Preview/ImagePreviewProviderTests.cs b/tests/WinQuickLook.Core.Tests/Preview/ImagePreviewProviderTests.cs new file mode 100644 index 00000000..c7ae776a --- /dev/null +++ b/tests/WinQuickLook.Core.Tests/Preview/ImagePreviewProviderTests.cs @@ -0,0 +1,179 @@ +using System; +using System.IO; + +using WinQuickLook.Preview; + +using Xunit; + +namespace WinQuickLook.Tests.Preview; + +public class ImagePreviewProviderTests +{ + [Theory] + [InlineData("sample.png")] + [InlineData("sample.jpg")] + [InlineData("sample.jpeg")] + [InlineData("sample.bmp")] + [InlineData("sample.gif")] + [InlineData("sample.webp")] + public void CanPreview_ReturnsTrue_ForSupportedImageExtension(string fileName) + { + using var temp = new TemporaryDirectory(); + var fileInfo = temp.CreateFile(fileName); + + var provider = new ImagePreviewProvider(); + + Assert.True(provider.CanPreview(fileInfo)); + } + + [Fact] + public void TryCreatePreview_ReturnsImageResult() + { + using var temp = new TemporaryDirectory(); + var fileInfo = temp.CreateFile("sample.png"); + var provider = new ImagePreviewProvider(); + + var success = provider.TryCreatePreview(new PreviewRequest(fileInfo.FullName), out var result); + + Assert.True(success); + Assert.NotNull(result); + Assert.Equal(PreviewKind.Image, result.Kind); + Assert.Equal(fileInfo.FullName, result.FilePath); + Assert.IsType(result.Payload); + } + + [Fact] + public void CreateWithDirectorySiblings_ReturnsOrderedSupportedImages() + { + using var temp = new TemporaryDirectory(); + var first = temp.CreateFile("a.png"); + var current = temp.CreateFile("b.jpg"); + var third = temp.CreateFile("c.webp"); + temp.CreateFile("notes.txt"); + + var request = PreviewRequestFactory.CreateWithDirectorySiblings(current.FullName, new ImagePreviewProvider()); + + Assert.Equal(current.FullName, request.FilePath); + Assert.Equal(1, request.CurrentIndex); + Assert.Equal([first.FullName, current.FullName, third.FullName], request.SiblingFilePaths); + } + + [Fact] + public void AggregatePreviewProvider_CanPreview_TextFile() + { + using var temp = new TemporaryDirectory(); + var fileInfo = temp.CreateFile("sample.cs", "Console.WriteLine(\"Hello\");"); + + var provider = AggregatePreviewProvider.CreateDefault(); + + Assert.True(provider.CanPreview(fileInfo)); + } + + [Fact] + public void TextPreviewProvider_ReturnsTextResult() + { + using var temp = new TemporaryDirectory(); + var fileInfo = temp.CreateFile("sample.json", "{\"ok\":true}"); + var provider = new TextPreviewProvider(); + + var success = provider.TryCreatePreview(new PreviewRequest(fileInfo.FullName), out var result); + + Assert.True(success); + Assert.NotNull(result); + Assert.Equal(PreviewKind.Text, result.Kind); + var payload = Assert.IsType(result.Payload); + Assert.Equal("{\"ok\":true}", payload.Text); + Assert.Equal("JSON", payload.Language); + } + + [Fact] + public void MarkdownPreviewProvider_ReturnsMarkdownResult() + { + using var temp = new TemporaryDirectory(); + var fileInfo = temp.CreateFile("readme.md", "# Hello"); + var provider = new MarkdownPreviewProvider(); + + var success = provider.TryCreatePreview(new PreviewRequest(fileInfo.FullName), out var result); + + Assert.True(success); + Assert.NotNull(result); + Assert.Equal(PreviewKind.Markdown, result.Kind); + var payload = Assert.IsType(result.Payload); + Assert.Equal(WebDocumentKind.Markdown, payload.Kind); + Assert.Contains("(result.Payload); + Assert.Equal(WebDocumentKind.Pdf, payload.Kind); + } + + [Fact] + public void MediaPreviewProvider_ReturnsAudioResult() + { + using var temp = new TemporaryDirectory(); + var fileInfo = temp.CreateFile("sample.mp3"); + var provider = new MediaPreviewProvider(); + + var success = provider.TryCreatePreview(new PreviewRequest(fileInfo.FullName), out var result); + + Assert.True(success); + Assert.NotNull(result); + Assert.Equal(PreviewKind.Audio, result.Kind); + var payload = Assert.IsType(result.Payload); + Assert.False(payload.HasVideo); + } + + [Fact] + public void GenericPreviewProvider_ReturnsDirectoryResult() + { + using var temp = new TemporaryDirectory(); + temp.CreateFile("child.txt"); + var provider = new GenericPreviewProvider(); + + var success = provider.TryCreatePreview(new PreviewRequest(temp.DirectoryPath), out var result); + + Assert.True(success); + Assert.NotNull(result); + Assert.Equal(PreviewKind.Directory, result.Kind); + var payload = Assert.IsType(result.Payload); + Assert.True(payload.IsDirectory); + } + + private sealed class TemporaryDirectory : IDisposable + { + private readonly string _path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + + public string DirectoryPath => _path; + + public TemporaryDirectory() + { + Directory.CreateDirectory(_path); + } + + public FileInfo CreateFile(string fileName, string content = "") + { + var path = Path.Combine(_path, fileName); + + File.WriteAllText(path, content); + + return new FileInfo(path); + } + + public void Dispose() + { + Directory.Delete(_path, true); + } + } +} diff --git a/tests/WinQuickLook.Core.Tests/WinQuickLook.Core.Tests.csproj b/tests/WinQuickLook.Core.Tests/WinQuickLook.Core.Tests.csproj index db71c3e8..146fc02e 100644 --- a/tests/WinQuickLook.Core.Tests/WinQuickLook.Core.Tests.csproj +++ b/tests/WinQuickLook.Core.Tests/WinQuickLook.Core.Tests.csproj @@ -16,13 +16,13 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -32,6 +32,7 @@ + diff --git a/tests/WinQuickLook.Core.Tests/packages.lock.json b/tests/WinQuickLook.Core.Tests/packages.lock.json index a97da18b..e8743395 100644 --- a/tests/WinQuickLook.Core.Tests/packages.lock.json +++ b/tests/WinQuickLook.Core.Tests/packages.lock.json @@ -4,18 +4,18 @@ "net10.0-windows10.0.26100": { "coverlet.collector": { "type": "Direct", - "requested": "[10.0.0, )", - "resolved": "10.0.0", - "contentHash": "WFejCcOUR6k8UYyDnnR6Gk+obFYMsWrZuNqPJnsVFGVhpPSN0y20D4qbdKJnXinYGx9PQ397Hf9TnU1NBST8vA==" + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "27jXSV/0DbVqF5jDrAxuQFZ9oaz6gmG03p8ttxAFk+X0M4woFYj7MoWDLCna5EGLb0CE6OE7X6ZH3Wt5smTtaA==" }, "Microsoft.NET.Test.Sdk": { "type": "Direct", - "requested": "[18.5.1, )", - "resolved": "18.5.1", - "contentHash": "SfqVaLiIqAbRWuPg5BP4QFwBIirQj/YIL8Dhxl6zntBKbXp0cQykoV480SmwG+yRMiWptxEI6NbHQuGSZ8b97w==", + "requested": "[18.6.0, )", + "resolved": "18.6.0", + "contentHash": "kAIBt0MsYR0o2RULmlW5BhQ1ha50aGEgLKG4f1p0kePBGLJCprqs3S+NxRrYN8UH7mSQRPKpeiH9mwPMEKUObQ==", "dependencies": { - "Microsoft.CodeCoverage": "18.5.1", - "Microsoft.TestPlatform.TestHost": "18.5.1" + "Microsoft.CodeCoverage": "18.6.0", + "Microsoft.TestPlatform.TestHost": "18.6.0" } }, "Moq": { @@ -71,8 +71,8 @@ }, "Markdig": { "type": "Transitive", - "resolved": "1.1.3", - "contentHash": "wboYc6YToID7koq+onej7rjd/kv4urZaZ4QMiJoF/jXt/ZlXe2Af9gWG4nOe5tk654UaOPYvKL64iFum7qWEYA==" + "resolved": "1.2.0", + "contentHash": "LoBVOgeDWfHthWTPzQJkjDkxImqBXRA5XpuTyjdM4SAURyWQGG1CbEEbfPbmvAavggnAZPaTYpbhIPUurWvIXA==" }, "Microsoft.ApplicationInsights": { "type": "Transitive", @@ -86,8 +86,8 @@ }, "Microsoft.CodeCoverage": { "type": "Transitive", - "resolved": "18.5.1", - "contentHash": "vMFDR1ZjqzzgKmM0zrPie7Gv9Y+ZppjODB5Quzu9Eq0TlIusUfUCYFPEawO91zQuqwzvdFbJSU7WHNtjStffJQ==" + "resolved": "18.6.0", + "contentHash": "bkmCXn/65Cd0LdO2zTb/ValGAJ1H8y/CgYOiBb3jsDyHI3Y1ljKx6RBvhvn3e5D/4R4I00RRwLf+Bd2Sn6bJjA==" }, "Microsoft.Testing.Extensions.Telemetry": { "type": "Transitive", @@ -121,22 +121,22 @@ }, "Microsoft.TestPlatform.ObjectModel": { "type": "Transitive", - "resolved": "18.5.1", - "contentHash": "KNZd+M0S0rz5eNAln0pbZX+A/RbokYZCbGKx4fN4CkhtWhkz6nSJDO+9LGYjRE4d0WPVriJ2JnVubkjt3+PpMg==" + "resolved": "18.6.0", + "contentHash": "gQTW4BIfM2ZLxixo9ITXoulLKjn20FiiHtqTsx9PENqTrX7368ZeJ5L0QZJyReXDWORPRV8jXwZR6Aar8JOyaA==" }, "Microsoft.TestPlatform.TestHost": { "type": "Transitive", - "resolved": "18.5.1", - "contentHash": "RM+3JNHEoHOCFXzVntUcIiYxzPjzBN0N8wto6HYXi76YyBTZ/3CeRL8U+Pk5zx3AUrOmHxDvKJwGUCdElU9bJg==", + "resolved": "18.6.0", + "contentHash": "em1eLz5Q46+hsCtAXdXggWAPd9gQyT4ngdsQ7k1eWvQgpsjtS/wAOJ/5TteieFdiAvrEq1iVn00LtusAxRaVmQ==", "dependencies": { - "Microsoft.TestPlatform.ObjectModel": "18.5.1", + "Microsoft.TestPlatform.ObjectModel": "18.6.0", "Newtonsoft.Json": "13.0.3" } }, "Microsoft.Web.WebView2": { "type": "Transitive", - "resolved": "1.0.3912.50", - "contentHash": "mEIKZDSmb6CxGWKoxaxTSJ0yi0DD2zp29TA7kJY2GdanuH3c5aYruwfqsm3OsYn/h2hUhybQZvfbxyZe4O+X4Q==" + "resolved": "1.0.3967.48", + "contentHash": "b7dZ5kZ9bfU5Yo11M8feCOoiyalXCN+2Gsw674RBDBmQ2dJV5EOXb8/ivN2LuLJ+VO2V7FmK6l6LsyfEnG1lXw==" }, "Microsoft.Win32.Registry": { "type": "Transitive", @@ -225,13 +225,19 @@ "dependencies": { "AvalonEdit": "[6.3.1.120, )", "Cylinder.WPF": "[1.0.0-preview.2, )", - "Markdig": "[1.1.3, )", - "Microsoft.Web.WebView2": "[1.0.3912.50, )", + "Markdig": "[1.2.0, )", + "Microsoft.Web.WebView2": "[1.0.3967.48, )", "WinQuickLook.CsWin32": "[1.0.0, )" } }, "winquicklook.cswin32": { "type": "Project" + }, + "winquicklook.preview": { + "type": "Project", + "dependencies": { + "Markdig": "[1.2.0, )" + } } } } From 6ba9a64eabf7d1d65afa1486feb54b57742b0ec2 Mon Sep 17 00:00:00 2001 From: Tatsuro Shibamura Date: Wed, 27 May 2026 22:28:47 +0900 Subject: [PATCH 2/2] Add WindowComposition and refine preview UI Introduce WindowComposition (DWM attribute helpers) and use it to cloak windows and enable rounded corners and dark mode. Retrieve native HWNDs via WindowNative/GetWindowHandle and cloak the hidden message window used for the tray icon. Refactor PreviewWindow to improve UX and reduce visual flicker: add a chrome header with title/subtitle/counter/prev/next/close controls, navigation state handling, SwapContent/IsBoundsChanging logic, and a RevealWindowAsync that uncloaks after layout. Use cloaking around size-changing content swaps instead of animated window-bound interpolation, and remove the old open animation/bounds animation code. Adjust many layout, spacing, typography, icon, and sizing calculations (images, text, media, metadata, unsupported view) for a more consistent appearance and behavior. --- src/WinQuickLook.App.WinUI/App.xaml.cs | 8 +- src/WinQuickLook.App.WinUI/PreviewWindow.xaml | 126 ++++-- .../PreviewWindow.xaml.cs | 410 ++++++++++-------- .../WindowComposition.cs | 32 ++ 4 files changed, 359 insertions(+), 217 deletions(-) create mode 100644 src/WinQuickLook.App.WinUI/WindowComposition.cs diff --git a/src/WinQuickLook.App.WinUI/App.xaml.cs b/src/WinQuickLook.App.WinUI/App.xaml.cs index 0681cc6c..227b3b1e 100644 --- a/src/WinQuickLook.App.WinUI/App.xaml.cs +++ b/src/WinQuickLook.App.WinUI/App.xaml.cs @@ -55,11 +55,15 @@ protected override void OnLaunched(LaunchActivatedEventArgs args) private void ConfigureNotifyIcon() { _messageWindow = new Window(); + + var hwnd = WindowNative.GetWindowHandle(_messageWindow); + + WindowComposition.SetCloaked(hwnd, true); _messageWindow.Activate(); - NativeNotifyIcon.HideWindow(WindowNative.GetWindowHandle(_messageWindow)); + NativeNotifyIcon.HideWindow(hwnd); _notifyIcon = new NativeNotifyIcon( - WindowNative.GetWindowHandle(_messageWindow), + hwnd, AppParameters.Title, () => _dispatcherQueue.TryEnqueue(PerformPreview), () => _dispatcherQueue.TryEnqueue(ExitApplication)); diff --git a/src/WinQuickLook.App.WinUI/PreviewWindow.xaml b/src/WinQuickLook.App.WinUI/PreviewWindow.xaml index 5b69d017..d78b6378 100644 --- a/src/WinQuickLook.App.WinUI/PreviewWindow.xaml +++ b/src/WinQuickLook.App.WinUI/PreviewWindow.xaml @@ -7,70 +7,122 @@ Background="Transparent" IsTabStop="True" KeyDown="Root_KeyDown"> - - - - + + +