mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-02-23 19:49:43 +01:00
Merge branch 'main' of https://github.com/microsoft/PowerToys into net10-clean
This commit is contained in:
8
.github/actions/spell-check/expect.txt
vendored
8
.github/actions/spell-check/expect.txt
vendored
@@ -44,6 +44,7 @@ ALLCHILDREN
|
||||
ALLINPUT
|
||||
Allman
|
||||
Allmodule
|
||||
ALLNOISE
|
||||
ALLOWUNDO
|
||||
ALLVIEW
|
||||
ALPHATYPE
|
||||
@@ -129,6 +130,7 @@ bezelled
|
||||
bhid
|
||||
BIF
|
||||
bigbar
|
||||
BIGGERSIZEOK
|
||||
bigobj
|
||||
binlog
|
||||
binres
|
||||
@@ -310,6 +312,7 @@ CRECT
|
||||
CRH
|
||||
critsec
|
||||
cropandlock
|
||||
CROPTOSQUARE
|
||||
Crossdevice
|
||||
csdevkit
|
||||
CSearch
|
||||
@@ -760,6 +763,7 @@ IAI
|
||||
icf
|
||||
ICONERROR
|
||||
ICONLOCATION
|
||||
ICONONLY
|
||||
IDCANCEL
|
||||
IDD
|
||||
idk
|
||||
@@ -899,6 +903,7 @@ LEFTTEXT
|
||||
LError
|
||||
LEVELID
|
||||
LExit
|
||||
LFU
|
||||
lhwnd
|
||||
LIBFUZZER
|
||||
LIBID
|
||||
@@ -1670,6 +1675,7 @@ sigdn
|
||||
Signedness
|
||||
SIGNINGSCENARIO
|
||||
signtool
|
||||
SIIGBF
|
||||
SINGLEKEY
|
||||
sipolicy
|
||||
SIZEBOX
|
||||
@@ -2174,4 +2180,4 @@ Zoneszonabletester
|
||||
Zoomin
|
||||
zoomit
|
||||
ZOOMITX
|
||||
Zorder
|
||||
Zorder
|
||||
|
||||
@@ -250,7 +250,6 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam)
|
||||
|
||||
Logger::info(L"[LightSwitchService] Initialized at {:02d}:{:02d}.", st.wHour, st.wMinute);
|
||||
stateManager.SyncInitialThemeState();
|
||||
stateManager.OnTick(nowMinutes);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Worker Loop
|
||||
@@ -281,7 +280,7 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam)
|
||||
GetLocalTime(&st);
|
||||
nowMinutes = st.wHour * 60 + st.wMinute;
|
||||
DetectAndHandleExternalThemeChange(stateManager);
|
||||
stateManager.OnTick(nowMinutes);
|
||||
stateManager.OnTick();
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ void LightSwitchStateManager::OnSettingsChanged()
|
||||
}
|
||||
|
||||
// Called once per minute
|
||||
void LightSwitchStateManager::OnTick(int currentMinutes)
|
||||
void LightSwitchStateManager::OnTick()
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(_stateMutex);
|
||||
if (_state.lastAppliedMode != ScheduleMode::FollowNightLight)
|
||||
@@ -109,10 +109,14 @@ void LightSwitchStateManager::SyncInitialThemeState()
|
||||
std::lock_guard<std::mutex> lock(_stateMutex);
|
||||
_state.isSystemLightActive = GetCurrentSystemTheme();
|
||||
_state.isAppsLightActive = GetCurrentAppsTheme();
|
||||
_state.isNightLightActive = IsNightLightEnabled();
|
||||
Logger::debug(L"[LightSwitchStateManager] Synced initial state to current system theme ({})",
|
||||
_state.isSystemLightActive ? L"light" : L"dark");
|
||||
Logger::debug(L"[LightSwitchStateManager] Synced initial state to current apps theme ({})",
|
||||
_state.isAppsLightActive ? L"light" : L"dark");
|
||||
|
||||
// This will ensure that the theme is applied according to current settings at startup
|
||||
EvaluateAndApplyIfNeeded();
|
||||
}
|
||||
|
||||
static std::pair<int, int> update_sun_times(auto& settings)
|
||||
|
||||
@@ -28,7 +28,7 @@ public:
|
||||
void OnSettingsChanged();
|
||||
|
||||
// Called every minute (from service worker tick).
|
||||
void OnTick(int currentMinutes);
|
||||
void OnTick();
|
||||
|
||||
// Called when manual override is toggled (via shortcut or system change).
|
||||
void OnManualOverride();
|
||||
|
||||
@@ -32,6 +32,7 @@ using Microsoft.CmdPal.UI.ViewModels.Services;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.PowerToys.Telemetry;
|
||||
using Microsoft.UI.Dispatching;
|
||||
using Microsoft.UI.Xaml;
|
||||
|
||||
// To learn more about WinUI, the WinUI project structure,
|
||||
@@ -72,6 +73,8 @@ public partial class App : Application, IDisposable
|
||||
|
||||
Services = ConfigureServices();
|
||||
|
||||
IconCacheProvider.Initialize(Services);
|
||||
|
||||
this.InitializeComponent();
|
||||
|
||||
// Ensure types used in XAML are preserved for AOT compilation
|
||||
@@ -113,12 +116,13 @@ public partial class App : Application, IDisposable
|
||||
|
||||
// Root services
|
||||
services.AddSingleton(TaskScheduler.FromCurrentSynchronizationContext());
|
||||
var dispatcherQueue = DispatcherQueue.GetForCurrentThread();
|
||||
|
||||
AddBuiltInCommands(services);
|
||||
|
||||
AddCoreServices(services);
|
||||
|
||||
AddUIServices(services);
|
||||
AddUIServices(services, dispatcherQueue);
|
||||
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
@@ -169,7 +173,7 @@ public partial class App : Application, IDisposable
|
||||
services.AddSingleton<ICommandProvider, RemoteDesktopCommandProvider>();
|
||||
}
|
||||
|
||||
private static void AddUIServices(ServiceCollection services)
|
||||
private static void AddUIServices(ServiceCollection services, DispatcherQueue dispatcherQueue)
|
||||
{
|
||||
// Models
|
||||
var sm = SettingsModel.LoadSettings();
|
||||
@@ -188,6 +192,8 @@ public partial class App : Application, IDisposable
|
||||
|
||||
services.AddSingleton<IThemeService, ThemeService>();
|
||||
services.AddSingleton<ResourceSwapper>();
|
||||
|
||||
services.AddIconServices(dispatcherQueue);
|
||||
}
|
||||
|
||||
private static void AddCoreServices(ServiceCollection services)
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
Margin="4,0,0,0"
|
||||
HorizontalAlignment="Left"
|
||||
SourceKey="{x:Bind Icon, Mode=OneWay}"
|
||||
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" />
|
||||
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested20}" />
|
||||
<TextBlock
|
||||
x:Name="TitleTextBlock"
|
||||
Grid.Column="1"
|
||||
@@ -83,7 +83,7 @@
|
||||
HorizontalAlignment="Left"
|
||||
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
|
||||
SourceKey="{x:Bind Icon, Mode=OneWay}"
|
||||
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" />
|
||||
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested20}" />
|
||||
<TextBlock
|
||||
x:Name="TitleTextBlock"
|
||||
Grid.Column="1"
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
Height="16"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
SourceKey="{x:Bind Icon, Mode=OneWay}"
|
||||
SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested}" />
|
||||
SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested20}" />
|
||||
</cpcontrols:ContentIcon.Content>
|
||||
</cpcontrols:ContentIcon>
|
||||
</controls:SettingsCard.HeaderIcon>
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Center"
|
||||
SourceKey="{x:Bind Icon}"
|
||||
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" />
|
||||
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested20}" />
|
||||
<TextBlock
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
|
||||
@@ -2,15 +2,11 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using CommunityToolkit.WinUI.Deferred;
|
||||
using ManagedCommon;
|
||||
using Microsoft.CmdPal.Core.ViewModels;
|
||||
using Microsoft.CmdPal.UI.Deferred;
|
||||
using Microsoft.Terminal.UI;
|
||||
using Microsoft.UI.Dispatching;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Input;
|
||||
|
||||
using Windows.Foundation;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.Controls;
|
||||
@@ -20,7 +16,11 @@ namespace Microsoft.CmdPal.UI.Controls;
|
||||
/// </summary>
|
||||
public partial class IconBox : ContentControl
|
||||
{
|
||||
private readonly DispatcherQueue _queue = DispatcherQueue.GetForCurrentThread();
|
||||
private double _lastScale;
|
||||
private ElementTheme _lastTheme;
|
||||
private double _lastFontSize;
|
||||
|
||||
private const double DefaultIconFontSize = 16.0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the <see cref="IconSource"/> to display within the <see cref="IconBox"/>. Overwritten, if <see cref="SourceKey"/> is used instead.
|
||||
@@ -48,10 +48,23 @@ public partial class IconBox : ContentControl
|
||||
public static readonly DependencyProperty SourceKeyProperty =
|
||||
DependencyProperty.Register(nameof(SourceKey), typeof(object), typeof(IconBox), new PropertyMetadata(null, OnSourceKeyPropertyChanged));
|
||||
|
||||
private TypedEventHandler<IconBox, SourceRequestedEventArgs>? _sourceRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the <see cref="SourceRequested"/> event handler to provide the value of the <see cref="IconSource"/> for the <see cref="Source"/> property from the provided <see cref="SourceKey"/>.
|
||||
/// </summary>
|
||||
public event TypedEventHandler<IconBox, SourceRequestedEventArgs>? SourceRequested;
|
||||
public event TypedEventHandler<IconBox, SourceRequestedEventArgs>? SourceRequested
|
||||
{
|
||||
add
|
||||
{
|
||||
_sourceRequested += value;
|
||||
if (_sourceRequested?.GetInvocationList().Length == 1)
|
||||
{
|
||||
Refresh();
|
||||
}
|
||||
}
|
||||
remove => _sourceRequested -= value;
|
||||
}
|
||||
|
||||
public IconBox()
|
||||
{
|
||||
@@ -59,119 +72,208 @@ public partial class IconBox : ContentControl
|
||||
IsTabStop = false;
|
||||
HorizontalContentAlignment = HorizontalAlignment.Center;
|
||||
VerticalContentAlignment = VerticalAlignment.Center;
|
||||
|
||||
Loaded += OnLoaded;
|
||||
Unloaded += OnUnloaded;
|
||||
ActualThemeChanged += OnActualThemeChanged;
|
||||
SizeChanged += OnSizeChanged;
|
||||
|
||||
UpdateLastFontSize();
|
||||
}
|
||||
|
||||
private void UpdateLastFontSize()
|
||||
{
|
||||
_lastFontSize =
|
||||
Pick(Width)
|
||||
?? Pick(Height)
|
||||
?? Pick(ActualWidth)
|
||||
?? Pick(ActualHeight)
|
||||
?? DefaultIconFontSize;
|
||||
|
||||
return;
|
||||
|
||||
static double? Pick(double value) => double.IsFinite(value) && value > 0 ? value : null;
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object s, SizeChangedEventArgs e)
|
||||
{
|
||||
UpdateLastFontSize();
|
||||
|
||||
if (Source is FontIconSource fontIcon)
|
||||
{
|
||||
fontIcon.FontSize = _lastFontSize;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnActualThemeChanged(FrameworkElement sender, object args)
|
||||
{
|
||||
if (_lastTheme == ActualTheme)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_lastTheme = ActualTheme;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void OnLoaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_lastTheme = ActualTheme;
|
||||
UpdateLastFontSize();
|
||||
|
||||
if (XamlRoot is not null)
|
||||
{
|
||||
_lastScale = XamlRoot.RasterizationScale;
|
||||
XamlRoot.Changed += OnXamlRootChanged;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnUnloaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (XamlRoot is not null)
|
||||
{
|
||||
XamlRoot.Changed -= OnXamlRootChanged;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnXamlRootChanged(XamlRoot sender, XamlRootChangedEventArgs args)
|
||||
{
|
||||
var newScale = sender.RasterizationScale;
|
||||
var changedLastTheme = _lastTheme != ActualTheme;
|
||||
_lastTheme = ActualTheme;
|
||||
if ((changedLastTheme || Math.Abs(newScale - _lastScale) > 0.01) && SourceKey is not null)
|
||||
{
|
||||
_lastScale = newScale;
|
||||
UpdateSourceKey(this, SourceKey);
|
||||
}
|
||||
}
|
||||
|
||||
private void Refresh()
|
||||
{
|
||||
if (SourceKey is not null)
|
||||
{
|
||||
UpdateSourceKey(this, SourceKey);
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnSourcePropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (d is IconBox @this)
|
||||
if (d is not IconBox self)
|
||||
{
|
||||
switch (e.NewValue)
|
||||
{
|
||||
case null:
|
||||
@this.Content = null;
|
||||
break;
|
||||
case FontIconSource fontIco:
|
||||
fontIco.FontSize = double.IsNaN(@this.Width) ? @this.Height : @this.Width;
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.NewValue)
|
||||
{
|
||||
case null:
|
||||
self.Content = null;
|
||||
self.Padding = default;
|
||||
break;
|
||||
case FontIconSource fontIcon:
|
||||
if (self.Content is IconSourceElement iconSourceElement)
|
||||
{
|
||||
iconSourceElement.IconSource = fontIcon;
|
||||
}
|
||||
else
|
||||
{
|
||||
fontIcon.FontSize = self._lastFontSize;
|
||||
|
||||
// For inexplicable reasons, FontIconSource.CreateIconElement
|
||||
// doesn't work, so do it ourselves
|
||||
// TODO: File platform bug?
|
||||
IconSourceElement elem = new()
|
||||
{
|
||||
IconSource = fontIco,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
IconSource = fontIcon,
|
||||
};
|
||||
@this.Content = elem;
|
||||
break;
|
||||
case IconSource source:
|
||||
@this.Content = source.CreateIconElement();
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException($"New value of {e.NewValue} is not of type IconSource.");
|
||||
}
|
||||
self.Content = elem;
|
||||
}
|
||||
|
||||
self.Padding = new Thickness(Math.Round(self._lastFontSize * -0.2));
|
||||
|
||||
break;
|
||||
case BitmapIconSource bitmapIcon:
|
||||
if (self.Content is IconSourceElement iconSourceElement2)
|
||||
{
|
||||
iconSourceElement2.IconSource = bitmapIcon;
|
||||
}
|
||||
else
|
||||
{
|
||||
self.Content = bitmapIcon.CreateIconElement();
|
||||
}
|
||||
|
||||
self.Padding = default;
|
||||
|
||||
break;
|
||||
case IconSource source:
|
||||
self.Content = source.CreateIconElement();
|
||||
self.Padding = default;
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException($"New value of {e.NewValue} is not of type IconSource.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnSourceKeyPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (d is IconBox @this)
|
||||
if (d is not IconBox self)
|
||||
{
|
||||
if (e.NewValue is null)
|
||||
return;
|
||||
}
|
||||
|
||||
UpdateSourceKey(self, e.NewValue);
|
||||
}
|
||||
|
||||
private static void UpdateSourceKey(IconBox iconBox, object? sourceKey)
|
||||
{
|
||||
if (sourceKey is null)
|
||||
{
|
||||
iconBox.Source = null;
|
||||
return;
|
||||
}
|
||||
|
||||
Callback(iconBox, sourceKey);
|
||||
}
|
||||
|
||||
private static async void Callback(IconBox iconBox, object? sourceKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
var iconBoxSourceRequestedHandler = iconBox._sourceRequested;
|
||||
|
||||
if (iconBoxSourceRequestedHandler is null)
|
||||
{
|
||||
@this.Source = null;
|
||||
return;
|
||||
}
|
||||
else
|
||||
|
||||
var eventArgs = new SourceRequestedEventArgs(sourceKey, iconBox._lastTheme, iconBox._lastScale);
|
||||
await iconBoxSourceRequestedHandler.InvokeAsync(iconBox, eventArgs);
|
||||
|
||||
// After the await:
|
||||
// Is the icon we're looking up now, the one we still
|
||||
// want to find? Since this IconBox might be used in a
|
||||
// list virtualization situation, it's very possible we
|
||||
// may have already been set to a new icon before we
|
||||
// even got back from the await.
|
||||
if (eventArgs.Key != sourceKey)
|
||||
{
|
||||
// TODO GH #239 switch back when using the new MD text block
|
||||
// Switching back to EnqueueAsync has broken icons in tags (they don't show)
|
||||
// _ = @this._queue.EnqueueAsync(() =>
|
||||
@this._queue.TryEnqueue(async void () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (@this.SourceRequested is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var requestedTheme = @this.ActualTheme;
|
||||
var eventArgs = new SourceRequestedEventArgs(e.NewValue, requestedTheme);
|
||||
|
||||
await @this.SourceRequested.InvokeAsync(@this, eventArgs);
|
||||
|
||||
// After the await:
|
||||
// Is the icon we're looking up now, the one we still
|
||||
// want to find? Since this IconBox might be used in a
|
||||
// list virtualization situation, it's very possible we
|
||||
// may have already been set to a new icon before we
|
||||
// even got back from the await.
|
||||
if (eventArgs.Key != @this.SourceKey)
|
||||
{
|
||||
// If the requested icon has changed, then just bail
|
||||
return;
|
||||
}
|
||||
|
||||
@this.Source = eventArgs.Value;
|
||||
|
||||
// Here's a little lesson in trickery:
|
||||
// Emoji are rendered just a bit bigger than Segoe Icons.
|
||||
// Just enough bigger that they get clipped if you put
|
||||
// them in a box at the same size.
|
||||
//
|
||||
// So, if the icon we get back was a font icon,
|
||||
// and the glyph for that icon is NOT in the range of
|
||||
// Segoe icons, then let's give the icon some extra space
|
||||
var iconData = eventArgs.Key switch
|
||||
{
|
||||
IconDataViewModel key => key,
|
||||
IconInfoViewModel info => requestedTheme == ElementTheme.Light ? info.Light : info.Dark,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (iconData?.Icon is not null && @this.Source is FontIconSource)
|
||||
{
|
||||
var iconSize =
|
||||
!double.IsNaN(@this.Width) ? @this.Width :
|
||||
!double.IsNaN(@this.Height) ? @this.Height :
|
||||
@this.ActualWidth > 0 ? @this.ActualWidth :
|
||||
@this.ActualHeight;
|
||||
|
||||
@this.Padding = new Thickness(Math.Round(iconSize * -0.2));
|
||||
}
|
||||
else
|
||||
{
|
||||
@this.Padding = default;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Exception from TryEnqueue bypasses the global error handler,
|
||||
// and crashes the app.
|
||||
Logger.LogError("Failed to set icon", ex);
|
||||
}
|
||||
});
|
||||
// If the requested icon has changed, then just bail
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventArgs.Value == iconBox.Source)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
iconBox.Source = eventArgs.Value;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Exception from TryEnqueue bypasses the global error handler,
|
||||
// and crashes the app.
|
||||
Logger.LogError("Failed to set icon", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,11 +11,13 @@ namespace Microsoft.CmdPal.UI.Controls;
|
||||
/// <summary>
|
||||
/// See <see cref="IconBox.SourceRequested"/> event.
|
||||
/// </summary>
|
||||
public class SourceRequestedEventArgs(object? key, ElementTheme requestedTheme) : DeferredEventArgs
|
||||
public class SourceRequestedEventArgs(object? key, ElementTheme requestedTheme, double scale = 1.0) : DeferredEventArgs
|
||||
{
|
||||
public object? Key { get; private set; } = key;
|
||||
|
||||
public IconSource? Value { get; set; }
|
||||
|
||||
public ElementTheme Theme => requestedTheme;
|
||||
|
||||
public double Scale => scale;
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
<local:IconBox
|
||||
x:Name="PART_Icon"
|
||||
Grid.Column="0"
|
||||
Width="12"
|
||||
Height="12"
|
||||
Margin="{Binding Text, RelativeSource={RelativeSource TemplatedParent}, Converter={StaticResource IconMarginConverter}}"
|
||||
SourceKey="{TemplateBinding Icon}" />
|
||||
|
||||
@@ -72,7 +72,7 @@ public partial class Tag : Control
|
||||
|
||||
if (GetTemplateChild(TagIconBox) is IconBox iconBox)
|
||||
{
|
||||
iconBox.SourceRequested += IconCacheProvider.SourceRequested;
|
||||
iconBox.SourceRequested += IconCacheProvider.SourceRequested20;
|
||||
iconBox.Visibility = HasIcon ? Visibility.Visible : Visibility.Collapsed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,11 @@
|
||||
<CornerRadius x:Key="SmallGridViewItemCornerRadius">8</CornerRadius>
|
||||
<CornerRadius x:Key="MediumGridViewItemCornerRadius">8</CornerRadius>
|
||||
|
||||
<x:Double x:Key="SmallGridSize">32</x:Double>
|
||||
<x:Double x:Key="MediumGridSize">48</x:Double>
|
||||
<x:Double x:Key="MediumGridContainerSize">100</x:Double>
|
||||
<x:Double x:Key="GalleryGridSize">160</x:Double>
|
||||
|
||||
<x:Double x:Key="ListViewItemMinHeight">40</x:Double>
|
||||
<x:Double x:Key="ListViewSectionMinHeight">0</x:Double>
|
||||
<x:Double x:Key="ListViewSeparatorMinHeight">0</x:Double>
|
||||
@@ -288,7 +293,7 @@
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
SourceKey="{x:Bind Icon, Mode=OneWay}"
|
||||
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" />
|
||||
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested20}" />
|
||||
|
||||
<StackPanel
|
||||
Grid.Column="1"
|
||||
@@ -377,21 +382,21 @@
|
||||
|
||||
<cpcontrols:IconBox
|
||||
x:Name="GridIconBorder"
|
||||
Width="28"
|
||||
Height="28"
|
||||
Width="{StaticResource SmallGridSize}"
|
||||
Height="{StaticResource SmallGridSize}"
|
||||
Margin="0"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{ThemeResource TextFillColorPrimary}"
|
||||
SourceKey="{x:Bind Icon, Mode=OneWay}"
|
||||
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" />
|
||||
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested32}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate x:Key="MediumGridItemViewModelTemplate" x:DataType="coreViewModels:ListItemViewModel">
|
||||
<Grid
|
||||
Width="100"
|
||||
Height="100"
|
||||
Width="{StaticResource MediumGridContainerSize}"
|
||||
Height="{StaticResource MediumGridContainerSize}"
|
||||
Padding="8"
|
||||
AutomationProperties.Name="{x:Bind Title, Mode=OneWay}"
|
||||
CornerRadius="{StaticResource MediumGridViewItemCornerRadius}"
|
||||
@@ -403,15 +408,15 @@
|
||||
<cpcontrols:IconBox
|
||||
x:Name="GridIconBorder"
|
||||
Grid.Row="0"
|
||||
Width="36"
|
||||
Height="36"
|
||||
Width="{StaticResource MediumGridSize}"
|
||||
Height="{StaticResource MediumGridSize}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
CharacterSpacing="12"
|
||||
FontSize="14"
|
||||
Foreground="{ThemeResource TextFillColorPrimary}"
|
||||
SourceKey="{x:Bind Icon, Mode=OneWay}"
|
||||
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" />
|
||||
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested64}" />
|
||||
<TextBlock
|
||||
x:Name="TitleTextBlock"
|
||||
Grid.Row="1"
|
||||
@@ -430,7 +435,7 @@
|
||||
|
||||
<DataTemplate x:Key="GalleryGridItemViewModelTemplate" x:DataType="coreViewModels:ListItemViewModel">
|
||||
<StackPanel
|
||||
Width="160"
|
||||
Width="{StaticResource GalleryGridSize}"
|
||||
Margin="4"
|
||||
Padding="0"
|
||||
HorizontalAlignment="Center"
|
||||
@@ -442,8 +447,8 @@
|
||||
ToolTipService.ToolTip="{x:Bind Title, Mode=OneWay}">
|
||||
|
||||
<Grid
|
||||
Width="160"
|
||||
Height="160"
|
||||
Width="{StaticResource GalleryGridSize}"
|
||||
Height="{StaticResource GalleryGridSize}"
|
||||
Margin="0"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
@@ -456,7 +461,7 @@
|
||||
CornerRadius="4"
|
||||
Foreground="{ThemeResource TextFillColorPrimary}"
|
||||
SourceKey="{x:Bind Icon, Mode=OneWay}"
|
||||
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" />
|
||||
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested256}" />
|
||||
</Viewbox>
|
||||
</Grid>
|
||||
|
||||
@@ -582,7 +587,7 @@
|
||||
Margin="8"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
SourceKey="{x:Bind ViewModel.EmptyContent.Icon, Mode=OneWay}"
|
||||
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" />
|
||||
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested64}" />
|
||||
<TextBlock
|
||||
Margin="0,4,0,0"
|
||||
HorizontalAlignment="Center"
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Microsoft.CmdPal.Core.Common.Helpers;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// A high-performance, near-lock-free adaptive cache optimized for UI Icons.
|
||||
/// Eviction merely drops references to allow the GC to manage UI-bound lifetimes.
|
||||
/// </summary>
|
||||
internal sealed class AdaptiveCache<TKey, TValue>
|
||||
where TKey : IEquatable<TKey>
|
||||
{
|
||||
private readonly int _capacity;
|
||||
private readonly double _decayFactor;
|
||||
private readonly TimeSpan _decayInterval;
|
||||
|
||||
private readonly ConcurrentDictionary<TKey, CacheEntry> _map;
|
||||
private readonly ConcurrentStack<CacheEntry> _pool = [];
|
||||
private readonly WaitCallback _maintenanceCallback;
|
||||
|
||||
private long _currentTick;
|
||||
private long _lastDecayTicks = DateTime.UtcNow.Ticks;
|
||||
private InterlockedBoolean _maintenanceSwitch = new(false);
|
||||
|
||||
public AdaptiveCache(int capacity = 384, TimeSpan? decayInterval = null, double decayFactor = 0.5)
|
||||
{
|
||||
_capacity = capacity;
|
||||
_decayInterval = decayInterval ?? TimeSpan.FromMinutes(5);
|
||||
_decayFactor = decayFactor;
|
||||
_map = new ConcurrentDictionary<TKey, CacheEntry>(Environment.ProcessorCount, capacity);
|
||||
|
||||
_maintenanceCallback = static state =>
|
||||
{
|
||||
var cache = (AdaptiveCache<TKey, TValue>)state!;
|
||||
try
|
||||
{
|
||||
cache.PerformCleanup();
|
||||
}
|
||||
finally
|
||||
{
|
||||
cache._maintenanceSwitch.Clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public TValue GetOrAdd<TArg>(TKey key, Func<TKey, TArg, TValue> factory, TArg arg)
|
||||
{
|
||||
if (_map.TryGetValue(key, out var entry))
|
||||
{
|
||||
entry.Update(Interlocked.Increment(ref _currentTick));
|
||||
return entry.Value!;
|
||||
}
|
||||
|
||||
if (!_pool.TryPop(out var newEntry))
|
||||
{
|
||||
newEntry = new CacheEntry();
|
||||
}
|
||||
|
||||
var value = factory(key, arg);
|
||||
var tick = Interlocked.Increment(ref _currentTick);
|
||||
newEntry.Initialize(key, value, 1.0, tick);
|
||||
|
||||
if (!_map.TryAdd(key, newEntry))
|
||||
{
|
||||
newEntry.Clear();
|
||||
_pool.Push(newEntry);
|
||||
|
||||
if (_map.TryGetValue(key, out var existing))
|
||||
{
|
||||
existing.Update(tick);
|
||||
return existing.Value!;
|
||||
}
|
||||
}
|
||||
|
||||
if (ShouldMaintenanceRun())
|
||||
{
|
||||
TryRunMaintenance();
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
public bool TryGet(TKey key, [MaybeNullWhen(false)] out TValue value)
|
||||
{
|
||||
if (_map.TryGetValue(key, out var entry))
|
||||
{
|
||||
entry.Update(Interlocked.Increment(ref _currentTick));
|
||||
value = entry.Value;
|
||||
return true;
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Add(TKey key, TValue value)
|
||||
{
|
||||
var tick = Interlocked.Increment(ref _currentTick);
|
||||
|
||||
if (_map.TryGetValue(key, out var existing))
|
||||
{
|
||||
existing.Update(tick);
|
||||
existing.SetValue(value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_pool.TryPop(out var newEntry))
|
||||
{
|
||||
newEntry = new CacheEntry();
|
||||
}
|
||||
|
||||
newEntry.Initialize(key, value, 1.0, tick);
|
||||
|
||||
if (!_map.TryAdd(key, newEntry))
|
||||
{
|
||||
newEntry.Clear();
|
||||
_pool.Push(newEntry);
|
||||
}
|
||||
|
||||
if (ShouldMaintenanceRun())
|
||||
{
|
||||
TryRunMaintenance();
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryRemove(TKey key)
|
||||
{
|
||||
if (_map.TryRemove(key, out var evicted))
|
||||
{
|
||||
evicted.Clear();
|
||||
_pool.Push(evicted);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
foreach (var key in _map.Keys)
|
||||
{
|
||||
TryRemove(key);
|
||||
}
|
||||
|
||||
Interlocked.Exchange(ref _currentTick, 0);
|
||||
}
|
||||
|
||||
private bool ShouldMaintenanceRun()
|
||||
{
|
||||
return _map.Count > _capacity || (DateTime.UtcNow.Ticks - Interlocked.Read(ref _lastDecayTicks)) > _decayInterval.Ticks;
|
||||
}
|
||||
|
||||
private void TryRunMaintenance()
|
||||
{
|
||||
if (_maintenanceSwitch.Set())
|
||||
{
|
||||
ThreadPool.UnsafeQueueUserWorkItem(_maintenanceCallback, this);
|
||||
}
|
||||
}
|
||||
|
||||
private void PerformCleanup()
|
||||
{
|
||||
var nowTicks = DateTime.UtcNow.Ticks;
|
||||
var isDecay = (nowTicks - Interlocked.Read(ref _lastDecayTicks)) > _decayInterval.Ticks;
|
||||
if (isDecay)
|
||||
{
|
||||
Interlocked.Exchange(ref _lastDecayTicks, nowTicks);
|
||||
}
|
||||
|
||||
var currentTick = Interlocked.Read(ref _currentTick);
|
||||
|
||||
foreach (var (key, entry) in _map)
|
||||
{
|
||||
if (isDecay)
|
||||
{
|
||||
entry.Decay(_decayFactor);
|
||||
}
|
||||
|
||||
var score = CalculateScore(entry, currentTick);
|
||||
|
||||
if (score < 0.1 || _map.Count > _capacity)
|
||||
{
|
||||
if (_map.TryRemove(key, out var evicted))
|
||||
{
|
||||
evicted.Clear();
|
||||
_pool.Push(evicted);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the survival score of an entry.
|
||||
/// Higher score = stay in cache; Lower score = priority for eviction.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static double CalculateScore(CacheEntry entry, long currentTick)
|
||||
{
|
||||
// Tuning parameter: How much weight to give recency vs frequency.
|
||||
// - a larger ageWeight makes the cache behave more like LRU (Least Recently Used).
|
||||
// - a smaller ageWeight makes it behave more like LFU (Least Frequently Used).
|
||||
const double ageWeight = 0.001;
|
||||
|
||||
var frequency = entry.GetFrequency();
|
||||
var age = currentTick - entry.GetLastAccess();
|
||||
|
||||
return frequency - (age * ageWeight);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single pooled entry in the cache, containing the value and
|
||||
/// atomic metadata for adaptive eviction logic.
|
||||
/// </summary>
|
||||
private sealed class CacheEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the key associated with this entry. Used primarily for identification during cleanup.
|
||||
/// </summary>
|
||||
public TKey Key { get; private set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the cached value. This reference is cleared on eviction to allow GC collection.
|
||||
/// </summary>
|
||||
public TValue Value { get; private set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Stores the frequency count as double bits to allow for Interlocked atomic math.
|
||||
/// Frequencies are decayed over time to ensure the cache adapts to new usage patterns.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This allows the use of Interlocked.CompareExchange to perform thread-safe floating point
|
||||
/// arithmetic without a global lock.
|
||||
/// </remarks>
|
||||
private long _frequencyBits;
|
||||
|
||||
/// <summary>
|
||||
/// The tick (monotonically increasing counter) of the last time this entry was accessed.
|
||||
/// </summary>
|
||||
private long _lastAccessTick;
|
||||
|
||||
public void Initialize(TKey key, TValue value, double frequency, long lastAccessTick)
|
||||
{
|
||||
Key = key;
|
||||
Value = value;
|
||||
_frequencyBits = BitConverter.DoubleToInt64Bits(frequency);
|
||||
_lastAccessTick = lastAccessTick;
|
||||
}
|
||||
|
||||
public void SetValue(TValue value)
|
||||
{
|
||||
Value = value;
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
Key = default!;
|
||||
Value = default!;
|
||||
}
|
||||
|
||||
public void Update(long tick)
|
||||
{
|
||||
Interlocked.Exchange(ref _lastAccessTick, tick);
|
||||
long initial, updated;
|
||||
do
|
||||
{
|
||||
initial = Interlocked.Read(ref _frequencyBits);
|
||||
updated = BitConverter.DoubleToInt64Bits(BitConverter.Int64BitsToDouble(initial) + 1.0);
|
||||
}
|
||||
while (Interlocked.CompareExchange(ref _frequencyBits, updated, initial) != initial);
|
||||
}
|
||||
|
||||
public void Decay(double factor)
|
||||
{
|
||||
long initial, updated;
|
||||
do
|
||||
{
|
||||
initial = Interlocked.Read(ref _frequencyBits);
|
||||
updated = BitConverter.DoubleToInt64Bits(BitConverter.Int64BitsToDouble(initial) * factor);
|
||||
}
|
||||
while (Interlocked.CompareExchange(ref _frequencyBits, updated, initial) != initial);
|
||||
}
|
||||
|
||||
public double GetFrequency()
|
||||
{
|
||||
return BitConverter.Int64BitsToDouble(Interlocked.Read(ref _frequencyBits));
|
||||
}
|
||||
|
||||
public long GetLastAccess()
|
||||
{
|
||||
return Interlocked.Read(ref _lastAccessTick);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Core.ViewModels;
|
||||
using Microsoft.CmdPal.UI.Controls;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Common async event handler provides the cache lookup function for the <see cref="IconBox.SourceRequested"/> deferred event.
|
||||
/// </summary>
|
||||
public static partial class IconCacheProvider
|
||||
{
|
||||
private static readonly IconCacheService IconService = new(Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread());
|
||||
|
||||
#pragma warning disable IDE0060 // Remove unused parameter
|
||||
public static async void SourceRequested(IconBox sender, SourceRequestedEventArgs args)
|
||||
#pragma warning restore IDE0060 // Remove unused parameter
|
||||
{
|
||||
if (args.Key is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.Key is IconDataViewModel iconData)
|
||||
{
|
||||
var deferral = args.GetDeferral();
|
||||
|
||||
args.Value = await IconService.GetIconSource(iconData);
|
||||
|
||||
deferral.Complete();
|
||||
}
|
||||
else if (args.Key is IconInfoViewModel iconInfo)
|
||||
{
|
||||
var deferral = args.GetDeferral();
|
||||
|
||||
var data = args.Theme == Microsoft.UI.Xaml.ElementTheme.Dark ? iconInfo.Dark : iconInfo.Light;
|
||||
args.Value = await IconService.GetIconSource(data);
|
||||
|
||||
deferral.Complete();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Diagnostics;
|
||||
using Microsoft.CmdPal.Core.ViewModels;
|
||||
using Microsoft.Terminal.UI;
|
||||
using Microsoft.UI.Dispatching;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Media.Imaging;
|
||||
using Windows.Storage.Streams;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.Helpers;
|
||||
|
||||
public sealed class IconCacheService(DispatcherQueue dispatcherQueue)
|
||||
{
|
||||
public Task<IconSource?> GetIconSource(IconDataViewModel icon) =>
|
||||
|
||||
// todo: actually implement a cache of some sort
|
||||
IconToSource(icon);
|
||||
|
||||
private async Task<IconSource?> IconToSource(IconDataViewModel icon)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrEmpty(icon.Icon))
|
||||
{
|
||||
var source = IconPathConverter.IconSourceMUX(icon.Icon, false, icon.FontFamily);
|
||||
return source;
|
||||
}
|
||||
else if (icon.Data is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await StreamToIconSource(icon.Data.Unsafe!);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine("Failed to load icon from stream: " + ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<IconSource?> StreamToIconSource(IRandomAccessStreamReference iconStreamRef)
|
||||
{
|
||||
if (iconStreamRef is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var bitmap = await IconStreamToBitmapImageAsync(iconStreamRef);
|
||||
var icon = new ImageIconSource() { ImageSource = bitmap };
|
||||
return icon;
|
||||
}
|
||||
|
||||
private async Task<BitmapImage> IconStreamToBitmapImageAsync(IRandomAccessStreamReference iconStreamRef)
|
||||
{
|
||||
// Return the bitmap image via TaskCompletionSource. Using WCT's EnqueueAsync does not suffice here, since if
|
||||
// we're already on the thread of the DispatcherQueue then it just directly calls the function, with no async involved.
|
||||
return await TryEnqueueAsync(dispatcherQueue, async () =>
|
||||
{
|
||||
using var bitmapStream = await iconStreamRef.OpenReadAsync();
|
||||
var itemImage = new BitmapImage();
|
||||
await itemImage.SetSourceAsync(bitmapStream);
|
||||
return itemImage;
|
||||
});
|
||||
}
|
||||
|
||||
private static Task<T> TryEnqueueAsync<T>(DispatcherQueue dispatcher, Func<Task<T>> function)
|
||||
{
|
||||
var completionSource = new TaskCompletionSource<T>();
|
||||
|
||||
var enqueued = dispatcher.TryEnqueue(DispatcherQueuePriority.Normal, async void () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await function();
|
||||
completionSource.SetResult(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
completionSource.SetException(ex);
|
||||
}
|
||||
});
|
||||
|
||||
if (!enqueued)
|
||||
{
|
||||
completionSource.SetException(new InvalidOperationException("Failed to enqueue the operation on the UI dispatcher"));
|
||||
}
|
||||
|
||||
return completionSource.Task;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using Microsoft.CmdPal.Core.ViewModels;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Windows.Foundation;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.Helpers;
|
||||
|
||||
internal sealed class CachedIconSourceProvider : IIconSourceProvider
|
||||
{
|
||||
private readonly AdaptiveCache<IconCacheKey, Task<IconSource?>> _cache;
|
||||
private readonly Size _iconSize;
|
||||
private readonly IconLoaderService _loader;
|
||||
private readonly Lock _lock = new();
|
||||
|
||||
public CachedIconSourceProvider(IconLoaderService loader, Size iconSize, int cacheSize)
|
||||
{
|
||||
_loader = loader;
|
||||
_iconSize = iconSize;
|
||||
_cache = new AdaptiveCache<IconCacheKey, Task<IconSource?>>(cacheSize, TimeSpan.FromMinutes(60));
|
||||
}
|
||||
|
||||
public CachedIconSourceProvider(IconLoaderService loader, int iconSize, int cacheSize)
|
||||
: this(loader, new Size(iconSize, iconSize), cacheSize)
|
||||
{
|
||||
}
|
||||
|
||||
public Task<IconSource?> GetIconSource(IconDataViewModel icon, double scale)
|
||||
{
|
||||
var key = new IconCacheKey(icon, scale);
|
||||
|
||||
return _cache.TryGet(key, out var existingTask)
|
||||
? existingTask
|
||||
: GetOrCreateSlowPath(key, icon, scale);
|
||||
}
|
||||
|
||||
private Task<IconSource?> GetOrCreateSlowPath(IconCacheKey key, IconDataViewModel icon, double scale)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_cache.TryGet(key, out var existingTask))
|
||||
{
|
||||
return existingTask;
|
||||
}
|
||||
|
||||
var tcs = new TaskCompletionSource<IconSource?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
_loader.EnqueueLoad(
|
||||
icon.Icon,
|
||||
icon.FontFamily,
|
||||
icon.Data?.Unsafe,
|
||||
_iconSize,
|
||||
scale,
|
||||
tcs);
|
||||
|
||||
var task = tcs.Task;
|
||||
|
||||
_ = task.ContinueWith(
|
||||
_ =>
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_cache.TryRemove(key);
|
||||
}
|
||||
},
|
||||
TaskContinuationOptions.OnlyOnFaulted);
|
||||
|
||||
_cache.Add(key, task);
|
||||
return task;
|
||||
}
|
||||
}
|
||||
|
||||
private readonly struct IconCacheKey : IEquatable<IconCacheKey>
|
||||
{
|
||||
private readonly string? _icon;
|
||||
private readonly string? _fontFamily;
|
||||
private readonly int _streamRefHashCode;
|
||||
private readonly int _scale;
|
||||
|
||||
public IconCacheKey(IconDataViewModel icon, double scale)
|
||||
{
|
||||
_icon = icon.Icon;
|
||||
_fontFamily = icon.FontFamily;
|
||||
_streamRefHashCode = icon.Data?.Unsafe is { } stream
|
||||
? RuntimeHelpers.GetHashCode(stream)
|
||||
: 0;
|
||||
_scale = (int)(100 * Math.Round(scale, 2));
|
||||
}
|
||||
|
||||
public bool Equals(IconCacheKey other) =>
|
||||
_icon == other._icon &&
|
||||
_fontFamily == other._fontFamily &&
|
||||
_streamRefHashCode == other._streamRefHashCode &&
|
||||
_scale == other._scale;
|
||||
|
||||
public override bool Equals(object? obj) => obj is IconCacheKey other && Equals(other);
|
||||
|
||||
public override int GetHashCode() => HashCode.Combine(_icon, _fontFamily, _streamRefHashCode, _scale);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Windows.Foundation;
|
||||
using Windows.Storage.Streams;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.Helpers;
|
||||
|
||||
internal interface IIconLoaderService : IAsyncDisposable
|
||||
{
|
||||
void EnqueueLoad(
|
||||
string? iconString,
|
||||
string? fontFamily,
|
||||
IRandomAccessStreamReference? streamRef,
|
||||
Size iconSize,
|
||||
double scale,
|
||||
TaskCompletionSource<IconSource?> tcs,
|
||||
IconLoadPriority priority);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Core.ViewModels;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.Helpers;
|
||||
|
||||
internal interface IIconSourceProvider
|
||||
{
|
||||
Task<IconSource?> GetIconSource(IconDataViewModel icon, double scale);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Core.ViewModels;
|
||||
using Microsoft.CmdPal.UI.Controls;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Common async event handler provides the cache lookup function for the <see cref="IconBox.SourceRequested"/> deferred event.
|
||||
/// </summary>
|
||||
public static partial class IconCacheProvider
|
||||
{
|
||||
/*
|
||||
Memory Usage Considerations (raw estimates):
|
||||
| Icon Size | Per Icon | Count | Total | Per Icon @ 200% | Total @ 200% | Per Icon @ 300% | Total @ 300% |
|
||||
| --------- | -------: | ----: | -------: | --------------: | -----------: | --------------: | -----------: |
|
||||
| 20×20 | 1.6 KB | 1024 | 1.6 MB | 6.4 KB | 6.4 MB | 14.4 KB | 14.4 MB |
|
||||
| 32×32 | 4.0 KB | 512 | 2.0 MB | 16 KB | 8.0 MB | 36.0 KB | 18.0 MB |
|
||||
| 48×48 | 9.0 KB | 256 | 2.3 MB | 36 KB | 9.0 MB | 81.0 KB | 20.3 MB |
|
||||
| 64×64 | 16.0 KB | 64 | 1.0 MB | 64 KB | 4.0 MB | 144.0 KB | 9.0 MB |
|
||||
| 256×256 | 256.0 KB | 64 | 16.0 MB | 1 MB | 64.0 MB | 2.3 MB | 144 MB |
|
||||
*/
|
||||
|
||||
private static IIconSourceProvider _provider20 = null!;
|
||||
private static IIconSourceProvider _provider32 = null!;
|
||||
private static IIconSourceProvider _provider64 = null!;
|
||||
private static IIconSourceProvider _provider256 = null!;
|
||||
|
||||
public static void Initialize(IServiceProvider serviceProvider)
|
||||
{
|
||||
_provider20 = serviceProvider.GetRequiredKeyedService<IIconSourceProvider>(WellKnownIconSize.Size20);
|
||||
_provider32 = serviceProvider.GetRequiredKeyedService<IIconSourceProvider>(WellKnownIconSize.Size32);
|
||||
_provider64 = serviceProvider.GetRequiredKeyedService<IIconSourceProvider>(WellKnownIconSize.Size64);
|
||||
_provider256 = serviceProvider.GetRequiredKeyedService<IIconSourceProvider>(WellKnownIconSize.Size256);
|
||||
}
|
||||
|
||||
private static async void SourceRequestedCore(IIconSourceProvider service, SourceRequestedEventArgs args)
|
||||
{
|
||||
if (args.Key is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var deferral = args.GetDeferral();
|
||||
|
||||
try
|
||||
{
|
||||
args.Value = args.Key switch
|
||||
{
|
||||
IconDataViewModel iconData => await service.GetIconSource(iconData, args.Scale),
|
||||
IconInfoViewModel iconInfo => await service.GetIconSource(
|
||||
args.Theme == Microsoft.UI.Xaml.ElementTheme.Light ? iconInfo.Light : iconInfo.Dark,
|
||||
args.Scale),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
deferral.Complete();
|
||||
}
|
||||
}
|
||||
|
||||
#pragma warning disable IDE0060 // Remove unused parameter
|
||||
public static void SourceRequested20(IconBox sender, SourceRequestedEventArgs args)
|
||||
=> SourceRequestedCore(_provider20, args);
|
||||
|
||||
public static void SourceRequested32(IconBox sender, SourceRequestedEventArgs args)
|
||||
=> SourceRequestedCore(_provider32, args);
|
||||
|
||||
public static void SourceRequested64(IconBox sender, SourceRequestedEventArgs args)
|
||||
=> SourceRequestedCore(_provider64, args);
|
||||
|
||||
public static void SourceRequested256(IconBox sender, SourceRequestedEventArgs args)
|
||||
=> SourceRequestedCore(_provider256, args);
|
||||
#pragma warning restore IDE0060 // Remove unused parameter
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.UI.Helpers;
|
||||
|
||||
internal enum IconLoadPriority
|
||||
{
|
||||
Low,
|
||||
High,
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Threading.Channels;
|
||||
using CommunityToolkit.WinUI;
|
||||
using ManagedCommon;
|
||||
using Microsoft.Terminal.UI;
|
||||
using Microsoft.UI.Dispatching;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Media.Imaging;
|
||||
using Windows.Foundation;
|
||||
using Windows.Storage.Streams;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.Helpers;
|
||||
|
||||
internal sealed partial class IconLoaderService : IIconLoaderService
|
||||
{
|
||||
private const DispatcherQueuePriority LoadingPriorityOnDispatcher = DispatcherQueuePriority.Low;
|
||||
private const int DefaultIconSize = 256;
|
||||
private const int MaxWorkerCount = 4;
|
||||
|
||||
private static readonly int WorkerCount = Math.Clamp(Environment.ProcessorCount / 2, 1, MaxWorkerCount);
|
||||
|
||||
private readonly Channel<Func<Task>> _highPriorityQueue = Channel.CreateBounded<Func<Task>>(32);
|
||||
private readonly Channel<Func<Task>> _lowPriorityQueue = Channel.CreateUnbounded<Func<Task>>();
|
||||
private readonly Task[] _workers;
|
||||
private readonly DispatcherQueue _dispatcherQueue;
|
||||
|
||||
public IconLoaderService(DispatcherQueue dispatcherQueue)
|
||||
{
|
||||
_dispatcherQueue = dispatcherQueue;
|
||||
_workers = new Task[WorkerCount];
|
||||
|
||||
for (var i = 0; i < WorkerCount; i++)
|
||||
{
|
||||
_workers[i] = Task.Run(ProcessQueueAsync);
|
||||
}
|
||||
}
|
||||
|
||||
public void EnqueueLoad(
|
||||
string? iconString,
|
||||
string? fontFamily,
|
||||
IRandomAccessStreamReference? streamRef,
|
||||
Size iconSize,
|
||||
double scale,
|
||||
TaskCompletionSource<IconSource?> tcs,
|
||||
IconLoadPriority priority = IconLoadPriority.Low)
|
||||
{
|
||||
var workItem = () => LoadAndCompleteAsync(iconString, fontFamily, streamRef, iconSize, scale, tcs);
|
||||
|
||||
if (priority == IconLoadPriority.High)
|
||||
{
|
||||
if (_highPriorityQueue.Writer.TryWrite(workItem))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
Logger.LogDebug("High priority icon queue full, falling back to low priority");
|
||||
#endif
|
||||
}
|
||||
|
||||
_lowPriorityQueue.Writer.TryWrite(workItem);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_highPriorityQueue.Writer.Complete();
|
||||
_lowPriorityQueue.Writer.Complete();
|
||||
|
||||
await Task.WhenAll(_workers).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task ProcessQueueAsync()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
Func<Task>? workItem;
|
||||
|
||||
if (_highPriorityQueue.Reader.TryRead(out workItem))
|
||||
{
|
||||
await ExecuteWork(workItem).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
var highWait = _highPriorityQueue.Reader.WaitToReadAsync().AsTask();
|
||||
var lowWait = _lowPriorityQueue.Reader.WaitToReadAsync().AsTask();
|
||||
|
||||
await Task.WhenAny(highWait, lowWait).ConfigureAwait(false);
|
||||
|
||||
// Check if both channels are completed (disposal)
|
||||
if (_highPriorityQueue.Reader.Completion.IsCompleted &&
|
||||
_lowPriorityQueue.Reader.Completion.IsCompleted)
|
||||
{
|
||||
// Drain any remaining items
|
||||
while (_highPriorityQueue.Reader.TryRead(out workItem))
|
||||
{
|
||||
await ExecuteWork(workItem).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
while (_lowPriorityQueue.Reader.TryRead(out workItem))
|
||||
{
|
||||
await ExecuteWork(workItem).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (_highPriorityQueue.Reader.TryRead(out workItem) ||
|
||||
_lowPriorityQueue.Reader.TryRead(out workItem))
|
||||
{
|
||||
await ExecuteWork(workItem).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
static async Task ExecuteWork(Func<Task> workItem)
|
||||
{
|
||||
try
|
||||
{
|
||||
await workItem().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to load icon", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadAndCompleteAsync(
|
||||
string? iconString,
|
||||
string? fontFamily,
|
||||
IRandomAccessStreamReference? streamRef,
|
||||
Size iconSize,
|
||||
double scale,
|
||||
TaskCompletionSource<IconSource?> tcs)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await LoadIconCoreAsync(iconString, fontFamily, streamRef, iconSize, scale).ConfigureAwait(false);
|
||||
tcs.TrySetResult(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
tcs.TrySetException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IconSource?> LoadIconCoreAsync(
|
||||
string? iconString,
|
||||
string? fontFamily,
|
||||
IRandomAccessStreamReference? streamRef,
|
||||
Size iconSize,
|
||||
double scale)
|
||||
{
|
||||
var scaledSize = new Size(iconSize.Width * scale, iconSize.Height * scale);
|
||||
|
||||
if (!string.IsNullOrEmpty(iconString))
|
||||
{
|
||||
return await _dispatcherQueue
|
||||
.EnqueueAsync(() => GetStringIconSource(iconString, fontFamily, scaledSize), LoadingPriorityOnDispatcher)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (streamRef != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var bitmapStream = await streamRef.OpenReadAsync().AsTask().ConfigureAwait(false);
|
||||
|
||||
return await _dispatcherQueue
|
||||
.EnqueueAsync(BuildImageSource, LoadingPriorityOnDispatcher)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
async Task<IconSource?> BuildImageSource()
|
||||
{
|
||||
var bitmap = new BitmapImage();
|
||||
ApplyDecodeSize(bitmap, scaledSize);
|
||||
await bitmap.SetSourceAsync(bitmapStream);
|
||||
return new ImageIconSource { ImageSource = bitmap };
|
||||
}
|
||||
}
|
||||
#pragma warning disable CS0168 // Variable is declared but never used
|
||||
catch (Exception ex)
|
||||
#pragma warning restore CS0168 // Variable is declared but never used
|
||||
{
|
||||
#if DEBUG
|
||||
Logger.LogDebug($"Failed to open icon stream: {ex}");
|
||||
#endif
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void ApplyDecodeSize(BitmapImage bitmap, Size size)
|
||||
{
|
||||
if (size.IsEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (size.Width >= size.Height)
|
||||
{
|
||||
bitmap.DecodePixelWidth = (int)size.Width;
|
||||
}
|
||||
else
|
||||
{
|
||||
bitmap.DecodePixelHeight = (int)size.Height;
|
||||
}
|
||||
}
|
||||
|
||||
private static IconSource? GetStringIconSource(string iconString, string? fontFamily, Size size)
|
||||
{
|
||||
var iconSize = (int)Math.Max(size.Width, size.Height);
|
||||
if (iconSize == 0)
|
||||
{
|
||||
iconSize = DefaultIconSize;
|
||||
}
|
||||
|
||||
return IconPathConverter.IconSourceMUX(iconString, false, fontFamily, iconSize);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.UI.Dispatching;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.Helpers;
|
||||
|
||||
internal static class IconServiceRegistration
|
||||
{
|
||||
public static IServiceCollection AddIconServices(this IServiceCollection services, DispatcherQueue dispatcherQueue)
|
||||
{
|
||||
// Single shared loader
|
||||
var loader = new IconLoaderService(dispatcherQueue);
|
||||
services.AddSingleton<IIconLoaderService>(loader);
|
||||
|
||||
// Keyed providers by size
|
||||
services.AddKeyedSingleton<IIconSourceProvider>(
|
||||
WellKnownIconSize.Size20,
|
||||
(_, _) => new CachedIconSourceProvider(loader, 20, 1024));
|
||||
|
||||
services.AddKeyedSingleton<IIconSourceProvider>(
|
||||
WellKnownIconSize.Size32,
|
||||
(_, _) => new IconSourceProvider(loader, 32));
|
||||
|
||||
services.AddKeyedSingleton<IIconSourceProvider>(
|
||||
WellKnownIconSize.Size64,
|
||||
(_, _) => new CachedIconSourceProvider(loader, 64, 256));
|
||||
|
||||
services.AddKeyedSingleton<IIconSourceProvider>(
|
||||
WellKnownIconSize.Size256,
|
||||
(_, _) => new CachedIconSourceProvider(loader, 256, 64));
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Core.ViewModels;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Windows.Foundation;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.Helpers;
|
||||
|
||||
internal sealed class IconSourceProvider : IIconSourceProvider
|
||||
{
|
||||
private readonly IconLoaderService _loader;
|
||||
private readonly Size _iconSize;
|
||||
|
||||
public IconSourceProvider(IconLoaderService loader, Size iconSize)
|
||||
{
|
||||
_loader = loader;
|
||||
_iconSize = iconSize;
|
||||
}
|
||||
|
||||
public IconSourceProvider(IconLoaderService loader, int iconSize)
|
||||
: this(loader, new Size(iconSize, iconSize))
|
||||
{
|
||||
}
|
||||
|
||||
public Task<IconSource?> GetIconSource(IconDataViewModel icon, double scale)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<IconSource?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
_loader.EnqueueLoad(
|
||||
icon.Icon,
|
||||
icon.FontFamily,
|
||||
icon.Data?.Unsafe,
|
||||
_iconSize,
|
||||
scale,
|
||||
tcs);
|
||||
|
||||
return tcs.Task;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.UI.Helpers;
|
||||
|
||||
internal enum WellKnownIconSize
|
||||
{
|
||||
Size20 = 20,
|
||||
Size32 = 32,
|
||||
Size64 = 64,
|
||||
Size256 = 256,
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using CommunityToolkit.Common.Deferred;
|
||||
using Windows.Foundation;
|
||||
|
||||
// Pilfered from CommunityToolkit.WinUI.Deferred
|
||||
namespace Microsoft.CmdPal.UI.Deferred;
|
||||
|
||||
/// <summary>
|
||||
/// Extensions to <see cref="TypedEventHandler{TSender, TResult}"/> for Deferred Events.
|
||||
/// </summary>
|
||||
public static class TypedEventHandlerExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Use to invoke an async <see cref="TypedEventHandler{TSender, TResult}"/> using <see cref="DeferredEventArgs"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="S">Type of sender.</typeparam>
|
||||
/// <typeparam name="R"><see cref="EventArgs"/> type.</typeparam>
|
||||
/// <param name="eventHandler"><see cref="TypedEventHandler{TSender, TResult}"/> to be invoked.</param>
|
||||
/// <param name="sender">Sender of the event.</param>
|
||||
/// <param name="eventArgs"><see cref="EventArgs"/> instance.</param>
|
||||
/// <returns><see cref="Task"/> to wait on deferred event handler.</returns>
|
||||
#pragma warning disable CA1715 // Identifiers should have correct prefix
|
||||
#pragma warning disable SA1314 // Type parameter names should begin with T
|
||||
public static Task InvokeAsync<S, R>(this TypedEventHandler<S, R> eventHandler, S sender, R eventArgs)
|
||||
#pragma warning restore SA1314 // Type parameter names should begin with T
|
||||
#pragma warning restore CA1715 // Identifiers should have correct prefix
|
||||
where R : DeferredEventArgs => InvokeAsync(eventHandler, sender, eventArgs, CancellationToken.None);
|
||||
|
||||
/// <summary>
|
||||
/// Use to invoke an async <see cref="TypedEventHandler{TSender, TResult}"/> using <see cref="DeferredEventArgs"/> with a <see cref="CancellationToken"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="S">Type of sender.</typeparam>
|
||||
/// <typeparam name="R"><see cref="EventArgs"/> type.</typeparam>
|
||||
/// <param name="eventHandler"><see cref="TypedEventHandler{TSender, TResult}"/> to be invoked.</param>
|
||||
/// <param name="sender">Sender of the event.</param>
|
||||
/// <param name="eventArgs"><see cref="EventArgs"/> instance.</param>
|
||||
/// <param name="cancellationToken"><see cref="CancellationToken"/> option.</param>
|
||||
/// <returns><see cref="Task"/> to wait on deferred event handler.</returns>
|
||||
#pragma warning disable CA1715 // Identifiers should have correct prefix
|
||||
#pragma warning disable SA1314 // Type parameter names should begin with T
|
||||
public static Task InvokeAsync<S, R>(this TypedEventHandler<S, R> eventHandler, S sender, R eventArgs, CancellationToken cancellationToken)
|
||||
#pragma warning restore SA1314 // Type parameter names should begin with T
|
||||
#pragma warning restore CA1715 // Identifiers should have correct prefix
|
||||
where R : DeferredEventArgs
|
||||
{
|
||||
if (eventHandler is null)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var tasks = eventHandler.GetInvocationList()
|
||||
.OfType<TypedEventHandler<S, R>>()
|
||||
.Select(invocationDelegate =>
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return Task.FromCanceled(cancellationToken);
|
||||
}
|
||||
|
||||
invocationDelegate(sender, eventArgs);
|
||||
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
var deferral = eventArgs.GetCurrentDeferralAndReset();
|
||||
|
||||
return deferral?.WaitForCompletion(cancellationToken) ?? Task.CompletedTask;
|
||||
#pragma warning restore CS0618 // Type or member is obsolete
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
return Task.WhenAll(tasks);
|
||||
}
|
||||
}
|
||||
@@ -648,8 +648,14 @@ public sealed partial class MainWindow : WindowEx,
|
||||
// Updates our window s.t. the top of the window is draggable.
|
||||
private void UpdateRegionsForCustomTitleBar()
|
||||
{
|
||||
var xamlRoot = RootElement.XamlRoot;
|
||||
if (xamlRoot is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Specify the interactive regions of the title bar.
|
||||
var scaleAdjustment = RootElement.XamlRoot.RasterizationScale;
|
||||
var scaleAdjustment = xamlRoot.RasterizationScale;
|
||||
|
||||
// Get the rectangle around our XAML content. We're going to mark this
|
||||
// rectangle as "Passthrough", so that the normal window operations
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
Height="16"
|
||||
Margin="0,3,8,0"
|
||||
SourceKey="{x:Bind Icon, Mode=OneWay}"
|
||||
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" />
|
||||
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested20}" />
|
||||
<TextBlock Style="{StaticResource BodyTextBlockStyle}" Text="{x:Bind Name}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
@@ -296,7 +296,7 @@
|
||||
ui:VisualExtensions.NormalizedCenterPoint="0.5,0.5"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
SourceKey="{x:Bind ViewModel.CurrentPage.Icon, Mode=OneWay}"
|
||||
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}"
|
||||
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested20}"
|
||||
Visibility="{x:Bind ViewModel.CurrentPage.IsNested, Mode=OneWay}">
|
||||
<animations:Implicit.ShowAnimations>
|
||||
<animations:OpacityAnimation
|
||||
@@ -429,7 +429,7 @@
|
||||
HorizontalAlignment="Left"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
SourceKey="{x:Bind ViewModel.Details.HeroImage, Mode=OneWay}"
|
||||
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}"
|
||||
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested64}"
|
||||
Visibility="{x:Bind HasHeroImage, Mode=OneWay}" />
|
||||
|
||||
<TextBlock
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
Height="20"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
SourceKey="{x:Bind ViewModel.Icon, Mode=OneWay}"
|
||||
SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested}" />
|
||||
SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested20}" />
|
||||
</cpcontrols:ContentIcon.Content>
|
||||
</cpcontrols:ContentIcon>
|
||||
</controls:SettingsCard.HeaderIcon>
|
||||
@@ -94,7 +94,7 @@
|
||||
Height="20"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
SourceKey="{x:Bind Icon, Mode=OneWay}"
|
||||
SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested}" />
|
||||
SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested20}" />
|
||||
</cpcontrols:ContentIcon.Content>
|
||||
</cpcontrols:ContentIcon>
|
||||
</controls:SettingsExpander.HeaderIcon>
|
||||
@@ -165,7 +165,7 @@
|
||||
Height="20"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
SourceKey="{x:Bind Icon, Mode=OneWay}"
|
||||
SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested}" />
|
||||
SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested20}" />
|
||||
</cpcontrols:ContentIcon.Content>
|
||||
</cpcontrols:ContentIcon>
|
||||
</controls:SettingsExpander.HeaderIcon>
|
||||
|
||||
@@ -239,7 +239,7 @@
|
||||
Height="20"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
SourceKey="{x:Bind Icon, Mode=OneWay}"
|
||||
SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested}" />
|
||||
SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested20}" />
|
||||
</controls:Case>
|
||||
<controls:Case Value="False">
|
||||
<Image
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace Microsoft.Terminal.UI
|
||||
{
|
||||
// static Windows.UI.Xaml.Controls.IconElement IconWUX(String path);
|
||||
// static Windows.UI.Xaml.Controls.IconSource IconSourceWUX(String path);
|
||||
static Microsoft.UI.Xaml.Controls.IconSource IconSourceMUX(String path, Boolean convertToGrayscale, String fontFamily);
|
||||
static Microsoft.UI.Xaml.Controls.IconSource IconSourceMUX(String path, Boolean convertToGrayscale, String fontFamily, Int32 targetSize);
|
||||
static Microsoft.UI.Xaml.Controls.IconElement IconMUX(String path);
|
||||
static Microsoft.UI.Xaml.Controls.IconElement IconMUX(String path, Int32 targetSize);
|
||||
};
|
||||
|
||||
@@ -201,7 +201,7 @@
|
||||
<None Include="Microsoft.Terminal.UI.def" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup>
|
||||
<OutDir>..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps\CmdPal</OutDir>
|
||||
<OutDir>..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\</OutDir>
|
||||
<IntDir>obj\$(Platform)\$(Configuration)\</IntDir>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
|
||||
|
||||
@@ -117,7 +117,11 @@ public class FuzzyMatcherComparisonTests
|
||||
["_a", "_a"],
|
||||
["a_", "a_"],
|
||||
["-a", "-a"],
|
||||
["a-", "a-"]
|
||||
["a-", "a-"],
|
||||
["🐿️", "🐿️"], // Squirrel emoji
|
||||
["\U0001F44D", "\U0001F44D\U0001F3FB"], // Base thumbs-up vs thumbs-up with LIGHT skin tone modifier
|
||||
["\U0001F44D\U0001F3FB", "\U0001F44D\U0001F3FB"], // Thumbs-up with LIGHT skin tone vs itself (exact same sequence)
|
||||
["\U0001F44D\U0001F3FB", "\U0001F44D\U0001F3FF"], // Thumbs-up with LIGHT skin tone vs thumbs-up with DARK skin tone
|
||||
];
|
||||
|
||||
[TestMethod]
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CommandPalette.Extensions.Toolkit.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class FuzzyMatcherComplexEmojiTests
|
||||
{
|
||||
[TestMethod]
|
||||
[Ignore("For now this is not supported")]
|
||||
public void Mismatch_DifferentSkinTone_PartialMatch()
|
||||
{
|
||||
// "👍🏻" (Light) vs "👍🏿" (Dark)
|
||||
// They share the base "👍".
|
||||
const string needle = "👍🏻";
|
||||
const string haystack = "👍🏿";
|
||||
|
||||
var result = FuzzyStringMatcher.ScoreFuzzyWithPositions(needle, haystack, allowNonContiguousMatches: true);
|
||||
|
||||
// Should have a positive score because of the base emoji match
|
||||
Assert.IsTrue(result.Score > 0, "Expected partial match based on base emoji");
|
||||
|
||||
// Should match the base emoji (2 chars)
|
||||
Assert.AreEqual(2, result.Positions.Count, "Expected match on base emoji only");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CommandPalette.Extensions.Toolkit.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class FuzzyMatcherEmojiTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void ExactMatch_SimpleEmoji_ReturnsScore()
|
||||
{
|
||||
const string needle = "🚀";
|
||||
const string haystack = "Launch 🚀 sequence";
|
||||
|
||||
var result = FuzzyStringMatcher.ScoreFuzzyWithPositions(needle, haystack, allowNonContiguousMatches: true);
|
||||
|
||||
Assert.IsTrue(result.Score > 0, "Expected match for simple emoji");
|
||||
|
||||
// 🚀 is 2 chars (surrogates)
|
||||
Assert.AreEqual(2, result.Positions.Count, "Expected 2 matched characters positions for the emoji");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ExactMatch_SkinTone_ReturnsScore()
|
||||
{
|
||||
const string needle = "👍🏽"; // Medium skin tone
|
||||
const string haystack = "Thumbs up 👍🏽 here";
|
||||
|
||||
var result = FuzzyStringMatcher.ScoreFuzzyWithPositions(needle, haystack, allowNonContiguousMatches: true);
|
||||
|
||||
Assert.IsTrue(result.Score > 0, "Expected match for emoji with skin tone");
|
||||
|
||||
// 👍🏽 is 4 chars: U+1F44D (2 chars) + U+1F3FD (2 chars)
|
||||
Assert.AreEqual(4, result.Positions.Count, "Expected 4 matched characters positions for the emoji with modifier");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ZWJSequence_Family_Match()
|
||||
{
|
||||
const string needle = "👨👩👧👦"; // Family: Man, Woman, Girl, Boy
|
||||
const string haystack = "Emoji 👨👩👧👦 Test";
|
||||
|
||||
var result = FuzzyStringMatcher.ScoreFuzzyWithPositions(needle, haystack, allowNonContiguousMatches: true);
|
||||
|
||||
Assert.IsTrue(result.Score > 0, "Expected match for ZWJ sequence");
|
||||
|
||||
// This emoji is 11 code points? No.
|
||||
// Man (2) + ZWJ (1) + Woman (2) + ZWJ (1) + Girl (2) + ZWJ (1) + Boy (2) = 11 chars?
|
||||
// Let's just check score > 0.
|
||||
Assert.IsTrue(result.Positions.Count > 0);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Flags_Match()
|
||||
{
|
||||
const string needle = "🇺🇸"; // US Flag (Regional Indicator U + Regional Indicator S)
|
||||
const string haystack = "USA 🇺🇸";
|
||||
|
||||
var result = FuzzyStringMatcher.ScoreFuzzyWithPositions(needle, haystack, allowNonContiguousMatches: true);
|
||||
|
||||
Assert.IsTrue(result.Score > 0, "Expected match for flag emoji");
|
||||
|
||||
// 2 code points, each is surrogate pair?
|
||||
// U+1F1FA (REGIONAL INDICATOR SYMBOL LETTER U) -> 2 chars
|
||||
// U+1F1F8 (REGIONAL INDICATOR SYMBOL LETTER S) -> 2 chars
|
||||
// Total 4 chars.
|
||||
Assert.AreEqual(4, result.Positions.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Emoji_MixedWithText_Search()
|
||||
{
|
||||
const string needle = "t🌮o"; // "t" + taco + "o"
|
||||
const string haystack = "taco 🌮 on tuesday";
|
||||
|
||||
var result = FuzzyStringMatcher.ScoreFuzzyWithPositions(needle, haystack, allowNonContiguousMatches: true);
|
||||
|
||||
Assert.IsTrue(result.Score > 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CommandPalette.Extensions.Toolkit.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class FuzzyMatcherNormalizationTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void Normalization_ShouldBeLengthPreserving_GermanEszett()
|
||||
{
|
||||
// "Straße" (6 chars)
|
||||
// Standard "SS" expansion would change length to 7.
|
||||
// Our normalizer must preserve length.
|
||||
var input = "Straße";
|
||||
var expectedLength = input.Length;
|
||||
|
||||
// Case 1: Remove Diacritics = true
|
||||
var normalized = Fold(input, removeDiacritics: true);
|
||||
Assert.AreEqual(expectedLength, normalized.Length, "Normalization (removeDiacritics=true) must be length preserving for 'Straße'");
|
||||
|
||||
// Verify expected mapping: ß -> ß (length 1)
|
||||
Assert.AreEqual("STRAßE", normalized);
|
||||
|
||||
// Case 2: Remove Diacritics = false
|
||||
var normalizedKeep = Fold(input, removeDiacritics: false);
|
||||
Assert.AreEqual(expectedLength, normalizedKeep.Length, "Normalization (removeDiacritics=false) must be length preserving for 'Straße'");
|
||||
|
||||
// ß maps to ß in invariant culture (length 1)
|
||||
Assert.AreEqual("STRAßE", normalizedKeep);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Normalization_ShouldBeLengthPreserving_CommonDiacritics()
|
||||
{
|
||||
var input = "Crème Brûlée";
|
||||
var expected = "CREME BRULEE";
|
||||
|
||||
var normalized = Fold(input, removeDiacritics: true);
|
||||
|
||||
Assert.AreEqual(input.Length, normalized.Length);
|
||||
Assert.AreEqual(expected, normalized);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Normalization_ShouldBeLengthPreserving_MixedComposed()
|
||||
{
|
||||
// "Ångström" -> A + ring, o + umlaut /* #no-spell-check-line */
|
||||
var input = "Ångström"; /* #no-spell-check-line */
|
||||
var expected = "ANGSTROM";
|
||||
|
||||
var normalized = Fold(input, removeDiacritics: true);
|
||||
|
||||
Assert.AreEqual(input.Length, normalized.Length);
|
||||
Assert.AreEqual(expected, normalized);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Normalization_ShouldNormalizeSlashes()
|
||||
{
|
||||
var input = @"Folder\File.txt";
|
||||
var expected = "FOLDER/FILE.TXT";
|
||||
|
||||
var normalized = Fold(input, removeDiacritics: true);
|
||||
|
||||
Assert.AreEqual(input.Length, normalized.Length);
|
||||
Assert.AreEqual(expected, normalized);
|
||||
}
|
||||
|
||||
private string Fold(string input, bool removeDiacritics)
|
||||
{
|
||||
return FuzzyStringMatcher.Folding.FoldForComparison(input, removeDiacritics);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CommandPalette.Extensions.Toolkit.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class FuzzyMatcherUnicodeGarbageTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void UnpairedHighSurrogateInNeedle_RemoveDiacritics_ShouldNotThrow()
|
||||
{
|
||||
const string needle = "\uD83D"; // high surrogate (unpaired)
|
||||
const string haystack = "abc";
|
||||
|
||||
_ = FuzzyStringMatcher.ScoreFuzzyWithPositions(
|
||||
needle,
|
||||
haystack,
|
||||
allowNonContiguousMatches: true,
|
||||
removeDiacritics: true);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UnpairedLowSurrogateInNeedle_RemoveDiacritics_ShouldNotThrow()
|
||||
{
|
||||
const string needle = "\uDC00"; // low surrogate (unpaired)
|
||||
const string haystack = "abc";
|
||||
|
||||
_ = FuzzyStringMatcher.ScoreFuzzyWithPositions(
|
||||
needle,
|
||||
haystack,
|
||||
allowNonContiguousMatches: true,
|
||||
removeDiacritics: true);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UnpairedHighSurrogateInHaystack_RemoveDiacritics_ShouldNotThrow()
|
||||
{
|
||||
const string needle = "a";
|
||||
const string haystack = "a\uD83D" + "bc"; // inject unpaired high surrogate
|
||||
|
||||
_ = FuzzyStringMatcher.ScoreFuzzyWithPositions(
|
||||
needle,
|
||||
haystack,
|
||||
allowNonContiguousMatches: true,
|
||||
removeDiacritics: true);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UnpairedLowSurrogateInHaystack_RemoveDiacritics_ShouldNotThrow()
|
||||
{
|
||||
const string needle = "a";
|
||||
const string haystack = "a\uDC00" + "bc"; // inject unpaired low surrogate
|
||||
|
||||
_ = FuzzyStringMatcher.ScoreFuzzyWithPositions(
|
||||
needle,
|
||||
haystack,
|
||||
allowNonContiguousMatches: true,
|
||||
removeDiacritics: true);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MixedSurrogatesAndMarks_RemoveDiacritics_ShouldNotThrow()
|
||||
{
|
||||
// "Garbage smoothie": unpaired surrogate + combining mark + emoji surrogate pair
|
||||
const string needle = "a\uD83D\u0301"; // 'a' + unpaired high surrogate + combining acute
|
||||
const string haystack = "a\u0301 \U0001F600"; // 'a' + combining acute + space + 😀 (valid pair)
|
||||
|
||||
_ = FuzzyStringMatcher.ScoreFuzzyWithPositions(
|
||||
needle,
|
||||
haystack,
|
||||
allowNonContiguousMatches: true,
|
||||
removeDiacritics: true);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ValidEmojiSurrogatePair_RemoveDiacritics_ShouldNotThrow_AndCanMatch()
|
||||
{
|
||||
// 😀 U+1F600 encoded as surrogate pair in UTF-16
|
||||
const string needle = "\U0001F600";
|
||||
const string haystack = "x \U0001F600 y";
|
||||
|
||||
var result = FuzzyStringMatcher.ScoreFuzzyWithPositions(
|
||||
needle,
|
||||
haystack,
|
||||
allowNonContiguousMatches: true,
|
||||
removeDiacritics: true);
|
||||
|
||||
// Keep assertions minimal: just ensure it doesn't act like "no match".
|
||||
// If your API returns score=0 for no match, this is stable.
|
||||
Assert.IsTrue(result.Score > 0, "Expected emoji to produce a match score > 0.");
|
||||
Assert.IsTrue(result.Positions.Count > 0, "Expected at least one matched position.");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DiacriticStripping_StillWorks_OnBMPNonSurrogate()
|
||||
{
|
||||
// This is a regression guard: we fixed surrogates; don't break diacritic stripping.
|
||||
// "é" should fold like "e" when removeDiacritics=true.
|
||||
const string needle = "cafe";
|
||||
const string haystack = "CAFÉ";
|
||||
|
||||
var withDiacriticsRemoved = FuzzyStringMatcher.ScoreFuzzyWithPositions(
|
||||
needle,
|
||||
haystack,
|
||||
allowNonContiguousMatches: true,
|
||||
removeDiacritics: true);
|
||||
|
||||
var withoutDiacriticsRemoved = FuzzyStringMatcher.ScoreFuzzyWithPositions(
|
||||
needle,
|
||||
haystack,
|
||||
allowNonContiguousMatches: true,
|
||||
removeDiacritics: false);
|
||||
|
||||
Assert.IsTrue(withDiacriticsRemoved.Score >= withoutDiacriticsRemoved.Score, "Removing diacritics should not make matching worse for 'CAFÉ' vs 'cafe'.");
|
||||
Assert.IsTrue(withDiacriticsRemoved.Score > 0, "Expected a match when diacritics are removed.");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void RandomUtf16Garbage_RemoveDiacritics_ShouldNotThrow()
|
||||
{
|
||||
// Deterministic pseudo-random "UTF-16 garbage", including surrogates.
|
||||
// This is a quick fuzz-lite test that’s stable across runs.
|
||||
var s1 = MakeDeterministicGarbage(seed: 1234, length: 512);
|
||||
var s2 = MakeDeterministicGarbage(seed: 5678, length: 1024);
|
||||
|
||||
_ = FuzzyStringMatcher.ScoreFuzzyWithPositions(
|
||||
s1,
|
||||
s2,
|
||||
allowNonContiguousMatches: true,
|
||||
removeDiacritics: true);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void RandomUtf16Garbage_NoDiacritics_ShouldNotThrow()
|
||||
{
|
||||
var s1 = MakeDeterministicGarbage(seed: 42, length: 512);
|
||||
var s2 = MakeDeterministicGarbage(seed: 43, length: 1024);
|
||||
|
||||
_ = FuzzyStringMatcher.ScoreFuzzyWithPositions(
|
||||
s1,
|
||||
s2,
|
||||
allowNonContiguousMatches: true,
|
||||
removeDiacritics: false);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void HighSurrogateAtEndOfHaystack_RemoveDiacritics_ShouldNotThrow()
|
||||
{
|
||||
const string needle = "a";
|
||||
const string haystack = "abc\uD83D"; // Ends with high surrogate
|
||||
|
||||
_ = FuzzyStringMatcher.ScoreFuzzyWithPositions(
|
||||
needle,
|
||||
haystack,
|
||||
allowNonContiguousMatches: true,
|
||||
removeDiacritics: true);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ComplexEmojiSequence_RemoveDiacritics_ShouldNotThrow()
|
||||
{
|
||||
// Family: Man, Woman, Girl, Boy
|
||||
// U+1F468 U+200D U+1F469 U+200D U+1F467 U+200D U+1F466
|
||||
const string needle = "\U0001F468";
|
||||
const string haystack = "Info: \U0001F468\u200D\U0001F469\u200D\U0001F467\u200D\U0001F466 family";
|
||||
|
||||
_ = FuzzyStringMatcher.ScoreFuzzyWithPositions(
|
||||
needle,
|
||||
haystack,
|
||||
allowNonContiguousMatches: true,
|
||||
removeDiacritics: true);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void NullOrEmptyInputs_ShouldNotThrow()
|
||||
{
|
||||
// Empty needle
|
||||
var result1 = FuzzyStringMatcher.ScoreFuzzyWithPositions(string.Empty, "abc", true, true);
|
||||
Assert.AreEqual(0, result1.Score);
|
||||
|
||||
// Empty haystack
|
||||
var result2 = FuzzyStringMatcher.ScoreFuzzyWithPositions("abc", string.Empty, true, true);
|
||||
Assert.AreEqual(0, result2.Score);
|
||||
|
||||
// Null haystack
|
||||
var result3 = FuzzyStringMatcher.ScoreFuzzyWithPositions("abc", null!, true, true);
|
||||
Assert.AreEqual(0, result3.Score);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void VeryLongStrings_ShouldNotThrow()
|
||||
{
|
||||
var needle = new string('a', 100);
|
||||
var haystack = new string('b', 10000) + needle + new string('c', 10000);
|
||||
|
||||
_ = FuzzyStringMatcher.ScoreFuzzyWithPositions(
|
||||
needle,
|
||||
haystack,
|
||||
allowNonContiguousMatches: true,
|
||||
removeDiacritics: true);
|
||||
}
|
||||
|
||||
private static string MakeDeterministicGarbage(int seed, int length)
|
||||
{
|
||||
// LCG for deterministic generation without Random’s platform/version surprises.
|
||||
var x = (uint)seed;
|
||||
var chars = length <= 2048 ? stackalloc char[length] : new char[length];
|
||||
|
||||
for (var i = 0; i < chars.Length; i++)
|
||||
{
|
||||
// LCG: x = (a*x + c) mod 2^32
|
||||
x = unchecked((1664525u * x) + 1013904223u);
|
||||
|
||||
// Take top 16 bits as UTF-16 code unit (includes surrogates).
|
||||
chars[i] = (char)(x >> 16);
|
||||
}
|
||||
|
||||
return new string(chars);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,8 @@ public sealed class AppItem
|
||||
|
||||
public string? FullExecutablePath { get; set; }
|
||||
|
||||
public string? JumboIconPath { get; set; }
|
||||
|
||||
public AppItem()
|
||||
{
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ using System.Threading.Tasks;
|
||||
using ManagedCommon;
|
||||
using Microsoft.CmdPal.Core.Common.Helpers;
|
||||
using Microsoft.CmdPal.Ext.Apps.Commands;
|
||||
using Microsoft.CmdPal.Ext.Apps.Helpers;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
@@ -17,6 +18,7 @@ public sealed partial class AppListItem : ListItem
|
||||
{
|
||||
private readonly AppCommand _appCommand;
|
||||
private readonly AppItem _app;
|
||||
|
||||
private readonly Lazy<Task<IconInfo?>> _iconLoadTask;
|
||||
private readonly Lazy<Task<Details>> _detailsLoadTask;
|
||||
|
||||
@@ -66,7 +68,7 @@ public sealed partial class AppListItem : ListItem
|
||||
MoreCommands = AddPinCommands(_app.Commands!, isPinned);
|
||||
|
||||
_detailsLoadTask = new Lazy<Task<Details>>(BuildDetails);
|
||||
_iconLoadTask = new Lazy<Task<IconInfo?>>(async () => await FetchIcon(useThumbnails));
|
||||
_iconLoadTask = new Lazy<Task<IconInfo?>>(async () => await FetchIcon(useThumbnails).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
private async Task LoadDetailsAsync()
|
||||
@@ -85,7 +87,7 @@ public sealed partial class AppListItem : ListItem
|
||||
{
|
||||
try
|
||||
{
|
||||
Icon = _appCommand.Icon = await _iconLoadTask.Value ?? Icons.GenericAppIcon;
|
||||
Icon = _appCommand.Icon = CoalesceIcon(await _iconLoadTask.Value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -93,6 +95,21 @@ public sealed partial class AppListItem : ListItem
|
||||
}
|
||||
}
|
||||
|
||||
private static IconInfo CoalesceIcon(IconInfo? value)
|
||||
{
|
||||
return CoalesceIcon(value, Icons.GenericAppIcon)!;
|
||||
}
|
||||
|
||||
private static IconInfo? CoalesceIcon(IconInfo? value, IconInfo? replacement)
|
||||
{
|
||||
return IconIsNullOrEmpty(value) ? replacement : value;
|
||||
}
|
||||
|
||||
private static bool IconIsNullOrEmpty(IconInfo? value)
|
||||
{
|
||||
return value == null || (string.IsNullOrEmpty(value.Light?.Icon) && value.Light?.Data is null) || (string.IsNullOrEmpty(value.Dark?.Icon) && value.Dark?.Data is null);
|
||||
}
|
||||
|
||||
private async Task<Details> BuildDetails()
|
||||
{
|
||||
// Build metadata, with app type, path, etc.
|
||||
@@ -107,24 +124,49 @@ public sealed partial class AppListItem : ListItem
|
||||
metadata.Add(new DetailsElement() { Key = "[DEBUG] AppIdentifier", Data = new DetailsLink() { Text = _app.AppIdentifier } });
|
||||
metadata.Add(new DetailsElement() { Key = "[DEBUG] ExePath", Data = new DetailsLink() { Text = _app.ExePath } });
|
||||
metadata.Add(new DetailsElement() { Key = "[DEBUG] IcoPath", Data = new DetailsLink() { Text = _app.IcoPath } });
|
||||
metadata.Add(new DetailsElement() { Key = "[DEBUG] JumboIconPath", Data = new DetailsLink() { Text = _app.JumboIconPath ?? "(null)" } });
|
||||
#endif
|
||||
|
||||
// Icon
|
||||
IconInfo? heroImage = null;
|
||||
if (_app.IsPackaged)
|
||||
{
|
||||
heroImage = new IconInfo(_app.IcoPath);
|
||||
heroImage = new IconInfo(_app.JumboIconPath ?? _app.IcoPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Get the icon from the system
|
||||
if (!string.IsNullOrEmpty(_app.JumboIconPath))
|
||||
{
|
||||
var randomAccessStream = await IconExtractor.GetIconStreamAsync(_app.JumboIconPath, 64);
|
||||
if (randomAccessStream != null)
|
||||
{
|
||||
heroImage = IconInfo.FromStream(randomAccessStream);
|
||||
}
|
||||
}
|
||||
|
||||
if (IconIsNullOrEmpty(heroImage) && !string.IsNullOrEmpty(_app.IcoPath))
|
||||
{
|
||||
var randomAccessStream = await IconExtractor.GetIconStreamAsync(_app.IcoPath, 64);
|
||||
if (randomAccessStream != null)
|
||||
{
|
||||
heroImage = IconInfo.FromStream(randomAccessStream);
|
||||
}
|
||||
}
|
||||
|
||||
// do nothing if we fail to load an icon.
|
||||
// Logging it would be too NOISY, there's really no need.
|
||||
if (!string.IsNullOrEmpty(_app.IcoPath))
|
||||
if (IconIsNullOrEmpty(heroImage) && !string.IsNullOrEmpty(_app.JumboIconPath))
|
||||
{
|
||||
heroImage = await TryLoadThumbnail(_app.JumboIconPath, jumbo: true, logOnFailure: false);
|
||||
}
|
||||
|
||||
if (IconIsNullOrEmpty(heroImage) && !string.IsNullOrEmpty(_app.IcoPath))
|
||||
{
|
||||
heroImage = await TryLoadThumbnail(_app.IcoPath, jumbo: true, logOnFailure: false);
|
||||
}
|
||||
|
||||
if (heroImage == null && !string.IsNullOrEmpty(_app.ExePath))
|
||||
if (IconIsNullOrEmpty(heroImage) && !string.IsNullOrEmpty(_app.ExePath))
|
||||
{
|
||||
heroImage = await TryLoadThumbnail(_app.ExePath, jumbo: true, logOnFailure: false);
|
||||
}
|
||||
@@ -133,8 +175,8 @@ public sealed partial class AppListItem : ListItem
|
||||
return new Details()
|
||||
{
|
||||
Title = this.Title,
|
||||
HeroImage = heroImage ?? this.Icon ?? Icons.GenericAppIcon,
|
||||
Metadata = metadata.ToArray(),
|
||||
HeroImage = CoalesceIcon(CoalesceIcon(heroImage, this.Icon as IconInfo)),
|
||||
Metadata = [..metadata],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -154,7 +196,7 @@ public sealed partial class AppListItem : ListItem
|
||||
icon = await TryLoadThumbnail(_app.IcoPath, jumbo: false, logOnFailure: true);
|
||||
}
|
||||
|
||||
if (icon == null && !string.IsNullOrEmpty(_app.ExePath))
|
||||
if (IconIsNullOrEmpty(icon) && !string.IsNullOrEmpty(_app.ExePath))
|
||||
{
|
||||
icon = await TryLoadThumbnail(_app.ExePath, jumbo: false, logOnFailure: true);
|
||||
}
|
||||
@@ -196,22 +238,25 @@ public sealed partial class AppListItem : ListItem
|
||||
|
||||
private async Task<IconInfo?> TryLoadThumbnail(string path, bool jumbo, bool logOnFailure)
|
||||
{
|
||||
try
|
||||
return await Task.Run(async () =>
|
||||
{
|
||||
var stream = await ThumbnailHelper.GetThumbnail(path, jumbo);
|
||||
if (stream is not null)
|
||||
try
|
||||
{
|
||||
return IconInfo.FromStream(stream);
|
||||
var stream = await ThumbnailHelper.GetThumbnail(path, jumbo).ConfigureAwait(false);
|
||||
if (stream is not null)
|
||||
{
|
||||
return IconInfo.FromStream(stream);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (logOnFailure)
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogDebug($"Failed to load icon {path} for {AppIdentifier}:\n{ex}");
|
||||
if (logOnFailure)
|
||||
{
|
||||
Logger.LogDebug($"Failed to load icon {path} for {AppIdentifier}:\n{ex}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return null;
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Microsoft.CmdPal.Ext.Apps.Programs;
|
||||
using Microsoft.CmdPal.Ext.Apps.Utils;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Apps.Helpers;
|
||||
|
||||
internal static class AppxIconLoader
|
||||
{
|
||||
private const string ContrastWhite = "contrast-white";
|
||||
private const string ContrastBlack = "contrast-black";
|
||||
|
||||
private static readonly Dictionary<UWP.PackageVersion, List<int>> _scaleFactors = new()
|
||||
{
|
||||
{ UWP.PackageVersion.Windows10, [100, 125, 150, 200, 400] },
|
||||
{ UWP.PackageVersion.Windows81, [100, 120, 140, 160, 180] },
|
||||
{ UWP.PackageVersion.Windows8, [100] },
|
||||
};
|
||||
|
||||
private static readonly List<int> TargetSizes = [16, 24, 30, 36, 44, 60, 72, 96, 128, 180, 256];
|
||||
|
||||
private static IconSearchResult GetScaleIcons(
|
||||
string path,
|
||||
string colorscheme,
|
||||
UWP.PackageVersion packageVersion,
|
||||
bool highContrast = false)
|
||||
{
|
||||
var extension = Path.GetExtension(path);
|
||||
if (extension is null)
|
||||
{
|
||||
return IconSearchResult.NotFound();
|
||||
}
|
||||
|
||||
var end = path.Length - extension.Length;
|
||||
var prefix = path[..end];
|
||||
|
||||
if (!_scaleFactors.TryGetValue(packageVersion, out var factors))
|
||||
{
|
||||
return IconSearchResult.NotFound();
|
||||
}
|
||||
|
||||
var logoType = highContrast ? LogoType.HighContrast : LogoType.Colored;
|
||||
|
||||
// Check from highest scale factor to lowest for best quality
|
||||
for (var i = factors.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var factor = factors[i];
|
||||
string[] pathsToTry = highContrast
|
||||
?
|
||||
[
|
||||
$"{prefix}.scale-{factor}_{colorscheme}{extension}",
|
||||
$"{prefix}.{colorscheme}_scale-{factor}{extension}",
|
||||
]
|
||||
:
|
||||
[
|
||||
$"{prefix}.scale-{factor}{extension}",
|
||||
];
|
||||
|
||||
foreach (var p in pathsToTry)
|
||||
{
|
||||
if (File.Exists(p))
|
||||
{
|
||||
return IconSearchResult.FoundScaled(p, logoType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check base path (100% scale) as last resort
|
||||
if (!highContrast && File.Exists(path))
|
||||
{
|
||||
return IconSearchResult.FoundScaled(path, logoType);
|
||||
}
|
||||
|
||||
return IconSearchResult.NotFound();
|
||||
}
|
||||
|
||||
private static IconSearchResult GetTargetSizeIcon(
|
||||
string path,
|
||||
string colorscheme,
|
||||
bool highContrast = false,
|
||||
int appIconSize = 36,
|
||||
double maxSizeCoefficient = 8.0)
|
||||
{
|
||||
var extension = Path.GetExtension(path);
|
||||
if (extension is null)
|
||||
{
|
||||
return IconSearchResult.NotFound();
|
||||
}
|
||||
|
||||
var end = path.Length - extension.Length;
|
||||
var prefix = path[..end];
|
||||
var pathSizePairs = new List<(string Path, int Size)>();
|
||||
|
||||
foreach (var size in TargetSizes)
|
||||
{
|
||||
if (highContrast)
|
||||
{
|
||||
pathSizePairs.Add(($"{prefix}.targetsize-{size}_{colorscheme}{extension}", size));
|
||||
pathSizePairs.Add(($"{prefix}.{colorscheme}_targetsize-{size}{extension}", size));
|
||||
}
|
||||
else
|
||||
{
|
||||
pathSizePairs.Add(($"{prefix}.targetsize-{size}_altform-unplated{extension}", size));
|
||||
pathSizePairs.Add(($"{prefix}.targetsize-{size}{extension}", size));
|
||||
}
|
||||
}
|
||||
|
||||
var maxAllowedSize = (int)(appIconSize * maxSizeCoefficient);
|
||||
var logoType = highContrast ? LogoType.HighContrast : LogoType.Colored;
|
||||
|
||||
string? bestLargerPath = null;
|
||||
var bestLargerSize = int.MaxValue;
|
||||
|
||||
string? bestSmallerPath = null;
|
||||
var bestSmallerSize = 0;
|
||||
|
||||
foreach (var (p, size) in pathSizePairs)
|
||||
{
|
||||
if (!File.Exists(p))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (size >= appIconSize && size <= maxAllowedSize)
|
||||
{
|
||||
if (size < bestLargerSize)
|
||||
{
|
||||
bestLargerSize = size;
|
||||
bestLargerPath = p;
|
||||
}
|
||||
}
|
||||
else if (size < appIconSize)
|
||||
{
|
||||
if (size > bestSmallerSize)
|
||||
{
|
||||
bestSmallerSize = size;
|
||||
bestSmallerPath = p;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestLargerPath is not null)
|
||||
{
|
||||
return IconSearchResult.FoundTargetSize(bestLargerPath, logoType, bestLargerSize);
|
||||
}
|
||||
|
||||
if (bestSmallerPath is not null)
|
||||
{
|
||||
return IconSearchResult.FoundTargetSize(bestSmallerPath, logoType, bestSmallerSize);
|
||||
}
|
||||
|
||||
return IconSearchResult.NotFound();
|
||||
}
|
||||
|
||||
private static IconSearchResult GetColoredIcon(
|
||||
string path,
|
||||
string colorscheme,
|
||||
int iconSize,
|
||||
UWP package)
|
||||
{
|
||||
// First priority: targetsize icons (we know the exact size)
|
||||
var targetResult = GetTargetSizeIcon(path, colorscheme, highContrast: false, appIconSize: iconSize);
|
||||
if (targetResult.MeetsMinimumSize(iconSize))
|
||||
{
|
||||
return targetResult;
|
||||
}
|
||||
|
||||
var hcTargetResult = GetTargetSizeIcon(path, colorscheme, highContrast: true, appIconSize: iconSize);
|
||||
if (hcTargetResult.MeetsMinimumSize(iconSize))
|
||||
{
|
||||
return hcTargetResult;
|
||||
}
|
||||
|
||||
// Second priority: scale icons (size unknown, but higher scale = likely better)
|
||||
var scaleResult = GetScaleIcons(path, colorscheme, package.Version, highContrast: false);
|
||||
if (scaleResult.IsFound)
|
||||
{
|
||||
return scaleResult;
|
||||
}
|
||||
|
||||
var hcScaleResult = GetScaleIcons(path, colorscheme, package.Version, highContrast: true);
|
||||
if (hcScaleResult.IsFound)
|
||||
{
|
||||
return hcScaleResult;
|
||||
}
|
||||
|
||||
// Last resort: return undersized targetsize if we found one
|
||||
if (targetResult.IsFound)
|
||||
{
|
||||
return targetResult;
|
||||
}
|
||||
|
||||
if (hcTargetResult.IsFound)
|
||||
{
|
||||
return hcTargetResult;
|
||||
}
|
||||
|
||||
return IconSearchResult.NotFound();
|
||||
}
|
||||
|
||||
private static IconSearchResult SetHighContrastIcon(
|
||||
string path,
|
||||
string colorscheme,
|
||||
int iconSize,
|
||||
UWP package)
|
||||
{
|
||||
// First priority: HC targetsize icons (we know the exact size)
|
||||
var hcTargetResult = GetTargetSizeIcon(path, colorscheme, highContrast: true, appIconSize: iconSize);
|
||||
if (hcTargetResult.MeetsMinimumSize(iconSize))
|
||||
{
|
||||
return hcTargetResult;
|
||||
}
|
||||
|
||||
var targetResult = GetTargetSizeIcon(path, colorscheme, highContrast: false, appIconSize: iconSize);
|
||||
if (targetResult.MeetsMinimumSize(iconSize))
|
||||
{
|
||||
return targetResult;
|
||||
}
|
||||
|
||||
// Second priority: scale icons
|
||||
var hcScaleResult = GetScaleIcons(path, colorscheme, package.Version, highContrast: true);
|
||||
if (hcScaleResult.IsFound)
|
||||
{
|
||||
return hcScaleResult;
|
||||
}
|
||||
|
||||
var scaleResult = GetScaleIcons(path, colorscheme, package.Version, highContrast: false);
|
||||
if (scaleResult.IsFound)
|
||||
{
|
||||
return scaleResult;
|
||||
}
|
||||
|
||||
// Last resort: undersized targetsize
|
||||
if (hcTargetResult.IsFound)
|
||||
{
|
||||
return hcTargetResult;
|
||||
}
|
||||
|
||||
if (targetResult.IsFound)
|
||||
{
|
||||
return targetResult;
|
||||
}
|
||||
|
||||
return IconSearchResult.NotFound();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads an icon from a UWP package, attempting to find the best match for the requested size.
|
||||
/// </summary>
|
||||
/// <param name="uri">The relative URI to the logo asset.</param>
|
||||
/// <param name="theme">The current theme.</param>
|
||||
/// <param name="iconSize">The requested icon size in pixels.</param>
|
||||
/// <param name="package">The UWP package.</param>
|
||||
/// <returns>
|
||||
/// An IconSearchResult. Use <see cref="IconSearchResult.MeetsMinimumSize"/> to check if
|
||||
/// the icon is confirmed to be large enough, or <see cref="IconSearchResult.IsTargetSizeIcon"/>
|
||||
/// to determine if the size is known.
|
||||
/// </returns>
|
||||
internal static IconSearchResult LogoPathFromUri(
|
||||
string uri,
|
||||
Theme theme,
|
||||
int iconSize,
|
||||
UWP package)
|
||||
{
|
||||
var path = Path.Combine(package.Location, uri);
|
||||
var logo = Probe(theme, path, iconSize, package);
|
||||
if (!logo.IsFound && !uri.Contains('\\', StringComparison.Ordinal))
|
||||
{
|
||||
path = Path.Combine(package.Location, "Assets", uri);
|
||||
logo = Probe(theme, path, iconSize, package);
|
||||
}
|
||||
|
||||
return logo;
|
||||
}
|
||||
|
||||
private static IconSearchResult Probe(Theme theme, string path, int iconSize, UWP package)
|
||||
{
|
||||
return theme switch
|
||||
{
|
||||
Theme.HighContrastBlack or Theme.HighContrastOne or Theme.HighContrastTwo
|
||||
=> SetHighContrastIcon(path, ContrastBlack, iconSize, package),
|
||||
Theme.HighContrastWhite
|
||||
=> SetHighContrastIcon(path, ContrastWhite, iconSize, package),
|
||||
Theme.Light
|
||||
=> GetColoredIcon(path, ContrastWhite, iconSize, package),
|
||||
_
|
||||
=> GetColoredIcon(path, ContrastBlack, iconSize, package),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Runtime.InteropServices.WindowsRuntime;
|
||||
using System.Threading.Tasks;
|
||||
using ManagedCommon;
|
||||
using Windows.Graphics.Imaging;
|
||||
using Windows.Storage.Streams;
|
||||
using Windows.Win32;
|
||||
using Windows.Win32.Foundation;
|
||||
using Windows.Win32.Graphics.Gdi;
|
||||
using Windows.Win32.UI.Shell;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Apps.Helpers;
|
||||
|
||||
internal static class IconExtractor
|
||||
{
|
||||
public static async Task<IRandomAccessStream?> GetIconStreamAsync(string path, int size)
|
||||
{
|
||||
var bitmap = GetIcon(path, size);
|
||||
if (bitmap == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var stream = new InMemoryRandomAccessStream();
|
||||
var encoder = await BitmapEncoder.CreateAsync(BitmapEncoder.PngEncoderId, stream);
|
||||
encoder.SetSoftwareBitmap(bitmap);
|
||||
await encoder.FlushAsync();
|
||||
|
||||
stream.Seek(0);
|
||||
return stream;
|
||||
}
|
||||
|
||||
public static unsafe SoftwareBitmap? GetIcon(string path, int size)
|
||||
{
|
||||
IShellItemImageFactory* factory = null;
|
||||
HBITMAP hBitmap = default;
|
||||
|
||||
try
|
||||
{
|
||||
fixed (char* pPath = path)
|
||||
{
|
||||
var iid = IShellItemImageFactory.IID_Guid;
|
||||
var hr = PInvoke.SHCreateItemFromParsingName(
|
||||
pPath,
|
||||
null,
|
||||
&iid,
|
||||
(void**)&factory);
|
||||
|
||||
if (hr.Failed || factory == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
var requestedSize = new SIZE { cx = size, cy = size };
|
||||
var hr2 = factory->GetImage(
|
||||
requestedSize,
|
||||
SIIGBF.SIIGBF_ICONONLY | SIIGBF.SIIGBF_BIGGERSIZEOK | SIIGBF.SIIGBF_CROPTOSQUARE,
|
||||
&hBitmap);
|
||||
|
||||
if (hr2.Failed || hBitmap.IsNull)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return CreateSoftwareBitmap(hBitmap, size);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"Failed to load icon from path='{path}',size={size}", ex);
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!hBitmap.IsNull)
|
||||
{
|
||||
PInvoke.DeleteObject(hBitmap);
|
||||
}
|
||||
|
||||
if (factory != null)
|
||||
{
|
||||
factory->Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static unsafe SoftwareBitmap CreateSoftwareBitmap(HBITMAP hBitmap, int size)
|
||||
{
|
||||
var pixels = new byte[size * size * 4];
|
||||
|
||||
var bmi = new BITMAPINFO
|
||||
{
|
||||
bmiHeader = new BITMAPINFOHEADER
|
||||
{
|
||||
biSize = (uint)sizeof(BITMAPINFOHEADER),
|
||||
biWidth = size,
|
||||
biHeight = -size,
|
||||
biPlanes = 1,
|
||||
biBitCount = 32,
|
||||
biCompression = 0,
|
||||
},
|
||||
};
|
||||
|
||||
var hdc = PInvoke.GetDC(default);
|
||||
try
|
||||
{
|
||||
fixed (byte* pPixels = pixels)
|
||||
{
|
||||
_ = PInvoke.GetDIBits(
|
||||
hdc,
|
||||
hBitmap,
|
||||
0,
|
||||
(uint)size,
|
||||
pPixels,
|
||||
&bmi,
|
||||
DIB_USAGE.DIB_RGB_COLORS);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_ = PInvoke.ReleaseDC(default, hdc);
|
||||
}
|
||||
|
||||
var bitmap = new SoftwareBitmap(BitmapPixelFormat.Bgra8, size, size, BitmapAlphaMode.Premultiplied);
|
||||
bitmap.CopyFromBuffer(pixels.AsBuffer());
|
||||
return bitmap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Ext.Apps.Programs;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Apps.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Result of an icon search operation.
|
||||
/// </summary>
|
||||
internal readonly record struct IconSearchResult(
|
||||
string? LogoPath,
|
||||
LogoType LogoType,
|
||||
bool IsTargetSizeIcon,
|
||||
int? KnownSize = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether an icon was found.
|
||||
/// </summary>
|
||||
public bool IsFound => LogoPath is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if we can confirm the icon meets the minimum size.
|
||||
/// Only possible for targetsize icons where the size is encoded in the filename.
|
||||
/// </summary>
|
||||
public bool MeetsMinimumSize(int minimumSize) =>
|
||||
IsTargetSizeIcon && KnownSize >= minimumSize;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if we know the icon is undersized.
|
||||
/// Returns false if not found, or if size is unknown (scale-based icons).
|
||||
/// </summary>
|
||||
public bool IsKnownUndersized(int minimumSize) =>
|
||||
IsTargetSizeIcon && KnownSize < minimumSize;
|
||||
|
||||
public static IconSearchResult NotFound() => new(null, default, false);
|
||||
|
||||
public static IconSearchResult FoundTargetSize(string path, LogoType logoType, int size)
|
||||
=> new(path, logoType, IsTargetSizeIcon: true, size);
|
||||
|
||||
public static IconSearchResult FoundScaled(string path, LogoType logoType)
|
||||
=> new(path, logoType, IsTargetSizeIcon: false);
|
||||
}
|
||||
@@ -18,3 +18,9 @@ ShellLink
|
||||
IPersistFile
|
||||
CoTaskMemFree
|
||||
IUnknown
|
||||
IShellItemImageFactory
|
||||
DeleteObject
|
||||
GetDIBits
|
||||
GetDC
|
||||
ReleaseDC
|
||||
SIIGBF
|
||||
|
||||
@@ -8,6 +8,7 @@ using System.IO.Abstractions;
|
||||
using System.Xml;
|
||||
using ManagedCommon;
|
||||
using Microsoft.CmdPal.Ext.Apps.Commands;
|
||||
using Microsoft.CmdPal.Ext.Apps.Helpers;
|
||||
using Microsoft.CmdPal.Ext.Apps.Properties;
|
||||
using Microsoft.CmdPal.Ext.Apps.Utils;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
@@ -23,8 +24,10 @@ namespace Microsoft.CmdPal.Ext.Apps.Programs;
|
||||
[Serializable]
|
||||
public class UWPApplication : IUWPApplication
|
||||
{
|
||||
private const int ListIconSize = 20;
|
||||
private const int JumboIconSize = 64;
|
||||
|
||||
private static readonly IFileSystem FileSystem = new FileSystem();
|
||||
private static readonly IPath Path = FileSystem.Path;
|
||||
private static readonly IFile File = FileSystem.File;
|
||||
|
||||
public string AppListEntry { get; set; } = string.Empty;
|
||||
@@ -56,13 +59,15 @@ public class UWPApplication : IUWPApplication
|
||||
|
||||
public LogoType LogoType { get; set; }
|
||||
|
||||
public string JumboLogoPath { get; set; } = string.Empty;
|
||||
|
||||
public LogoType JumboLogoType { get; set; }
|
||||
|
||||
public UWP Package { get; set; }
|
||||
|
||||
private string logoUri;
|
||||
private string _logoUri;
|
||||
|
||||
private const string ContrastWhite = "contrast-white";
|
||||
|
||||
private const string ContrastBlack = "contrast-black";
|
||||
private string _jumboLogoUri;
|
||||
|
||||
// Function to set the subtitle based on the Type of application
|
||||
public static string Type()
|
||||
@@ -154,7 +159,8 @@ public class UWPApplication : IUWPApplication
|
||||
|
||||
DisplayName = ResourceFromPri(package.FullName, DisplayName);
|
||||
Description = ResourceFromPri(package.FullName, Description);
|
||||
logoUri = LogoUriFromManifest(manifestApp);
|
||||
_logoUri = LogoUriFromManifest(manifestApp);
|
||||
_jumboLogoUri = LogoUriFromManifest(manifestApp, jumbo: true);
|
||||
|
||||
Enabled = true;
|
||||
CanRunElevated = IfApplicationCanRunElevated();
|
||||
@@ -280,16 +286,24 @@ public class UWPApplication : IUWPApplication
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly Dictionary<PackageVersion, string> _logoKeyFromVersion = new Dictionary<PackageVersion, string>
|
||||
private static readonly Dictionary<PackageVersion, string> _smallLogoKeyFromVersion = new Dictionary<PackageVersion, string>
|
||||
{
|
||||
{ PackageVersion.Windows10, "Square44x44Logo" },
|
||||
{ PackageVersion.Windows81, "Square30x30Logo" },
|
||||
{ PackageVersion.Windows8, "SmallLogo" },
|
||||
};
|
||||
|
||||
internal unsafe string LogoUriFromManifest(IAppxManifestApplication* app)
|
||||
private static readonly Dictionary<PackageVersion, string> _largeLogoKeyFromVersion = new Dictionary<PackageVersion, string>
|
||||
{
|
||||
if (_logoKeyFromVersion.TryGetValue(Package.Version, out var key))
|
||||
{ PackageVersion.Windows10, "Square150x150Logo" },
|
||||
{ PackageVersion.Windows81, "Square150x150Logo" },
|
||||
{ PackageVersion.Windows8, "Logo" },
|
||||
};
|
||||
|
||||
internal unsafe string LogoUriFromManifest(IAppxManifestApplication* app, bool jumbo = false)
|
||||
{
|
||||
var logoMap = jumbo ? _largeLogoKeyFromVersion : _smallLogoKeyFromVersion;
|
||||
if (logoMap.TryGetValue(Package.Version, out var key))
|
||||
{
|
||||
var hr = app->GetStringValue(key, out var logoUriFromAppPtr);
|
||||
return ComFreeHelper.GetStringAndFree(hr, logoUriFromAppPtr);
|
||||
@@ -302,257 +316,55 @@ public class UWPApplication : IUWPApplication
|
||||
|
||||
public void UpdateLogoPath(Theme theme)
|
||||
{
|
||||
LogoPathFromUri(logoUri, theme);
|
||||
}
|
||||
|
||||
// scale factors on win10: https://learn.microsoft.com/windows/uwp/controls-and-patterns/tiles-and-notifications-app-assets#asset-size-tables,
|
||||
private static readonly Dictionary<PackageVersion, List<int>> _scaleFactors = new Dictionary<PackageVersion, List<int>>
|
||||
// Update small logo
|
||||
var logo = AppxIconLoader.LogoPathFromUri(_logoUri, theme, ListIconSize, Package);
|
||||
if (logo.IsFound)
|
||||
{
|
||||
{ PackageVersion.Windows10, new List<int> { 100, 125, 150, 200, 400 } },
|
||||
{ PackageVersion.Windows81, new List<int> { 100, 120, 140, 160, 180 } },
|
||||
{ PackageVersion.Windows8, new List<int> { 100 } },
|
||||
};
|
||||
|
||||
private bool SetScaleIcons(string path, string colorscheme, bool highContrast = false)
|
||||
{
|
||||
var extension = Path.GetExtension(path);
|
||||
if (extension is not null)
|
||||
{
|
||||
var end = path.Length - extension.Length;
|
||||
var prefix = path.Substring(0, end);
|
||||
var paths = new List<string> { };
|
||||
|
||||
if (!highContrast)
|
||||
{
|
||||
paths.Add(path);
|
||||
}
|
||||
|
||||
if (_scaleFactors.TryGetValue(Package.Version, out var factors))
|
||||
{
|
||||
foreach (var factor in factors)
|
||||
{
|
||||
if (highContrast)
|
||||
{
|
||||
paths.Add($"{prefix}.scale-{factor}_{colorscheme}{extension}");
|
||||
paths.Add($"{prefix}.{colorscheme}_scale-{factor}{extension}");
|
||||
}
|
||||
else
|
||||
{
|
||||
paths.Add($"{prefix}.scale-{factor}{extension}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// By working from the highest resolution to the lowest, we make
|
||||
// sure that we use the highest quality possible icon for the app.
|
||||
//
|
||||
// FirstOrDefault would result in us using the 1x scaled icon
|
||||
// always, which is usually too small for our needs.
|
||||
for (var i = paths.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (File.Exists(paths[i]))
|
||||
{
|
||||
LogoPath = paths[i];
|
||||
if (highContrast)
|
||||
{
|
||||
LogoType = LogoType.HighContrast;
|
||||
}
|
||||
else
|
||||
{
|
||||
LogoType = LogoType.Colored;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool SetTargetSizeIcon(string path, string colorscheme, bool highContrast = false)
|
||||
{
|
||||
var extension = Path.GetExtension(path);
|
||||
if (extension is not null)
|
||||
{
|
||||
var end = path.Length - extension.Length;
|
||||
var prefix = path.Substring(0, end);
|
||||
var paths = new List<string> { };
|
||||
const int appIconSize = 36;
|
||||
var targetSizes = new List<int> { 16, 24, 30, 36, 44, 60, 72, 96, 128, 180, 256 };
|
||||
var pathFactorPairs = new Dictionary<string, int>();
|
||||
|
||||
foreach (var factor in targetSizes)
|
||||
{
|
||||
if (highContrast)
|
||||
{
|
||||
var suffixThemePath = $"{prefix}.targetsize-{factor}_{colorscheme}{extension}";
|
||||
var prefixThemePath = $"{prefix}.{colorscheme}_targetsize-{factor}{extension}";
|
||||
paths.Add(suffixThemePath);
|
||||
paths.Add(prefixThemePath);
|
||||
pathFactorPairs.Add(suffixThemePath, factor);
|
||||
pathFactorPairs.Add(prefixThemePath, factor);
|
||||
}
|
||||
else
|
||||
{
|
||||
var simplePath = $"{prefix}.targetsize-{factor}{extension}";
|
||||
var altformUnPlatedPath = $"{prefix}.targetsize-{factor}_altform-unplated{extension}";
|
||||
paths.Add(simplePath);
|
||||
paths.Add(altformUnPlatedPath);
|
||||
pathFactorPairs.Add(simplePath, factor);
|
||||
pathFactorPairs.Add(altformUnPlatedPath, factor);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort paths by distance to desired app icon size
|
||||
var selectedIconPath = string.Empty;
|
||||
var closestDistance = int.MaxValue;
|
||||
|
||||
foreach (var p in paths)
|
||||
{
|
||||
if (File.Exists(p) && pathFactorPairs.TryGetValue(p, out var factor))
|
||||
{
|
||||
var distance = Math.Abs(factor - appIconSize);
|
||||
if (distance < closestDistance)
|
||||
{
|
||||
closestDistance = distance;
|
||||
selectedIconPath = p;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(selectedIconPath))
|
||||
{
|
||||
LogoPath = selectedIconPath;
|
||||
if (highContrast)
|
||||
{
|
||||
LogoType = LogoType.HighContrast;
|
||||
}
|
||||
else
|
||||
{
|
||||
LogoType = LogoType.Colored;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool SetColoredIcon(string path, string colorscheme)
|
||||
{
|
||||
var isSetColoredScaleIcon = SetScaleIcons(path, colorscheme);
|
||||
if (isSetColoredScaleIcon)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var isSetColoredTargetIcon = SetTargetSizeIcon(path, colorscheme);
|
||||
if (isSetColoredTargetIcon)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var isSetHighContrastScaleIcon = SetScaleIcons(path, colorscheme, true);
|
||||
if (isSetHighContrastScaleIcon)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var isSetHighContrastTargetIcon = SetTargetSizeIcon(path, colorscheme, true);
|
||||
if (isSetHighContrastTargetIcon)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool SetHighContrastIcon(string path, string colorscheme)
|
||||
{
|
||||
var isSetHighContrastScaleIcon = SetScaleIcons(path, colorscheme, true);
|
||||
if (isSetHighContrastScaleIcon)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var isSetHighContrastTargetIcon = SetTargetSizeIcon(path, colorscheme, true);
|
||||
if (isSetHighContrastTargetIcon)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var isSetColoredScaleIcon = SetScaleIcons(path, colorscheme);
|
||||
if (isSetColoredScaleIcon)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var isSetColoredTargetIcon = SetTargetSizeIcon(path, colorscheme);
|
||||
if (isSetColoredTargetIcon)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
internal void LogoPathFromUri(string uri, Theme theme)
|
||||
{
|
||||
// all https://learn.microsoft.com/windows/uwp/controls-and-patterns/tiles-and-notifications-app-assets
|
||||
// windows 10 https://msdn.microsoft.com/library/windows/apps/dn934817.aspx
|
||||
// windows 8.1 https://msdn.microsoft.com/library/windows/apps/hh965372.aspx#target_size
|
||||
// windows 8 https://msdn.microsoft.com/library/windows/apps/br211475.aspx
|
||||
string path;
|
||||
bool isLogoUriSet;
|
||||
|
||||
// Using Ordinal since this is used internally with uri
|
||||
if (uri.Contains('\\', StringComparison.Ordinal))
|
||||
{
|
||||
path = Path.Combine(Package.Location, uri);
|
||||
LogoPath = logo.LogoPath!;
|
||||
LogoType = logo.LogoType;
|
||||
}
|
||||
else
|
||||
{
|
||||
// for C:\Windows\MiracastView, etc.
|
||||
path = Path.Combine(Package.Location, "Assets", uri);
|
||||
}
|
||||
|
||||
switch (theme)
|
||||
{
|
||||
case Theme.HighContrastBlack:
|
||||
case Theme.HighContrastOne:
|
||||
case Theme.HighContrastTwo:
|
||||
isLogoUriSet = SetHighContrastIcon(path, ContrastBlack);
|
||||
break;
|
||||
case Theme.HighContrastWhite:
|
||||
isLogoUriSet = SetHighContrastIcon(path, ContrastWhite);
|
||||
break;
|
||||
case Theme.Light:
|
||||
isLogoUriSet = SetColoredIcon(path, ContrastWhite);
|
||||
break;
|
||||
default:
|
||||
isLogoUriSet = SetColoredIcon(path, ContrastBlack);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!isLogoUriSet)
|
||||
{
|
||||
LogoPath = string.Empty;
|
||||
LogoType = LogoType.Error;
|
||||
}
|
||||
|
||||
// Jumbo logo ... small logo can actually provide better result
|
||||
var jumboLogo = AppxIconLoader.LogoPathFromUri(_logoUri, theme, JumboIconSize, Package);
|
||||
if (jumboLogo.IsFound)
|
||||
{
|
||||
JumboLogoPath = jumboLogo.LogoPath!;
|
||||
JumboLogoType = jumboLogo.LogoType;
|
||||
}
|
||||
else
|
||||
{
|
||||
JumboLogoPath = string.Empty;
|
||||
JumboLogoType = LogoType.Error;
|
||||
}
|
||||
|
||||
if (!jumboLogo.MeetsMinimumSize(JumboIconSize) || !jumboLogo.IsFound)
|
||||
{
|
||||
var jumboLogoAlt = AppxIconLoader.LogoPathFromUri(_jumboLogoUri, theme, JumboIconSize, Package);
|
||||
if (jumboLogoAlt.IsFound)
|
||||
{
|
||||
JumboLogoPath = jumboLogoAlt.LogoPath!;
|
||||
JumboLogoType = jumboLogoAlt.LogoType;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public AppItem ToAppItem()
|
||||
{
|
||||
var app = this;
|
||||
var iconPath = app.LogoType != LogoType.Error ? app.LogoPath : string.Empty;
|
||||
var item = new AppItem()
|
||||
var jumboIconPath = app.JumboLogoType != LogoType.Error ? app.JumboLogoPath : string.Empty;
|
||||
var item = new AppItem
|
||||
{
|
||||
Name = app.Name,
|
||||
Subtitle = app.Description,
|
||||
Type = UWPApplication.Type(),
|
||||
IcoPath = iconPath,
|
||||
JumboIconPath = jumboIconPath,
|
||||
DirPath = app.Location,
|
||||
UserModelId = app.UserModelId,
|
||||
IsPackaged = true,
|
||||
@@ -563,116 +375,6 @@ public class UWPApplication : IUWPApplication
|
||||
return item;
|
||||
}
|
||||
|
||||
/*
|
||||
public ImageSource Logo()
|
||||
{
|
||||
if (LogoType == LogoType.Colored)
|
||||
{
|
||||
var logo = ImageFromPath(LogoPath);
|
||||
var platedImage = PlatedImage(logo);
|
||||
return platedImage;
|
||||
}
|
||||
else
|
||||
{
|
||||
return ImageFromPath(LogoPath);
|
||||
}
|
||||
}
|
||||
|
||||
private const int _dpiScale100 = 96;
|
||||
|
||||
private ImageSource PlatedImage(BitmapImage image)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(BackgroundColor))
|
||||
{
|
||||
string currentBackgroundColor;
|
||||
if (BackgroundColor == "transparent")
|
||||
{
|
||||
// Using InvariantCulture since this is internal
|
||||
currentBackgroundColor = SystemParameters.WindowGlassBrush.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
else
|
||||
{
|
||||
currentBackgroundColor = BackgroundColor;
|
||||
}
|
||||
|
||||
var padding = 8;
|
||||
var width = image.Width + (2 * padding);
|
||||
var height = image.Height + (2 * padding);
|
||||
var x = 0;
|
||||
var y = 0;
|
||||
|
||||
var group = new DrawingGroup();
|
||||
var converted = ColorConverter.ConvertFromString(currentBackgroundColor);
|
||||
if (converted is not null)
|
||||
{
|
||||
var color = (Color)converted;
|
||||
var brush = new SolidColorBrush(color);
|
||||
var pen = new Pen(brush, 1);
|
||||
var backgroundArea = new Rect(0, 0, width, height);
|
||||
var rectangleGeometry = new RectangleGeometry(backgroundArea, 8, 8);
|
||||
var rectDrawing = new GeometryDrawing(brush, pen, rectangleGeometry);
|
||||
group.Children.Add(rectDrawing);
|
||||
|
||||
var imageArea = new Rect(x + padding, y + padding, image.Width, image.Height);
|
||||
var imageDrawing = new ImageDrawing(image, imageArea);
|
||||
group.Children.Add(imageDrawing);
|
||||
|
||||
// http://stackoverflow.com/questions/6676072/get-system-drawing-bitmap-of-a-wpf-area-using-visualbrush
|
||||
var visual = new DrawingVisual();
|
||||
var context = visual.RenderOpen();
|
||||
context.DrawDrawing(group);
|
||||
context.Close();
|
||||
|
||||
var bitmap = new RenderTargetBitmap(
|
||||
Convert.ToInt32(width),
|
||||
Convert.ToInt32(height),
|
||||
_dpiScale100,
|
||||
_dpiScale100,
|
||||
PixelFormats.Pbgra32);
|
||||
|
||||
bitmap.Render(visual);
|
||||
|
||||
return bitmap;
|
||||
}
|
||||
else
|
||||
{
|
||||
ProgramLogger.Exception($"Unable to convert background string {BackgroundColor} to color for {Package.Location}", new InvalidOperationException(), GetType(), Package.Location);
|
||||
|
||||
return new BitmapImage(new Uri(Constant.ErrorIcon));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// todo use windows theme as background
|
||||
return image;
|
||||
}
|
||||
}
|
||||
|
||||
private BitmapImage ImageFromPath(string path)
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
var memoryStream = new MemoryStream();
|
||||
using (var fileStream = File.OpenRead(path))
|
||||
{
|
||||
fileStream.CopyTo(memoryStream);
|
||||
memoryStream.Position = 0;
|
||||
|
||||
var image = new BitmapImage();
|
||||
image.BeginInit();
|
||||
image.StreamSource = memoryStream;
|
||||
image.EndInit();
|
||||
return image;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// ProgramLogger.Exception($"Unable to get logo for {UserModelId} from {path} and located in {Package.Location}", new FileNotFoundException(), GetType(), path);
|
||||
return new BitmapImage(new Uri(ImageLoader.ErrorIconPath));
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"{DisplayName}: {Description}";
|
||||
|
||||
@@ -2,33 +2,43 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.CmdPal.Ext.Indexer.Data;
|
||||
using Microsoft.CmdPal.Ext.Indexer.Helpers;
|
||||
using Microsoft.CmdPal.Ext.Indexer.Properties;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
using Windows.ApplicationModel.DataTransfer;
|
||||
using Windows.Storage.Streams;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Indexer;
|
||||
|
||||
internal sealed partial class FallbackOpenFileItem : FallbackCommandItem, System.IDisposable
|
||||
internal sealed partial class FallbackOpenFileItem : FallbackCommandItem, IDisposable
|
||||
{
|
||||
private const string _id = "com.microsoft.cmdpal.builtin.indexer.fallback";
|
||||
private static readonly NoOpCommand _baseCommandWithId = new() { Id = _id };
|
||||
private const string CommandId = "com.microsoft.cmdpal.builtin.indexer.fallback";
|
||||
|
||||
private readonly CompositeFormat fallbackItemSearchPageTitleCompositeFormat = CompositeFormat.Parse(Resources.Indexer_fallback_searchPage_title);
|
||||
// Cookie to identify our queries; since we replace the SearchEngine on each search,
|
||||
// this can be a constant.
|
||||
private const uint HardQueryCookie = 10;
|
||||
private static readonly NoOpCommand BaseCommandWithId = new() { Id = CommandId };
|
||||
|
||||
private readonly SearchEngine _searchEngine = new();
|
||||
private readonly CompositeFormat _fallbackItemSearchPageTitleFormat = CompositeFormat.Parse(Resources.Indexer_fallback_searchPage_title);
|
||||
private readonly CompositeFormat _fallbackItemSearchSubtitleMultipleResults = CompositeFormat.Parse(Resources.Indexer_Fallback_MultipleResults_Subtitle);
|
||||
private readonly Lock _querySwitchLock = new();
|
||||
private readonly Lock _resultLock = new();
|
||||
|
||||
private uint _queryCookie = 10;
|
||||
|
||||
private Func<string, bool> _suppressCallback;
|
||||
private CancellationTokenSource? _currentQueryCts;
|
||||
private Func<string, bool>? _suppressCallback;
|
||||
|
||||
public FallbackOpenFileItem()
|
||||
: base(_baseCommandWithId, Resources.Indexer_Find_Path_fallback_display_title, _id)
|
||||
: base(BaseCommandWithId, Resources.Indexer_Find_Path_fallback_display_title, CommandId)
|
||||
{
|
||||
Title = string.Empty;
|
||||
Subtitle = string.Empty;
|
||||
@@ -37,118 +47,209 @@ internal sealed partial class FallbackOpenFileItem : FallbackCommandItem, System
|
||||
|
||||
public override void UpdateQuery(string query)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
Command = new NoOpCommand();
|
||||
Title = string.Empty;
|
||||
Subtitle = string.Empty;
|
||||
Icon = null;
|
||||
MoreCommands = null;
|
||||
DataPackage = null;
|
||||
UpdateQueryCore(query);
|
||||
}
|
||||
|
||||
private void UpdateQueryCore(string query)
|
||||
{
|
||||
// Calling this will cancel any ongoing query processing. We always use a new SearchEngine
|
||||
// instance per query, as SearchEngine.Query cancels/reinitializes internally.
|
||||
CancellationToken cancellationToken;
|
||||
|
||||
lock (_querySwitchLock)
|
||||
{
|
||||
_currentQueryCts?.Cancel();
|
||||
_currentQueryCts?.Dispose();
|
||||
_currentQueryCts = new CancellationTokenSource();
|
||||
cancellationToken = _currentQueryCts.Token;
|
||||
}
|
||||
|
||||
var suppressCallback = _suppressCallback;
|
||||
if (string.IsNullOrWhiteSpace(query) || (suppressCallback is not null && suppressCallback(query)))
|
||||
{
|
||||
ClearResultForCurrentQuery(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_suppressCallback is not null && _suppressCallback(query))
|
||||
try
|
||||
{
|
||||
Command = new NoOpCommand();
|
||||
Title = string.Empty;
|
||||
Subtitle = string.Empty;
|
||||
Icon = null;
|
||||
MoreCommands = null;
|
||||
DataPackage = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (Path.Exists(query))
|
||||
{
|
||||
// Exit 1: The query is a direct path to a file. Great! Return it.
|
||||
var item = new IndexerItem(fullPath: query);
|
||||
var listItemForUs = new IndexerListItem(item, IncludeBrowseCommand.AsDefault);
|
||||
Command = listItemForUs.Command;
|
||||
MoreCommands = listItemForUs.MoreCommands;
|
||||
Subtitle = item.FileName;
|
||||
Title = item.FullPath;
|
||||
Icon = listItemForUs.Icon;
|
||||
DataPackage = DataPackageHelper.CreateDataPackageForPath(listItemForUs, item.FullPath);
|
||||
|
||||
try
|
||||
var exists = Path.Exists(query);
|
||||
if (exists)
|
||||
{
|
||||
var stream = ThumbnailHelper.GetThumbnail(item.FullPath).Result;
|
||||
if (stream is not null)
|
||||
ProcessDirectPath(query, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
ProcessSearchQuery(query, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Query was superseded by a newer one - discard silently.
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
ClearResultForCurrentQuery(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ProcessDirectPath(string query, CancellationToken ct)
|
||||
{
|
||||
var item = new IndexerItem(fullPath: query);
|
||||
var indexerListItem = new IndexerListItem(item, IncludeBrowseCommand.AsDefault);
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
UpdateResultForCurrentQuery(indexerListItem, skipIcon: true, ct);
|
||||
_ = LoadIconAsync(item.FullPath, ct);
|
||||
}
|
||||
|
||||
private void ProcessSearchQuery(string query, CancellationToken ct)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
// for now the SearchEngine and SearchQuery are not thread-safe, so we create a new instance per query
|
||||
// since SearchEngine will re-initialize on a new query anyway, it doesn't seem to be a big overhead for now
|
||||
var searchEngine = new SearchEngine();
|
||||
|
||||
try
|
||||
{
|
||||
searchEngine.Query(query, queryCookie: HardQueryCookie);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
// We only need to know whether there are 0, 1, or more than one result
|
||||
var results = searchEngine.FetchItems(0, 2, queryCookie: HardQueryCookie, out _, noIcons: true);
|
||||
var count = results.Count;
|
||||
|
||||
if (count == 0)
|
||||
{
|
||||
ClearResultForCurrentQuery(ct);
|
||||
}
|
||||
else if (count == 1)
|
||||
{
|
||||
if (results[0] is IndexerListItem indexerListItem)
|
||||
{
|
||||
var data = new IconData(RandomAccessStreamReference.CreateFromStream(stream));
|
||||
Icon = new IconInfo(data, data);
|
||||
UpdateResultForCurrentQuery(indexerListItem, skipIcon: true, ct);
|
||||
_ = LoadIconAsync(indexerListItem.FilePath, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
ClearResultForCurrentQuery(ct);
|
||||
}
|
||||
}
|
||||
catch
|
||||
else
|
||||
{
|
||||
var indexerPage = new IndexerPage(query);
|
||||
|
||||
var set = UpdateResultForCurrentQuery(
|
||||
string.Format(CultureInfo.CurrentCulture, _fallbackItemSearchPageTitleFormat, query),
|
||||
string.Format(CultureInfo.CurrentCulture, _fallbackItemSearchSubtitleMultipleResults),
|
||||
Icons.FileExplorerIcon,
|
||||
indexerPage,
|
||||
MoreCommands,
|
||||
DataPackage,
|
||||
skipIcon: false,
|
||||
ct);
|
||||
|
||||
if (!set)
|
||||
{
|
||||
// if we failed to set the result (query was cancelled), dispose the page and search engine
|
||||
indexerPage.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
else
|
||||
finally
|
||||
{
|
||||
_queryCookie++;
|
||||
searchEngine?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
private async Task LoadIconAsync(string path, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var stream = await ThumbnailHelper.GetThumbnail(path).ConfigureAwait(false);
|
||||
if (stream is null || ct.IsCancellationRequested)
|
||||
{
|
||||
_searchEngine.Query(query, _queryCookie);
|
||||
var results = _searchEngine.FetchItems(0, 20, _queryCookie, out var _);
|
||||
|
||||
if (results.Count == 0 || (results[0] is not IndexerListItem indexerListItem))
|
||||
{
|
||||
// Exit 2: We searched for the file, and found nothing. Oh well.
|
||||
// Hide ourselves.
|
||||
Title = string.Empty;
|
||||
Subtitle = string.Empty;
|
||||
Command = new NoOpCommand();
|
||||
MoreCommands = null;
|
||||
DataPackage = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (results.Count == 1)
|
||||
{
|
||||
// Exit 3: We searched for the file, and found exactly one thing. Awesome!
|
||||
// Return it.
|
||||
Title = indexerListItem.Title;
|
||||
Subtitle = indexerListItem.Subtitle;
|
||||
Icon = indexerListItem.Icon;
|
||||
Command = indexerListItem.Command;
|
||||
MoreCommands = indexerListItem.MoreCommands;
|
||||
DataPackage = DataPackageHelper.CreateDataPackageForPath(indexerListItem, indexerListItem.FilePath);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Exit 4: We found more than one result. Make our command take
|
||||
// us to the file search page, prepopulated with this search.
|
||||
var indexerPage = new IndexerPage(query, _searchEngine, _queryCookie, results);
|
||||
Title = string.Format(CultureInfo.CurrentCulture, fallbackItemSearchPageTitleCompositeFormat, query);
|
||||
Icon = Icons.FileExplorerIcon;
|
||||
Command = indexerPage;
|
||||
MoreCommands = null;
|
||||
DataPackage = null;
|
||||
|
||||
return;
|
||||
}
|
||||
catch
|
||||
|
||||
var thumbnailStream = RandomAccessStreamReference.CreateFromStream(stream);
|
||||
if (ct.IsCancellationRequested)
|
||||
{
|
||||
Title = string.Empty;
|
||||
Subtitle = string.Empty;
|
||||
Icon = null;
|
||||
Command = new NoOpCommand();
|
||||
MoreCommands = null;
|
||||
DataPackage = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var data = new IconData(thumbnailStream);
|
||||
UpdateIconForCurrentQuery(new IconInfo(data), ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore - keep default icon
|
||||
UpdateIconForCurrentQuery(Icons.FileExplorerIcon, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private bool ClearResultForCurrentQuery(CancellationToken ct)
|
||||
{
|
||||
return UpdateResultForCurrentQuery(string.Empty, string.Empty, Icons.FileExplorerIcon, BaseCommandWithId, null, null, false, ct);
|
||||
}
|
||||
|
||||
private bool UpdateResultForCurrentQuery(IndexerListItem listItem, bool skipIcon, CancellationToken ct)
|
||||
{
|
||||
return UpdateResultForCurrentQuery(
|
||||
listItem.Title,
|
||||
listItem.Subtitle,
|
||||
listItem.Icon,
|
||||
listItem.Command,
|
||||
listItem.MoreCommands,
|
||||
DataPackageHelper.CreateDataPackageForPath(listItem, listItem.FilePath),
|
||||
skipIcon,
|
||||
ct);
|
||||
}
|
||||
|
||||
private bool UpdateResultForCurrentQuery(string title, string subtitle, IIconInfo? iconInfo, ICommand? command, IContextItem[]? moreCommands, DataPackage? dataPackage, bool skipIcon, CancellationToken ct)
|
||||
{
|
||||
lock (_resultLock)
|
||||
{
|
||||
if (ct.IsCancellationRequested)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Title = title;
|
||||
Subtitle = subtitle;
|
||||
if (!skipIcon)
|
||||
{
|
||||
Icon = iconInfo!;
|
||||
}
|
||||
|
||||
MoreCommands = moreCommands!;
|
||||
DataPackage = dataPackage;
|
||||
Command = command;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateIconForCurrentQuery(IIconInfo icon, CancellationToken ct)
|
||||
{
|
||||
lock (_resultLock)
|
||||
{
|
||||
if (ct.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Icon = icon;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_searchEngine.Dispose();
|
||||
_currentQueryCts?.Cancel();
|
||||
_currentQueryCts?.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
@@ -14,51 +16,83 @@ namespace Microsoft.CmdPal.Ext.Indexer.Helpers;
|
||||
|
||||
internal static class DataPackageHelper
|
||||
{
|
||||
public static DataPackage CreateDataPackageForPath(ICommandItem listItem, string path)
|
||||
public static DataPackage? CreateDataPackageForPath(ICommandItem listItem, string? path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path))
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var dataPackage = new DataPackage();
|
||||
dataPackage.SetText(path);
|
||||
_ = dataPackage.TrySetStorageItemsAsync(path);
|
||||
dataPackage.Properties.Title = listItem.Title;
|
||||
dataPackage.Properties.Description = listItem.Subtitle;
|
||||
dataPackage.RequestedOperation = DataPackageOperation.Copy;
|
||||
// Capture now; don't rely on listItem still being valid later.
|
||||
var title = listItem.Title;
|
||||
var description = listItem.Subtitle;
|
||||
var capturedPath = path;
|
||||
|
||||
var dataPackage = new DataPackage
|
||||
{
|
||||
RequestedOperation = DataPackageOperation.Copy,
|
||||
Properties =
|
||||
{
|
||||
Title = title,
|
||||
Description = description,
|
||||
},
|
||||
};
|
||||
|
||||
// Cheap + immediate.
|
||||
dataPackage.SetText(capturedPath);
|
||||
|
||||
// Expensive + only computed if the consumer asks for StorageItems.
|
||||
dataPackage.SetDataProvider(StandardDataFormats.StorageItems, async void (request) =>
|
||||
{
|
||||
var deferral = request.GetDeferral();
|
||||
try
|
||||
{
|
||||
var items = await TryGetStorageItemAsync(capturedPath).ConfigureAwait(false);
|
||||
if (items is not null)
|
||||
{
|
||||
request.SetData(items);
|
||||
}
|
||||
|
||||
// If null: just don't provide StorageItems. Text still works.
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow: better to provide partial data (text) than fail the whole package.
|
||||
}
|
||||
finally
|
||||
{
|
||||
deferral.Complete();
|
||||
}
|
||||
});
|
||||
|
||||
return dataPackage;
|
||||
}
|
||||
|
||||
public static async Task<bool> TrySetStorageItemsAsync(this DataPackage dataPackage, string filePath)
|
||||
private static async Task<IStorageItem[]?> TryGetStorageItemAsync(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
var file = await StorageFile.GetFileFromPathAsync(filePath);
|
||||
dataPackage.SetStorageItems([file]);
|
||||
return true;
|
||||
return [file];
|
||||
}
|
||||
|
||||
if (Directory.Exists(filePath))
|
||||
{
|
||||
var folder = await StorageFolder.GetFolderFromPathAsync(filePath);
|
||||
dataPackage.SetStorageItems([folder]);
|
||||
return true;
|
||||
return [folder];
|
||||
}
|
||||
|
||||
// nothing there
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// Access denied – skip or report, but don't crash
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
catch (Exception)
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
@@ -17,66 +18,25 @@ namespace Microsoft.CmdPal.Ext.Indexer.Indexer;
|
||||
|
||||
internal sealed partial class SearchQuery : IDisposable
|
||||
{
|
||||
private readonly Lock _lockObject = new(); // Lock object for synchronization
|
||||
private readonly DBPROPIDSET dbPropIdSet;
|
||||
[SuppressMessage("StyleCop.CSharp.NamingRules", "SA1310:Field names should not contain underscore", Justification = "Indexing Service constant")]
|
||||
private const int QUERY_E_ALLNOISE = unchecked((int)0x80041605);
|
||||
|
||||
private uint reuseWhereID;
|
||||
private EventWaitHandle queryCompletedEvent;
|
||||
private Timer queryTpTimer;
|
||||
private IRowset currentRowset;
|
||||
private IRowset reuseRowset;
|
||||
private readonly Lock _lockObject = new();
|
||||
|
||||
public uint Cookie { get; set; }
|
||||
private IRowset _currentRowset;
|
||||
|
||||
public QueryState State { get; private set; } = QueryState.NotStarted;
|
||||
|
||||
private int? LastHResult { get; set; }
|
||||
|
||||
private string LastErrorMessage { get; set; }
|
||||
|
||||
public uint Cookie { get; private set; }
|
||||
|
||||
public string SearchText { get; private set; }
|
||||
|
||||
public ConcurrentQueue<SearchResult> SearchResults { get; private set; } = [];
|
||||
|
||||
public SearchQuery()
|
||||
{
|
||||
dbPropIdSet = new DBPROPIDSET
|
||||
{
|
||||
rgPropertyIDs = Marshal.AllocCoTaskMem(sizeof(uint)), // Allocate memory for the property ID array
|
||||
cPropertyIDs = 1,
|
||||
guidPropertySet = new Guid("AA6EE6B0-E828-11D0-B23E-00AA0047FC01"), // DBPROPSET_MSIDXS_ROWSETEXT,
|
||||
};
|
||||
|
||||
// Copy the property ID into the allocated memory
|
||||
Marshal.WriteInt32(dbPropIdSet.rgPropertyIDs, 8); // MSIDXSPROP_WHEREID
|
||||
|
||||
Init();
|
||||
}
|
||||
|
||||
private void Init()
|
||||
{
|
||||
// Create all the objects we will want cached
|
||||
try
|
||||
{
|
||||
queryTpTimer = new Timer(QueryTimerCallback, this, Timeout.Infinite, Timeout.Infinite);
|
||||
if (queryTpTimer is null)
|
||||
{
|
||||
Logger.LogError("Failed to create query timer");
|
||||
return;
|
||||
}
|
||||
|
||||
queryCompletedEvent = new EventWaitHandle(false, EventResetMode.ManualReset);
|
||||
if (queryCompletedEvent is null)
|
||||
{
|
||||
Logger.LogError("Failed to create query completed event");
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute a synchronous query on file items to prime the index and keep that handle around
|
||||
PrimeIndexAndCacheWhereId();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Exception at SearchUXQueryHelper Init", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void WaitForQueryCompletedEvent() => queryCompletedEvent.WaitOne();
|
||||
|
||||
public void CancelOutstandingQueries()
|
||||
{
|
||||
Logger.LogDebug("Cancel query " + SearchText);
|
||||
@@ -84,14 +44,7 @@ internal sealed partial class SearchQuery : IDisposable
|
||||
// Are we currently doing work? If so, let's cancel
|
||||
lock (_lockObject)
|
||||
{
|
||||
if (queryTpTimer is not null)
|
||||
{
|
||||
queryTpTimer.Change(Timeout.Infinite, Timeout.Infinite);
|
||||
queryTpTimer.Dispose();
|
||||
queryTpTimer = null;
|
||||
}
|
||||
|
||||
Init();
|
||||
State = QueryState.Cancelled;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,40 +55,32 @@ internal sealed partial class SearchQuery : IDisposable
|
||||
ExecuteSyncInternal();
|
||||
}
|
||||
|
||||
public static void QueryTimerCallback(object state)
|
||||
{
|
||||
var pQueryHelper = (SearchQuery)state;
|
||||
pQueryHelper.ExecuteSyncInternal();
|
||||
}
|
||||
|
||||
private void ExecuteSyncInternal()
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
var queryStr = QueryStringBuilder.GenerateQuery(SearchText, reuseWhereID);
|
||||
State = QueryState.Running;
|
||||
LastHResult = null;
|
||||
LastErrorMessage = null;
|
||||
|
||||
var queryStr = QueryStringBuilder.GenerateQuery(SearchText);
|
||||
try
|
||||
{
|
||||
// We need to generate a search query string with the search text the user entered above
|
||||
if (currentRowset is not null)
|
||||
{
|
||||
// We have a previous rowset, this means the user is typing and we should store this
|
||||
// recapture the where ID from this so the next ExecuteSync call will be faster
|
||||
reuseRowset = currentRowset;
|
||||
reuseWhereID = GetReuseWhereId(reuseRowset);
|
||||
}
|
||||
|
||||
currentRowset = ExecuteCommand(queryStr);
|
||||
var result = ExecuteCommand(queryStr);
|
||||
_currentRowset = result.Rowset;
|
||||
State = result.State;
|
||||
LastHResult = result.HResult;
|
||||
LastErrorMessage = result.ErrorMessage;
|
||||
|
||||
SearchResults.Clear();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
State = QueryState.ExecuteFailed;
|
||||
LastHResult = ex.HResult;
|
||||
LastErrorMessage = ex.Message;
|
||||
Logger.LogError("Error executing query", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
queryCompletedEvent.Set();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,31 +115,68 @@ internal sealed partial class SearchQuery : IDisposable
|
||||
|
||||
public bool FetchRows(int offset, int limit)
|
||||
{
|
||||
if (currentRowset is null)
|
||||
if (_currentRowset is null)
|
||||
{
|
||||
Logger.LogError("No rowset to fetch rows from");
|
||||
var message = $"No rowset to fetch rows from. State={State}, HResult={LastHResult}, Error='{LastErrorMessage}'";
|
||||
|
||||
switch (State)
|
||||
{
|
||||
case QueryState.NoResults:
|
||||
case QueryState.AllNoise:
|
||||
Logger.LogDebug(message);
|
||||
break;
|
||||
case QueryState.NotStarted:
|
||||
case QueryState.Cancelled:
|
||||
case QueryState.Running:
|
||||
Logger.LogInfo(message);
|
||||
break;
|
||||
default:
|
||||
Logger.LogError(message);
|
||||
break;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
IGetRow getRow = null;
|
||||
IGetRow getRow;
|
||||
|
||||
try
|
||||
{
|
||||
getRow = (IGetRow)currentRowset;
|
||||
getRow = (IGetRow)_currentRowset;
|
||||
}
|
||||
catch (Exception)
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogInfo("Reset the current rowset");
|
||||
Logger.LogInfo($"Reset the current rowset. State={State}, HResult={LastHResult}, Error='{LastErrorMessage}'");
|
||||
Logger.LogError("Failed to cast current rowset to IGetRow", ex);
|
||||
|
||||
ExecuteSyncInternal();
|
||||
getRow = (IGetRow)currentRowset;
|
||||
|
||||
if (_currentRowset is null)
|
||||
{
|
||||
var message = $"Failed to reset rowset. State={State}, HResult={LastHResult}, Error='{LastErrorMessage}'";
|
||||
|
||||
switch (State)
|
||||
{
|
||||
case QueryState.NoResults:
|
||||
case QueryState.AllNoise:
|
||||
Logger.LogDebug(message);
|
||||
break;
|
||||
default:
|
||||
Logger.LogError(message);
|
||||
break;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
getRow = (IGetRow)_currentRowset;
|
||||
}
|
||||
|
||||
uint rowCountReturned;
|
||||
var prghRows = IntPtr.Zero;
|
||||
|
||||
try
|
||||
{
|
||||
currentRowset.GetNextRows(IntPtr.Zero, offset, limit, out rowCountReturned, out prghRows);
|
||||
_currentRowset.GetNextRows(IntPtr.Zero, offset, limit, out var rowCountReturned, out prghRows);
|
||||
|
||||
if (rowCountReturned == 0)
|
||||
{
|
||||
@@ -215,7 +197,7 @@ internal sealed partial class SearchQuery : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
currentRowset.ReleaseRows(rowCountReturned, rowHandles, IntPtr.Zero, null, null);
|
||||
_currentRowset.ReleaseRows(rowCountReturned, rowHandles, IntPtr.Zero, null, null);
|
||||
|
||||
Marshal.FreeCoTaskMem(prghRows);
|
||||
prghRows = IntPtr.Zero;
|
||||
@@ -236,141 +218,91 @@ internal sealed partial class SearchQuery : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private void PrimeIndexAndCacheWhereId()
|
||||
{
|
||||
var queryStr = QueryStringBuilder.GeneratePrimingQuery();
|
||||
var rowset = ExecuteCommand(queryStr);
|
||||
if (rowset is not null)
|
||||
{
|
||||
reuseRowset = rowset;
|
||||
reuseWhereID = GetReuseWhereId(reuseRowset);
|
||||
}
|
||||
}
|
||||
|
||||
private unsafe IRowset ExecuteCommand(string queryStr)
|
||||
private static ExecuteCommandResult ExecuteCommand(string queryStr)
|
||||
{
|
||||
if (string.IsNullOrEmpty(queryStr))
|
||||
{
|
||||
return null;
|
||||
return new ExecuteCommandResult(Rowset: null, State: QueryState.ExecuteFailed, HResult: null, ErrorMessage: "Query string was empty.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var session = (IDBCreateSession)DataSourceManager.GetDataSource();
|
||||
var dataSource = DataSourceManager.GetDataSource();
|
||||
if (dataSource is null)
|
||||
{
|
||||
Logger.LogError("GetDataSource returned null");
|
||||
return new ExecuteCommandResult(Rowset: null, State: QueryState.NullDataSource, HResult: null, ErrorMessage: "GetDataSource returned null.");
|
||||
}
|
||||
|
||||
var session = (IDBCreateSession)dataSource;
|
||||
var guid = typeof(IDBCreateCommand).GUID;
|
||||
session.CreateSession(IntPtr.Zero, ref guid, out var ppDBSession);
|
||||
|
||||
if (ppDBSession is null)
|
||||
{
|
||||
Logger.LogError("CreateSession failed");
|
||||
return null;
|
||||
return new ExecuteCommandResult(Rowset: null, State: QueryState.CreateSessionFailed, HResult: null, ErrorMessage: "CreateSession returned null session.");
|
||||
}
|
||||
|
||||
var createCommand = (IDBCreateCommand)ppDBSession;
|
||||
guid = typeof(ICommandText).GUID;
|
||||
createCommand.CreateCommand(IntPtr.Zero, ref guid, out ICommandText commandText);
|
||||
createCommand.CreateCommand(IntPtr.Zero, ref guid, out var commandText);
|
||||
|
||||
if (commandText is null)
|
||||
{
|
||||
Logger.LogError("Failed to get ICommandText interface");
|
||||
return null;
|
||||
return new ExecuteCommandResult(Rowset: null, State: QueryState.CreateCommandFailed, HResult: null, ErrorMessage: "CreateCommand returned null command.");
|
||||
}
|
||||
|
||||
var riid = NativeHelpers.OleDb.DbGuidDefault;
|
||||
|
||||
var irowSetRiid = typeof(IRowset).GUID;
|
||||
|
||||
commandText.SetCommandText(ref riid, queryStr);
|
||||
commandText.Execute(null, ref irowSetRiid, null, out var pcRowsAffected, out var rowsetPointer);
|
||||
commandText.Execute(null, ref irowSetRiid, null, out _, out var rowsetPointer);
|
||||
|
||||
return rowsetPointer;
|
||||
return rowsetPointer is null
|
||||
? new ExecuteCommandResult(Rowset: null, State: QueryState.NoResults, HResult: null, ErrorMessage: null)
|
||||
: new ExecuteCommandResult(Rowset: rowsetPointer, State: QueryState.Completed, HResult: null, ErrorMessage: null);
|
||||
}
|
||||
catch (COMException ex) when (ex.HResult == QUERY_E_ALLNOISE)
|
||||
{
|
||||
Logger.LogDebug($"Query returned all noise, no results. ({queryStr})");
|
||||
return new ExecuteCommandResult(Rowset: null, State: QueryState.AllNoise, HResult: ex.HResult, ErrorMessage: ex.Message);
|
||||
}
|
||||
catch (COMException ex)
|
||||
{
|
||||
Logger.LogError($"Unexpected COM error for query '{queryStr}'.", ex);
|
||||
return new ExecuteCommandResult(Rowset: null, State: QueryState.ExecuteFailed, HResult: ex.HResult, ErrorMessage: ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Unexpected error.", ex);
|
||||
return null;
|
||||
Logger.LogError($"Unexpected error for query '{queryStr}'.", ex);
|
||||
return new ExecuteCommandResult(Rowset: null, State: QueryState.ExecuteFailed, HResult: ex.HResult, ErrorMessage: ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private unsafe DBPROP? GetPropset(IRowsetInfo rowsetInfo)
|
||||
{
|
||||
var prgPropSetsPtr = IntPtr.Zero;
|
||||
|
||||
try
|
||||
{
|
||||
ulong cPropertySets;
|
||||
var res = rowsetInfo.GetProperties(1, [dbPropIdSet], out cPropertySets, out prgPropSetsPtr);
|
||||
if (res != 0)
|
||||
{
|
||||
Logger.LogError($"Error getting properties: {res}");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (cPropertySets == 0 || prgPropSetsPtr == IntPtr.Zero)
|
||||
{
|
||||
Logger.LogError("No property sets returned");
|
||||
return null;
|
||||
}
|
||||
|
||||
var firstPropSetPtr = (DBPROPSET*)prgPropSetsPtr.ToInt64();
|
||||
var propSet = *firstPropSetPtr;
|
||||
if (propSet.cProperties == 0 || propSet.rgProperties == IntPtr.Zero)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var propPtr = (DBPROP*)propSet.rgProperties.ToInt64();
|
||||
return *propPtr;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"Exception occurred while getting properties,", ex);
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Free the property sets pointer returned by GetProperties, if necessary
|
||||
if (prgPropSetsPtr != IntPtr.Zero)
|
||||
{
|
||||
Marshal.FreeCoTaskMem(prgPropSetsPtr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private uint GetReuseWhereId(IRowset rowset)
|
||||
{
|
||||
var rowsetInfo = (IRowsetInfo)rowset;
|
||||
|
||||
if (rowsetInfo is null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var prop = GetPropset(rowsetInfo);
|
||||
if (prop is null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (prop?.vValue.VarType == VarEnum.VT_UI4)
|
||||
{
|
||||
var value = prop?.vValue._ulong;
|
||||
return (uint)value;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
CancelOutstandingQueries();
|
||||
|
||||
// Free the allocated memory for rgPropertyIDs
|
||||
if (dbPropIdSet.rgPropertyIDs != IntPtr.Zero)
|
||||
{
|
||||
Marshal.FreeCoTaskMem(dbPropIdSet.rgPropertyIDs);
|
||||
}
|
||||
|
||||
queryCompletedEvent?.Dispose();
|
||||
}
|
||||
|
||||
internal enum QueryState
|
||||
{
|
||||
NotStarted = 0,
|
||||
Running,
|
||||
Completed,
|
||||
NoResults,
|
||||
AllNoise,
|
||||
NullDataSource,
|
||||
CreateSessionFailed,
|
||||
CreateCommandFailed,
|
||||
ExecuteFailed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
private readonly record struct ExecuteCommandResult(
|
||||
IRowset Rowset,
|
||||
QueryState State,
|
||||
int? HResult,
|
||||
string ErrorMessage);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Runtime.CompilerServices;
|
||||
using ManagedCommon;
|
||||
using ManagedCsWin32;
|
||||
@@ -11,20 +10,16 @@ using Microsoft.CmdPal.Ext.Indexer.Indexer.SystemSearch;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Indexer.Indexer.Utils;
|
||||
|
||||
internal sealed partial class QueryStringBuilder
|
||||
internal static class QueryStringBuilder
|
||||
{
|
||||
private const string Properties = "System.ItemUrl, System.ItemNameDisplay, path, System.Search.EntryID, System.Kind, System.KindText";
|
||||
private const string SystemIndex = "SystemIndex";
|
||||
private const string ScopeFileConditions = "SCOPE='file:'";
|
||||
private const string OrderConditions = "System.DateModified DESC";
|
||||
private const string SelectQueryWithScope = "SELECT " + Properties + " FROM " + SystemIndex + " WHERE (" + ScopeFileConditions + ")";
|
||||
private const string SelectQueryWithScopeAndOrderConditions = SelectQueryWithScope + " ORDER BY " + OrderConditions;
|
||||
|
||||
private static ISearchQueryHelper queryHelper;
|
||||
|
||||
public static string GeneratePrimingQuery() => SelectQueryWithScopeAndOrderConditions;
|
||||
|
||||
public static string GenerateQuery(string searchText, uint whereId)
|
||||
public static string GenerateQuery(string searchText)
|
||||
{
|
||||
if (queryHelper is null)
|
||||
{
|
||||
@@ -40,7 +35,7 @@ internal sealed partial class QueryStringBuilder
|
||||
throw;
|
||||
}
|
||||
|
||||
ISearchCatalogManager catalogManager = searchManager.GetCatalog(SystemIndex);
|
||||
var catalogManager = searchManager.GetCatalog(SystemIndex);
|
||||
if (catalogManager is null)
|
||||
{
|
||||
throw new ArgumentException($"Failed to get catalog manager for {SystemIndex}");
|
||||
@@ -57,7 +52,7 @@ internal sealed partial class QueryStringBuilder
|
||||
queryHelper.SetQuerySorting(OrderConditions);
|
||||
}
|
||||
|
||||
queryHelper.SetQueryWhereRestrictions("AND " + ScopeFileConditions + "AND ReuseWhere(" + whereId.ToString(CultureInfo.InvariantCulture) + ")");
|
||||
queryHelper.SetQueryWhereRestrictions($"AND {ScopeFileConditions}");
|
||||
return queryHelper.GenerateSQLFromUserQuery(searchText);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.CmdPal.Ext.Indexer.Indexer;
|
||||
using Microsoft.CmdPal.Ext.Indexer.Properties;
|
||||
@@ -16,18 +19,25 @@ namespace Microsoft.CmdPal.Ext.Indexer;
|
||||
|
||||
internal sealed partial class IndexerPage : DynamicListPage, IDisposable
|
||||
{
|
||||
// Cookie to identify our queries; since we replace the SearchEngine on each search,
|
||||
// this can be a constant.
|
||||
private const uint HardQueryCookie = 10;
|
||||
|
||||
private readonly List<IListItem> _indexerListItems = [];
|
||||
private readonly SearchEngine _searchEngine;
|
||||
private readonly bool disposeSearchEngine = true;
|
||||
private readonly Lock _searchLock = new();
|
||||
|
||||
private uint _queryCookie;
|
||||
|
||||
private string initialQuery = string.Empty;
|
||||
private SearchEngine? _searchEngine;
|
||||
|
||||
private CancellationTokenSource? _searchCts;
|
||||
private string _initialQuery = string.Empty;
|
||||
private bool _isEmptyQuery = true;
|
||||
|
||||
private CommandItem _noSearchEmptyContent;
|
||||
private CommandItem _nothingFoundEmptyContent;
|
||||
private CommandItem? _noSearchEmptyContent;
|
||||
private CommandItem? _nothingFoundEmptyContent;
|
||||
|
||||
private bool _deferredLoad;
|
||||
|
||||
public override ICommandItem EmptyContent => _isEmptyQuery ? _noSearchEmptyContent! : _nothingFoundEmptyContent!;
|
||||
|
||||
public IndexerPage()
|
||||
{
|
||||
@@ -35,8 +45,8 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable
|
||||
Icon = Icons.FileExplorerIcon;
|
||||
Name = Resources.Indexer_Title;
|
||||
PlaceholderText = Resources.Indexer_PlaceholderText;
|
||||
|
||||
_searchEngine = new();
|
||||
_queryCookie = 10;
|
||||
|
||||
var filters = new SearchFilters();
|
||||
filters.PropChanged += Filters_PropChanged;
|
||||
@@ -45,22 +55,23 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable
|
||||
CreateEmptyContent();
|
||||
}
|
||||
|
||||
public IndexerPage(string query, SearchEngine searchEngine, uint queryCookie, IList<IListItem> firstPageData)
|
||||
public IndexerPage(string query)
|
||||
{
|
||||
Icon = Icons.FileExplorerIcon;
|
||||
Name = Resources.Indexer_Title;
|
||||
_searchEngine = searchEngine;
|
||||
_queryCookie = queryCookie;
|
||||
_indexerListItems.AddRange(firstPageData);
|
||||
initialQuery = query;
|
||||
|
||||
_searchEngine = new();
|
||||
|
||||
_initialQuery = query;
|
||||
SearchText = query;
|
||||
disposeSearchEngine = false;
|
||||
|
||||
var filters = new SearchFilters();
|
||||
filters.PropChanged += Filters_PropChanged;
|
||||
Filters = filters;
|
||||
|
||||
CreateEmptyContent();
|
||||
IsLoading = true;
|
||||
_deferredLoad = true;
|
||||
}
|
||||
|
||||
private void CreateEmptyContent()
|
||||
@@ -95,8 +106,6 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable
|
||||
ShellHelpers.OpenInShell(command);
|
||||
}
|
||||
|
||||
public override ICommandItem EmptyContent => _isEmptyQuery ? _noSearchEmptyContent : _nothingFoundEmptyContent;
|
||||
|
||||
private void Filters_PropChanged(object sender, IPropChangedEventArgs args)
|
||||
{
|
||||
PerformSearch(SearchText);
|
||||
@@ -104,35 +113,31 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable
|
||||
|
||||
public override void UpdateSearchText(string oldSearch, string newSearch)
|
||||
{
|
||||
if (oldSearch != newSearch && newSearch != initialQuery)
|
||||
if (oldSearch != newSearch && newSearch != _initialQuery)
|
||||
{
|
||||
PerformSearch(newSearch);
|
||||
}
|
||||
}
|
||||
|
||||
private void PerformSearch(string newSearch)
|
||||
public override IListItem[] GetItems()
|
||||
{
|
||||
var actualSearch = FullSearchString(newSearch);
|
||||
_ = Task.Run(() =>
|
||||
if (_deferredLoad)
|
||||
{
|
||||
_isEmptyQuery = string.IsNullOrWhiteSpace(actualSearch);
|
||||
Query(actualSearch);
|
||||
LoadMore();
|
||||
OnPropertyChanged(nameof(EmptyContent));
|
||||
initialQuery = null;
|
||||
});
|
||||
}
|
||||
PerformSearch(_initialQuery);
|
||||
_deferredLoad = false;
|
||||
}
|
||||
|
||||
public override IListItem[] GetItems() => [.. _indexerListItems];
|
||||
return [.. _indexerListItems];
|
||||
}
|
||||
|
||||
private string FullSearchString(string query)
|
||||
{
|
||||
switch (Filters.CurrentFilterId)
|
||||
switch (Filters?.CurrentFilterId)
|
||||
{
|
||||
case "folders":
|
||||
return $"{query} kind:folders";
|
||||
return $"System.Kind:folders {query}";
|
||||
case "files":
|
||||
return $"{query} kind:NOT folders";
|
||||
return $"System.Kind:NOT folders {query}";
|
||||
case "all":
|
||||
default:
|
||||
return query;
|
||||
@@ -141,28 +146,139 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable
|
||||
|
||||
public override void LoadMore()
|
||||
{
|
||||
var ct = Volatile.Read(ref _searchCts)?.Token;
|
||||
|
||||
IsLoading = true;
|
||||
var results = _searchEngine.FetchItems(_indexerListItems.Count, 20, _queryCookie, out var hasMore);
|
||||
_indexerListItems.AddRange(results);
|
||||
HasMoreItems = hasMore;
|
||||
IsLoading = false;
|
||||
RaiseItemsChanged(_indexerListItems.Count);
|
||||
|
||||
var hasMore = false;
|
||||
SearchEngine? searchEngine;
|
||||
int offset;
|
||||
|
||||
lock (_searchLock)
|
||||
{
|
||||
searchEngine = _searchEngine;
|
||||
offset = _indexerListItems.Count;
|
||||
}
|
||||
|
||||
var results = searchEngine?.FetchItems(offset, 20, queryCookie: HardQueryCookie, out hasMore) ?? [];
|
||||
|
||||
if (ct?.IsCancellationRequested == true)
|
||||
{
|
||||
IsLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_searchLock)
|
||||
{
|
||||
if (ct?.IsCancellationRequested == true)
|
||||
{
|
||||
IsLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
_indexerListItems.AddRange(results);
|
||||
HasMoreItems = hasMore;
|
||||
IsLoading = false;
|
||||
RaiseItemsChanged(_indexerListItems.Count);
|
||||
}
|
||||
}
|
||||
|
||||
private void Query(string query)
|
||||
{
|
||||
++_queryCookie;
|
||||
_indexerListItems.Clear();
|
||||
lock (_searchLock)
|
||||
{
|
||||
_indexerListItems.Clear();
|
||||
_searchEngine?.Query(query, queryCookie: HardQueryCookie);
|
||||
}
|
||||
}
|
||||
|
||||
_searchEngine.Query(query, _queryCookie);
|
||||
private void ReplaceSearchEngine(SearchEngine newSearchEngine)
|
||||
{
|
||||
SearchEngine? oldEngine;
|
||||
|
||||
lock (_searchLock)
|
||||
{
|
||||
oldEngine = _searchEngine;
|
||||
_searchEngine = newSearchEngine;
|
||||
}
|
||||
|
||||
oldEngine?.Dispose();
|
||||
}
|
||||
|
||||
private void PerformSearch(string newSearch)
|
||||
{
|
||||
var actualSearch = FullSearchString(newSearch);
|
||||
|
||||
var newCts = new CancellationTokenSource();
|
||||
var oldCts = Interlocked.Exchange(ref _searchCts, newCts);
|
||||
oldCts?.Cancel();
|
||||
oldCts?.Dispose();
|
||||
|
||||
var ct = newCts.Token;
|
||||
|
||||
_ = Task.Run(
|
||||
() =>
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
lock (_searchLock)
|
||||
{
|
||||
// If the user hasn't provided any base query text, results should be empty
|
||||
// regardless of the currently selected filter.
|
||||
_isEmptyQuery = string.IsNullOrWhiteSpace(newSearch);
|
||||
|
||||
if (_isEmptyQuery)
|
||||
{
|
||||
_indexerListItems.Clear();
|
||||
HasMoreItems = false;
|
||||
IsLoading = false;
|
||||
RaiseItemsChanged(0);
|
||||
OnPropertyChanged(nameof(EmptyContent));
|
||||
_initialQuery = string.Empty;
|
||||
return;
|
||||
}
|
||||
|
||||
// Track the most recent query we initiated, so UpdateSearchText doesn't
|
||||
// spuriously suppress a search when SearchText gets set programmatically.
|
||||
_initialQuery = newSearch;
|
||||
}
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
ReplaceSearchEngine(new SearchEngine());
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
Query(actualSearch);
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
LoadMore();
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
lock (_searchLock)
|
||||
{
|
||||
OnPropertyChanged(nameof(EmptyContent));
|
||||
}
|
||||
},
|
||||
ct);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposeSearchEngine)
|
||||
var cts = Interlocked.Exchange(ref _searchCts, null);
|
||||
cts?.Cancel();
|
||||
cts?.Dispose();
|
||||
|
||||
SearchEngine? searchEngine;
|
||||
|
||||
lock (_searchLock)
|
||||
{
|
||||
_searchEngine.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
searchEngine = _searchEngine;
|
||||
_searchEngine = null;
|
||||
_indexerListItems.Clear();
|
||||
}
|
||||
|
||||
searchEngine?.Dispose();
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ namespace Microsoft.CmdPal.Ext.Indexer.Properties {
|
||||
// class via a tool like ResGen or Visual Studio.
|
||||
// To add or remove a member, edit your .ResX file then rerun ResGen
|
||||
// with the /str option, or rebuild your VS project.
|
||||
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
|
||||
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||
internal class Resources {
|
||||
@@ -177,6 +177,15 @@ namespace Microsoft.CmdPal.Ext.Indexer.Properties {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to The query matches multiple items.
|
||||
/// </summary>
|
||||
internal static string Indexer_Fallback_MultipleResults_Subtitle {
|
||||
get {
|
||||
return ResourceManager.GetString("Indexer_Fallback_MultipleResults_Subtitle", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Search for "{0}" in files.
|
||||
/// </summary>
|
||||
|
||||
@@ -211,4 +211,7 @@ You can try searching all files on this PC or adjust your indexing settings.</va
|
||||
<data name="Indexer_Command_Peek_Failed" xml:space="preserve">
|
||||
<value>Failed to launch Peek</value>
|
||||
</data>
|
||||
<data name="Indexer_Fallback_MultipleResults_Subtitle" xml:space="preserve">
|
||||
<value>The query matches multiple items</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -2,6 +2,8 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
@@ -16,15 +18,20 @@ namespace Microsoft.CmdPal.Ext.Indexer;
|
||||
|
||||
public sealed partial class SearchEngine : IDisposable
|
||||
{
|
||||
private SearchQuery _searchQuery = new();
|
||||
private SearchQuery? _searchQuery = new();
|
||||
|
||||
public void Query(string query, uint queryCookie)
|
||||
{
|
||||
// _indexerListItems.Clear();
|
||||
_searchQuery.SearchResults.Clear();
|
||||
_searchQuery.CancelOutstandingQueries();
|
||||
var searchQuery = _searchQuery;
|
||||
if (searchQuery is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (query == string.Empty)
|
||||
searchQuery.SearchResults.Clear();
|
||||
searchQuery.CancelOutstandingQueries();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -32,64 +39,74 @@ public sealed partial class SearchEngine : IDisposable
|
||||
Stopwatch stopwatch = new();
|
||||
stopwatch.Start();
|
||||
|
||||
_searchQuery.Execute(query, queryCookie);
|
||||
searchQuery.Execute(query, queryCookie);
|
||||
|
||||
stopwatch.Stop();
|
||||
Logger.LogDebug($"Query time: {stopwatch.ElapsedMilliseconds} ms, query: \"{query}\"");
|
||||
}
|
||||
|
||||
public IList<IListItem> FetchItems(int offset, int limit, uint queryCookie, out bool hasMore)
|
||||
public IList<IListItem> FetchItems(int offset, int limit, uint queryCookie, out bool hasMore, bool noIcons = false)
|
||||
{
|
||||
hasMore = false;
|
||||
var results = new List<IListItem>();
|
||||
if (_searchQuery is not null)
|
||||
|
||||
var searchQuery = _searchQuery;
|
||||
if (searchQuery is null)
|
||||
{
|
||||
var cookie = _searchQuery.Cookie;
|
||||
if (cookie == queryCookie)
|
||||
{
|
||||
var index = 0;
|
||||
SearchResult result;
|
||||
|
||||
// var hasMoreItems = _searchQuery.FetchRows(_indexerListItems.Count, limit);
|
||||
var hasMoreItems = _searchQuery.FetchRows(offset, limit);
|
||||
|
||||
while (!_searchQuery.SearchResults.IsEmpty && _searchQuery.SearchResults.TryDequeue(out result) && ++index <= limit)
|
||||
{
|
||||
IconInfo icon = null;
|
||||
try
|
||||
{
|
||||
var stream = ThumbnailHelper.GetThumbnail(result.LaunchUri).Result;
|
||||
if (stream is not null)
|
||||
{
|
||||
var data = new IconData(RandomAccessStreamReference.CreateFromStream(stream));
|
||||
icon = new IconInfo(data, data);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to get the icon.", ex);
|
||||
}
|
||||
|
||||
results.Add(new IndexerListItem(new IndexerItem
|
||||
{
|
||||
FileName = result.ItemDisplayName,
|
||||
FullPath = result.LaunchUri,
|
||||
})
|
||||
{
|
||||
Icon = icon,
|
||||
});
|
||||
}
|
||||
|
||||
hasMore = hasMoreItems;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
var cookie = searchQuery.Cookie;
|
||||
if (cookie != queryCookie)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var results = new List<IListItem>();
|
||||
var index = 0;
|
||||
var hasMoreItems = searchQuery.FetchRows(offset, limit);
|
||||
|
||||
while (!searchQuery.SearchResults.IsEmpty && searchQuery.SearchResults.TryDequeue(out var result) && ++index <= limit)
|
||||
{
|
||||
var indexerListItem = new IndexerListItem(new IndexerItem
|
||||
{
|
||||
FileName = result.ItemDisplayName,
|
||||
FullPath = result.LaunchUri,
|
||||
});
|
||||
|
||||
if (!noIcons)
|
||||
{
|
||||
IconInfo? icon = null;
|
||||
try
|
||||
{
|
||||
var stream = ThumbnailHelper.GetThumbnail(result.LaunchUri).Result;
|
||||
if (stream is not null)
|
||||
{
|
||||
var data = new IconData(RandomAccessStreamReference.CreateFromStream(stream));
|
||||
icon = new IconInfo(data, data);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to get the icon.", ex);
|
||||
}
|
||||
|
||||
indexerListItem.Icon = icon;
|
||||
}
|
||||
|
||||
results.Add(indexerListItem);
|
||||
}
|
||||
|
||||
hasMore = hasMoreItems;
|
||||
return results;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
var searchQuery = _searchQuery;
|
||||
_searchQuery = null;
|
||||
|
||||
searchQuery?.Dispose();
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -643,7 +643,7 @@ public static class FuzzyStringMatcher
|
||||
// ============================================================
|
||||
|
||||
// Folding: slash normalization + upper case + optional diacritics stripping
|
||||
private static class Folding
|
||||
internal static class Folding
|
||||
{
|
||||
// Cache maps an upper case char to its diacritics-stripped upper case char.
|
||||
// '\0' means "not cached yet".
|
||||
@@ -820,6 +820,13 @@ public static class FuzzyStringMatcher
|
||||
return upper;
|
||||
}
|
||||
|
||||
// Emoji and other astral symbols come through as surrogate pairs in UTF-16.
|
||||
// We process char-by-char, so never try to normalize a lone surrogate.
|
||||
if (char.IsSurrogate(upper))
|
||||
{
|
||||
return upper;
|
||||
}
|
||||
|
||||
var cached = StripCacheUpper[upper];
|
||||
if (cached != '\0')
|
||||
{
|
||||
|
||||
@@ -112,8 +112,8 @@
|
||||
<controls:IsEnabledTextBlock x:Uid="LightSwitch_SunriseText" VerticalAlignment="Center" />
|
||||
<NumberBox
|
||||
AutomationProperties.AutomationId="SunriseOffset_LightSwitch"
|
||||
Maximum="60"
|
||||
Minimum="-60"
|
||||
Maximum="{x:Bind ViewModel.SunriseOffsetMax, Mode=OneWay}"
|
||||
Minimum="{x:Bind ViewModel.SunriseOffsetMin, Mode=OneWay}"
|
||||
SpinButtonPlacementMode="Compact"
|
||||
Value="{x:Bind ViewModel.SunriseOffset, Mode=TwoWay}" />
|
||||
</StackPanel>
|
||||
@@ -121,8 +121,8 @@
|
||||
<controls:IsEnabledTextBlock x:Uid="LightSwitch_SunsetText" VerticalAlignment="Center" />
|
||||
<NumberBox
|
||||
AutomationProperties.AutomationId="SunsetOffset_LightSwitch"
|
||||
Maximum="60"
|
||||
Minimum="-60"
|
||||
Maximum="{x:Bind ViewModel.SunsetOffsetMax, Mode=OneWay}"
|
||||
Minimum="{x:Bind ViewModel.SunsetOffsetMin, Mode=OneWay}"
|
||||
SpinButtonPlacementMode="Compact"
|
||||
Value="{x:Bind ViewModel.SunsetOffset, Mode=TwoWay}" />
|
||||
</StackPanel>
|
||||
|
||||
@@ -232,6 +232,8 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
NotifyPropertyChanged();
|
||||
|
||||
OnPropertyChanged(nameof(LightTimeTimeSpan));
|
||||
OnPropertyChanged(nameof(SunriseOffsetMin));
|
||||
OnPropertyChanged(nameof(SunsetOffsetMin));
|
||||
|
||||
if (ScheduleMode == "SunsetToSunrise")
|
||||
{
|
||||
@@ -252,6 +254,8 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
NotifyPropertyChanged();
|
||||
|
||||
OnPropertyChanged(nameof(DarkTimeTimeSpan));
|
||||
OnPropertyChanged(nameof(SunriseOffsetMax));
|
||||
OnPropertyChanged(nameof(SunsetOffsetMax));
|
||||
|
||||
if (ScheduleMode == "SunsetToSunrise")
|
||||
{
|
||||
@@ -270,6 +274,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
{
|
||||
ModuleSettings.Properties.SunriseOffset.Value = value;
|
||||
OnPropertyChanged(nameof(LightTimeTimeSpan));
|
||||
OnPropertyChanged(nameof(SunsetOffsetMin));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -283,10 +288,49 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
{
|
||||
ModuleSettings.Properties.SunsetOffset.Value = value;
|
||||
OnPropertyChanged(nameof(DarkTimeTimeSpan));
|
||||
OnPropertyChanged(nameof(SunriseOffsetMax));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int SunriseOffsetMin
|
||||
{
|
||||
get
|
||||
{
|
||||
// Minimum: don't let adjusted sunrise go before 00:00
|
||||
return -LightTime;
|
||||
}
|
||||
}
|
||||
|
||||
public int SunriseOffsetMax
|
||||
{
|
||||
get
|
||||
{
|
||||
// Maximum: adjusted sunrise must stay before adjusted sunset
|
||||
int adjustedSunset = DarkTime + SunsetOffset;
|
||||
return Math.Max(0, adjustedSunset - LightTime - 1);
|
||||
}
|
||||
}
|
||||
|
||||
public int SunsetOffsetMin
|
||||
{
|
||||
get
|
||||
{
|
||||
// Minimum: adjusted sunset must stay after adjusted sunrise
|
||||
int adjustedSunrise = LightTime + SunriseOffset;
|
||||
return Math.Min(0, adjustedSunrise - DarkTime + 1);
|
||||
}
|
||||
}
|
||||
|
||||
public int SunsetOffsetMax
|
||||
{
|
||||
get
|
||||
{
|
||||
// Maximum: don't let adjusted sunset go past 23:59 (1439 minutes)
|
||||
return 1439 - DarkTime;
|
||||
}
|
||||
}
|
||||
|
||||
// === Computed projections (OneWay bindings only) ===
|
||||
public TimeSpan LightTimeTimeSpan
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user