diff --git a/README.md b/README.md
index 334d555..abb78e6 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 5cb8472..0478223 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 0000000..bff833e
--- /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 0000000..227b3b1
--- /dev/null
+++ b/src/WinQuickLook.App.WinUI/App.xaml.cs
@@ -0,0 +1,159 @@
+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();
+
+ var hwnd = WindowNative.GetWindowHandle(_messageWindow);
+
+ WindowComposition.SetCloaked(hwnd, true);
+ _messageWindow.Activate();
+ NativeNotifyIcon.HideWindow(hwnd);
+
+ _notifyIcon = new NativeNotifyIcon(
+ hwnd,
+ 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 0000000..03e9c47
--- /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 0000000..30266e1
--- /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 0000000..d78b637
--- /dev/null
+++ b/src/WinQuickLook.App.WinUI/PreviewWindow.xaml
@@ -0,0 +1,141 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/WinQuickLook.App.WinUI/PreviewWindow.xaml.cs b/src/WinQuickLook.App.WinUI/PreviewWindow.xaml.cs
new file mode 100644
index 0000000..564d829
--- /dev/null
+++ b/src/WinQuickLook.App.WinUI/PreviewWindow.xaml.cs
@@ -0,0 +1,835 @@
+using System;
+using System.Collections.Generic;
+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;
+
+using WinRT.Interop;
+
+namespace WinQuickLook.App.WinUI;
+
+public sealed partial class PreviewWindow
+{
+ private static readonly TimeSpan CrossfadeDuration = TimeSpan.FromMilliseconds(140);
+ private static readonly SizeInt32 FallbackWindowSize = new(760, 520);
+
+ private readonly IPreviewProvider _previewProvider;
+ private readonly IReadOnlyList _filePaths;
+ private readonly nint _hwnd;
+
+ private int _currentIndex;
+ private bool _isPrimaryContentActive = true;
+ private bool _isSwitching;
+ private bool _revealed;
+
+ public PreviewWindow(IPreviewProvider previewProvider, PreviewRequest request)
+ {
+ InitializeComponent();
+
+ _previewProvider = previewProvider;
+ _filePaths = NormalizeFilePaths(request);
+ _currentIndex = Math.Clamp(request.CurrentIndex, 0, Math.Max(_filePaths.Count - 1, 0));
+
+ _hwnd = WindowNative.GetWindowHandle(this);
+ WindowComposition.SetCloaked(_hwnd, true);
+ WindowComposition.EnableRoundedCorners(_hwnd);
+
+ ConfigureWindow();
+ ConfigureNavigationChrome();
+
+ Root.Loaded += Root_Loaded;
+ }
+
+ private async void Root_Loaded(object sender, RoutedEventArgs e)
+ {
+ Root.Focus(FocusState.Programmatic);
+
+ await ShowCurrentAsync(false);
+ await RevealWindowAsync();
+ }
+
+ private async Task RevealWindowAsync()
+ {
+ if (_revealed)
+ {
+ return;
+ }
+
+ _revealed = true;
+
+ // Yield once so the layout pass that follows MoveAndResize has been
+ // composited by DWM before we uncloak — otherwise the first visible
+ // frame can show the previous (fallback) size with Mica not yet
+ // recomputed for the new bounds.
+ await Task.Yield();
+
+ WindowComposition.SetCloaked(_hwnd, false);
+ }
+
+ private void ConfigureWindow()
+ {
+ Title = "WinQuickLook Preview";
+ SystemBackdrop = new MicaBackdrop { Kind = MicaKind.BaseAlt };
+ ExtendsContentIntoTitleBar = true;
+ SetTitleBar(DragRegion);
+
+ AppWindow.Title = Title;
+ AppWindow.IsShownInSwitchers = false;
+
+ if (AppWindow.Presenter is OverlappedPresenter presenter)
+ {
+ presenter.IsMaximizable = false;
+ presenter.IsMinimizable = false;
+ presenter.IsResizable = true;
+ presenter.SetBorderAndTitleBar(true, false);
+ }
+
+ AppWindow.MoveAndResize(GetCenteredBounds(FallbackWindowSize));
+ }
+
+ private void ConfigureNavigationChrome()
+ {
+ var hasSiblings = _filePaths.Count > 1;
+ var visibility = hasSiblings ? Visibility.Visible : Visibility.Collapsed;
+
+ CounterPill.Visibility = visibility;
+ PrevButton.Visibility = visibility;
+ NextButton.Visibility = visibility;
+ HeaderSeparator.Visibility = visibility;
+
+ UpdateNavigationState();
+ }
+
+ private void UpdateNavigationState()
+ {
+ if (_filePaths.Count <= 1)
+ {
+ return;
+ }
+
+ PrevButton.IsEnabled = _currentIndex > 0;
+ NextButton.IsEnabled = _currentIndex < _filePaths.Count - 1;
+ }
+
+ 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);
+
+ UpdateHeader(result, result.FilePath);
+
+ var sizeChanging = IsBoundsChanging(renderedPreview.WindowBounds);
+
+ if (crossfade && !sizeChanging)
+ {
+ // Same size — content crossfade looks clean.
+ await CrossfadeToAsync(renderedPreview.Content);
+ }
+ else if (crossfade)
+ {
+ // Resize is visible; cloak the window across the resize +
+ // content swap so the user never sees a half-laid-out frame
+ // or a Mica recompute flash.
+ WindowComposition.SetCloaked(_hwnd, true);
+ try
+ {
+ SwapContent(renderedPreview.Content);
+ AppWindow.MoveAndResize(renderedPreview.WindowBounds);
+ await Task.Yield();
+ }
+ finally
+ {
+ WindowComposition.SetCloaked(_hwnd, false);
+ }
+ }
+ else
+ {
+ SwapContent(renderedPreview.Content);
+ AppWindow.MoveAndResize(renderedPreview.WindowBounds);
+ }
+ }
+ catch (Exception ex) when (ex is FileNotFoundException or UnauthorizedAccessException or IOException or ArgumentException)
+ {
+ ShowUnsupported("The file could not be opened.");
+ }
+ }
+
+ private bool IsBoundsChanging(RectInt32 target)
+ {
+ var position = AppWindow.Position;
+ var size = AppWindow.Size;
+
+ return position.X != target.X
+ || position.Y != target.Y
+ || size.Width != target.Width
+ || size.Height != target.Height;
+ }
+
+ private void SwapContent(UIElement content)
+ {
+ PrimaryContent.Content = content;
+ PrimaryContent.Opacity = 1;
+ PrimaryContent.Visibility = Visibility.Visible;
+ SecondaryContent.Content = null;
+ SecondaryContent.Opacity = 0;
+ SecondaryContent.Visibility = Visibility.Collapsed;
+ _isPrimaryContentActive = true;
+ }
+
+ 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));
+
+ var fallbackPath = _filePaths.Count == 0 ? string.Empty : _filePaths[_currentIndex];
+
+ TitleText.Text = string.IsNullOrEmpty(fallbackPath) ? "Unsupported preview" : Path.GetFileName(fallbackPath);
+ SubtitleText.Text = string.IsNullOrEmpty(fallbackPath)
+ ? string.Empty
+ : (Path.GetDirectoryName(fallbackPath) ?? 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 UpdateHeader(PreviewResult result, string filePath)
+ {
+ var title = result.Title ?? Path.GetFileName(filePath);
+
+ Title = title;
+ AppWindow.Title = title;
+ TitleText.Text = title;
+ SubtitleText.Text = BuildSubtitle(filePath);
+
+ if (_filePaths.Count > 1)
+ {
+ CounterText.Text = $"{_currentIndex + 1} / {_filePaths.Count}";
+ }
+
+ UpdateNavigationState();
+ }
+
+ private static string BuildSubtitle(string filePath)
+ {
+ var directory = Path.GetDirectoryName(filePath);
+
+ return string.IsNullOrEmpty(directory) ? filePath : directory;
+ }
+
+ 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 void PrevButton_Click(object sender, RoutedEventArgs e)
+ {
+ await NavigateAsync(-1);
+ }
+
+ private async void NextButton_Click(object sender, RoutedEventArgs e)
+ {
+ await NavigateAsync(1);
+ }
+
+ 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 static Task AnimateOpacityAsync(UIElement target, double to)
+ {
+ var storyboard = new Storyboard();
+ var animation = new DoubleAnimation
+ {
+ To = to,
+ Duration = CrossfadeDuration,
+ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }
+ };
+
+ Storyboard.SetTarget(animation, target);
+ Storyboard.SetTargetProperty(animation, nameof(UIElement.Opacity));
+ storyboard.Children.Add(animation);
+
+ 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,
+ Margin = new Thickness(8)
+ };
+ }
+
+ 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 = 12.5,
+ IsHitTestVisible = false,
+ Opacity = 0.4,
+ TextAlignment = TextAlignment.Right
+ };
+ var textBlock = new TextBlock
+ {
+ Text = text,
+ FontFamily = fontFamily,
+ FontSize = 12.5,
+ 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(16, 14, 16, 14),
+ Content = grid
+ };
+ }
+
+ private static UIElement CreateUnsupportedView(string message)
+ {
+ var stackPanel = new StackPanel
+ {
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ Spacing = 10
+ };
+
+ stackPanel.Children.Add(new FontIcon
+ {
+ FontFamily = new FontFamily("Segoe Fluent Icons"),
+ FontSize = 36,
+ Glyph = "\uE7BA",
+ Opacity = 0.55
+ });
+ stackPanel.Children.Add(new TextBlock
+ {
+ Text = message,
+ FontSize = 13,
+ Opacity = 0.78,
+ TextAlignment = TextAlignment.Center,
+ TextWrapping = TextWrapping.Wrap,
+ MaxWidth = 360
+ });
+
+ 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)
+ {
+ mediaPlayerElement.Margin = new Thickness(0);
+
+ return mediaPlayerElement;
+ }
+
+ var file = await StorageFile.GetFileFromPathAsync(payload.FilePath);
+ var properties = await file.Properties.GetMusicPropertiesAsync();
+ var title = string.IsNullOrWhiteSpace(properties.Title) ? Path.GetFileNameWithoutExtension(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(32, 28, 32, 24),
+ RowSpacing = 18
+ };
+
+ 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 = 8
+ };
+
+ var artworkContainer = new Border
+ {
+ Width = 132,
+ Height = 132,
+ CornerRadius = new CornerRadius(16),
+ Background = Application.Current.Resources["SubtleFillColorSecondaryBrush"] as Brush,
+ Margin = new Thickness(0, 0, 0, 8)
+ };
+
+ artworkContainer.Child = new FontIcon
+ {
+ FontFamily = new FontFamily("Segoe Fluent Icons"),
+ FontSize = 56,
+ Glyph = "\uE8D6",
+ Opacity = 0.72
+ };
+
+ stackPanel.Children.Add(artworkContainer);
+ stackPanel.Children.Add(new TextBlock
+ {
+ Text = title,
+ FontSize = 20,
+ FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,
+ TextAlignment = TextAlignment.Center,
+ TextWrapping = TextWrapping.Wrap,
+ MaxWidth = 360
+ });
+ stackPanel.Children.Add(new TextBlock
+ {
+ Text = string.IsNullOrWhiteSpace(album) ? artist : $"{artist} · {album}",
+ FontSize = 13,
+ Opacity = 0.7,
+ TextAlignment = TextAlignment.Center,
+ TextWrapping = TextWrapping.Wrap,
+ MaxWidth = 360
+ });
+
+ 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(32, 28, 32, 28),
+ ColumnSpacing = 28
+ };
+
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
+
+ var iconContainer = new Border
+ {
+ Width = 132,
+ Height = 132,
+ CornerRadius = new CornerRadius(18),
+ Background = Application.Current.Resources["SubtleFillColorSecondaryBrush"] as Brush,
+ VerticalAlignment = VerticalAlignment.Center
+ };
+
+ iconContainer.Child = new FontIcon
+ {
+ FontFamily = new FontFamily("Segoe Fluent Icons"),
+ FontSize = 64,
+ Glyph = payload.IsDirectory ? "\uE8B7" : "\uE7C3",
+ Opacity = 0.78,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center
+ };
+
+ var stackPanel = new StackPanel
+ {
+ VerticalAlignment = VerticalAlignment.Center,
+ Spacing = 10
+ };
+
+ stackPanel.Children.Add(new TextBlock
+ {
+ Text = Path.GetFileName(payload.Path),
+ FontSize = 20,
+ FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,
+ TextWrapping = TextWrapping.Wrap
+ });
+ stackPanel.Children.Add(CreateMetadataRow("\uE7C3", payload.TypeName));
+ stackPanel.Children.Add(CreateMetadataRow("\uE9CE", payload.Detail));
+ stackPanel.Children.Add(CreateMetadataRow("\uE787", payload.LastWriteTime.ToString("G")));
+
+ Grid.SetColumn(stackPanel, 1);
+ grid.Children.Add(iconContainer);
+ grid.Children.Add(stackPanel);
+
+ return grid;
+ }
+
+ private static UIElement CreateMetadataRow(string glyph, string text)
+ {
+ var stackPanel = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ Spacing = 10
+ };
+
+ stackPanel.Children.Add(new FontIcon
+ {
+ FontFamily = new FontFamily("Segoe Fluent Icons"),
+ FontSize = 12,
+ Glyph = glyph,
+ Opacity = 0.55,
+ VerticalAlignment = VerticalAlignment.Center,
+ Width = 16
+ });
+ stackPanel.Children.Add(new TextBlock
+ {
+ Text = text,
+ FontSize = 13.5,
+ Opacity = 0.78,
+ VerticalAlignment = VerticalAlignment.Center,
+ TextWrapping = TextWrapping.Wrap
+ });
+
+ return stackPanel;
+ }
+
+ 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 - 64, 360);
+ var maxImageHeight = Math.Max(maxHeight - 110, 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 + 64, 500, maxWidth);
+ var windowHeight = Math.Clamp(contentHeight + 110, 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(720, 460));
+ }
+
+ 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.2) + 200, 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 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 0000000..bbd50ab
--- /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/WindowComposition.cs b/src/WinQuickLook.App.WinUI/WindowComposition.cs
new file mode 100644
index 0000000..5723fca
--- /dev/null
+++ b/src/WinQuickLook.App.WinUI/WindowComposition.cs
@@ -0,0 +1,32 @@
+using System.Runtime.InteropServices;
+
+namespace WinQuickLook.App.WinUI;
+
+internal static class WindowComposition
+{
+ private const int DWMWA_CLOAK = 13;
+ private const int DWMWA_WINDOW_CORNER_PREFERENCE = 33;
+ private const int DWMWA_USE_IMMERSIVE_DARK_MODE = 20;
+ private const int DWMWCP_ROUND = 2;
+
+ public static void SetCloaked(nint hwnd, bool cloaked)
+ {
+ var value = cloaked ? 1 : 0;
+ DwmSetWindowAttribute(hwnd, DWMWA_CLOAK, ref value, sizeof(int));
+ }
+
+ public static void EnableRoundedCorners(nint hwnd)
+ {
+ var value = DWMWCP_ROUND;
+ DwmSetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, ref value, sizeof(int));
+ }
+
+ public static void SetImmersiveDarkMode(nint hwnd, bool enabled)
+ {
+ var value = enabled ? 1 : 0;
+ DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, ref value, sizeof(int));
+ }
+
+ [DllImport("dwmapi.dll", PreserveSig = true)]
+ private static extern int DwmSetWindowAttribute(nint hwnd, int attr, ref int value, int size);
+}
diff --git a/src/WinQuickLook.App.WinUI/app.manifest b/src/WinQuickLook.App.WinUI/app.manifest
new file mode 100644
index 0000000..aa36b8c
--- /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 0000000..c716942
--- /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 c4de391..9584e2c 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 9f6ddb3..bf5d791 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 3b4e03e..49a809f 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 d21b063..3366109 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 0e26d4c..95622b0 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 0000000..63a5c2b
--- /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 0000000..4c9ff96
--- /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 0000000..1563311
--- /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 0000000..ff9565d
--- /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 0000000..9a13e5d
--- /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 0000000..b11f852
--- /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 0000000..1556bcc
--- /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 0000000..a0b9339
--- /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 0000000..573f325
--- /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 0000000..7064404
--- /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 0000000..4911525
--- /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 0000000..c43be03
--- /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 0000000..ca6ee85
--- /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 0000000..ace5eb2
--- /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 0000000..49b0352
--- /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 0000000..a3aa563
--- /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 0000000..70f4858
--- /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 0000000..7e30603
--- /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 0000000..d0b0458
--- /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 0000000..500914c
--- /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 0000000..828670d
--- /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 0000000..78988b8
--- /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 0000000..25ca41b
--- /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 0000000..8383a02
--- /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 0000000..ccc4eec
--- /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 0000000..c7ae776
--- /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 db71c3e..146fc02 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 a97da18..e874339 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, )"
+ }
}
}
}