diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPaletteContentPageViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPaletteContentPageViewModel.cs index b0b65e7f70..ed6abebcb7 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPaletteContentPageViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPaletteContentPageViewModel.cs @@ -20,6 +20,8 @@ public partial class CommandPaletteContentPageViewModel : ContentPageViewModel IFormContent form => new ContentFormViewModel(form, context), IMarkdownContent markdown => new ContentMarkdownViewModel(markdown, context), ITreeContent tree => new ContentTreeViewModel(tree, context), + IPlainTextContent plainText => new ContentPlainTextViewModel(plainText, context), + IImageContent image => new ContentImageViewModel(image, context), _ => null, }; return viewModel; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentImageViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentImageViewModel.cs new file mode 100644 index 0000000000..00695bb8ee --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentImageViewModel.cs @@ -0,0 +1,94 @@ +// 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.UI.ViewModels.Models; +using Microsoft.CommandPalette.Extensions; + +namespace Microsoft.CmdPal.UI.ViewModels; + +public partial class ContentImageViewModel : ContentViewModel +{ + public ExtensionObject Model { get; } + + public IconInfoViewModel Image { get; protected set; } = new(null); + + public double MaxWidth { get; protected set; } = double.PositiveInfinity; + + public double MaxHeight { get; protected set; } = double.PositiveInfinity; + + public ContentImageViewModel(IImageContent content, WeakReference context) + : base(context) + { + Model = new ExtensionObject(content); + } + + public override void InitializeProperties() + { + var model = Model.Unsafe; + if (model is null) + { + return; + } + + Image = new IconInfoViewModel(model.Image); + Image.InitializeProperties(); + + MaxWidth = model.MaxWidth <= 0 ? double.PositiveInfinity : model.MaxWidth; + MaxHeight = model.MaxHeight <= 0 ? double.PositiveInfinity : model.MaxHeight; + + UpdateProperty(nameof(Image), nameof(MaxWidth), nameof(MaxHeight)); + model.PropChanged += Model_PropChanged; + } + + private void Model_PropChanged(object sender, IPropChangedEventArgs args) + { + try + { + var propName = args.PropertyName; + FetchProperty(propName); + } + catch (Exception ex) + { + ShowException(ex); + } + } + + private void FetchProperty(string propertyName) + { + var model = Model.Unsafe; + if (model is null) + { + return; // throw? + } + + switch (propertyName) + { + case nameof(Image): + Image = new IconInfoViewModel(model.Image); + Image.InitializeProperties(); + UpdateProperty(propertyName); + break; + + case nameof(IImageContent.MaxWidth): + MaxWidth = model.MaxWidth <= 0 ? double.PositiveInfinity : model.MaxWidth; + UpdateProperty(propertyName); + break; + + case nameof(IImageContent.MaxHeight): + MaxHeight = model.MaxHeight <= 0 ? double.PositiveInfinity : model.MaxHeight; + UpdateProperty(propertyName); + break; + } + } + + protected override void UnsafeCleanup() + { + base.UnsafeCleanup(); + var model = Model.Unsafe; + if (model is not null) + { + model.PropChanged -= Model_PropChanged; + } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentPlainTextViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentPlainTextViewModel.cs new file mode 100644 index 0000000000..6de669ff21 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentPlainTextViewModel.cs @@ -0,0 +1,114 @@ +// 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.UI.ViewModels.Models; +using Microsoft.CommandPalette.Extensions; + +namespace Microsoft.CmdPal.UI.ViewModels; + +public partial class ContentPlainTextViewModel : ContentViewModel +{ + private ExtensionObject Model { get; } + + public string? Text { get; protected set; } + + public bool WordWrapEnabled { get; protected set; } + + public bool UseMonospace { get; protected set; } + + public ContentPlainTextViewModel(IPlainTextContent content, WeakReference context) + : base(context) + { + Model = new ExtensionObject(content); + } + + public override void InitializeProperties() + { + var model = Model.Unsafe; + if (model is null) + { + return; + } + + Text = model.Text; + WordWrapEnabled = model.WrapWords; + UseMonospace = model.FontFamily == CommandPalette.Extensions.FontFamily.Monospace; + UpdateProperty(nameof(Text), nameof(WordWrapEnabled), nameof(UseMonospace)); + model.PropChanged += Model_PropChanged; + } + + private void Model_PropChanged(object sender, IPropChangedEventArgs args) + { + try + { + var propName = args.PropertyName; + FetchProperty(propName); + } + catch (Exception ex) + { + ShowException(ex); + } + } + + private void FetchProperty(string propertyName) + { + var model = Model.Unsafe; + if (model is null) + { + return; // throw? + } + + switch (propertyName) + { + case nameof(IPlainTextContent.FontFamily): + // RPC: + var incomingUseMonospace = model.FontFamily == CommandPalette.Extensions.FontFamily.Monospace; + + // local: + if (incomingUseMonospace != UseMonospace) + { + UseMonospace = incomingUseMonospace; + UpdateProperty(nameof(UseMonospace)); + } + + break; + + case nameof(IPlainTextContent.WrapWords): + // RPC: + var incomingWrap = model.WrapWords; + + // local: + if (WordWrapEnabled != incomingWrap) + { + WordWrapEnabled = model.WrapWords; + UpdateProperty(nameof(WordWrapEnabled)); + } + + break; + + case nameof(IPlainTextContent.Text): + // RPC: + var incomingText = model.Text; + + // local: + if (incomingText != Text) + { + Text = incomingText; + UpdateProperty(nameof(Text)); + } + + break; + } + } + + protected override void UnsafeCleanup() + { + base.UnsafeCleanup(); + var model = Model.Unsafe; + if (model is not null) + { + model.PropChanged -= Model_PropChanged; + } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentTreeViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentTreeViewModel.cs index d3c271d949..e58f75a693 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentTreeViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentTreeViewModel.cs @@ -58,6 +58,8 @@ public partial class ContentTreeViewModel(ITreeContent _tree, WeakReference new ContentFormViewModel(form, context), IMarkdownContent markdown => new ContentMarkdownViewModel(markdown, context), ITreeContent tree => new ContentTreeViewModel(tree, context), + IPlainTextContent plainText => new ContentPlainTextViewModel(plainText, context), + IImageContent image => new ContentImageViewModel(image, context), _ => null, }; return viewModel; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs index 7ad32a2213..f341368c78 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs @@ -77,7 +77,7 @@ public partial class App : Application, IDisposable Services = ConfigureServices(appInfoService); - IconCacheProvider.Initialize(Services); + IconProvider.Initialize(Services); this.InitializeComponent(); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml index 62f18ef63a..c0e4d7514a 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml @@ -41,7 +41,7 @@ Margin="4,0,0,0" HorizontalAlignment="Left" SourceKey="{x:Bind Icon, Mode=OneWay}" - SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested20}" /> + SourceRequested="{x:Bind help:IconProvider.SourceRequested20}" /> + SourceRequested="{x:Bind help:IconProvider.SourceRequested20}" /> + SourceRequested="{x:Bind helpers:IconProvider.SourceRequested20}" /> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FiltersDropDown.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FiltersDropDown.xaml index fb9f0c6cd9..80ef412f4c 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FiltersDropDown.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FiltersDropDown.xaml @@ -35,7 +35,7 @@ HorizontalAlignment="Left" VerticalAlignment="Center" SourceKey="{x:Bind Icon}" - SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested20}" /> + SourceRequested="{x:Bind help:IconProvider.SourceRequested20}" /> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ImageViewer.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ImageViewer.xaml new file mode 100644 index 0000000000..d0b94a9143 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ImageViewer.xaml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ImageViewer.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ImageViewer.xaml.cs new file mode 100644 index 0000000000..d0127675b2 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ImageViewer.xaml.cs @@ -0,0 +1,335 @@ +// 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; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using Microsoft.UI.Xaml.Media; +using Windows.Foundation; +using Windows.System; + +namespace Microsoft.CmdPal.UI.Controls; + +public sealed partial class ImageViewer : UserControl +{ + public event EventHandler? CancelRequested; + + private const double MinScale = 0.25; + private const double MaxScale = 8.0; + private const double MinVisiblePadding = 24.0; + private const double KeyboardPanStep = 24.0; + + private Grid? _host; + + private Point _lastPanPoint; + private bool _isPanning; + private double _scale = 1.0; + + public ImageViewer() + { + InitializeComponent(); + + _host = Content as Grid; + + IsTabStop = true; + KeyDown += OnKeyDown; + + Loaded += OnLoaded; + Unloaded += OnUnloaded; + + PointerPressed += OnPointerPressed; + PointerMoved += OnPointerMoved; + PointerReleased += OnPointerReleased; + PointerWheelChanged += OnPointerWheelChanged; + DoubleTapped += OnDoubleTapped; + SizeChanged += OnSizeChanged; + } + + public void Initialize(object? sourceKey) + { + ZoomImage.SourceKey = sourceKey; + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + ResetView(); + CenterImage(); + } + + private void OnUnloaded(object sender, RoutedEventArgs e) + { + } + + private void OnSizeChanged(object sender, SizeChangedEventArgs e) + { + ClampTranslation(); + } + + private void ResetView() + { + _scale = 1.0; + ScaleTransform.ScaleX = _scale; + ScaleTransform.ScaleY = _scale; + TranslateTransform.X = 0.0; + TranslateTransform.Y = 0.0; + ClampTranslation(); + } + + private void CenterImage() + { + TranslateTransform.X = 0.0; + TranslateTransform.Y = 0.0; + ClampTranslation(); + } + + private void OnZoomInClick(object sender, RoutedEventArgs e) + { + ZoomRelative(1.1); + } + + private void OnZoomOutClick(object sender, RoutedEventArgs e) + { + ZoomRelative(0.9); + } + + private void OnZoomToFitClick(object sender, RoutedEventArgs e) + { + ResetView(); + CenterImage(); + } + + private void OnKeyDown(object sender, KeyRoutedEventArgs e) + { + switch (e.Key) + { + case VirtualKey.Add: + ZoomRelative(1.1); + e.Handled = true; + break; + case VirtualKey.Subtract: + ZoomRelative(0.9); + e.Handled = true; + break; + case VirtualKey.Number0: + case VirtualKey.NumberPad0: + ResetView(); + CenterImage(); + e.Handled = true; + break; + case VirtualKey.R: + CenterImage(); + e.Handled = true; + break; + case VirtualKey.Escape: + CancelRequested?.Invoke(this, EventArgs.Empty); + e.Handled = true; + break; + case VirtualKey.Left: + case VirtualKey.A: + TranslateTransform.X += KeyboardPanStep; + ClampTranslation(); + e.Handled = true; + break; + case VirtualKey.Right: + case VirtualKey.D: + TranslateTransform.X -= KeyboardPanStep; + ClampTranslation(); + e.Handled = true; + break; + case VirtualKey.Up: + case VirtualKey.W: + TranslateTransform.Y += KeyboardPanStep; + ClampTranslation(); + e.Handled = true; + break; + case VirtualKey.Down: + case VirtualKey.S: + TranslateTransform.Y -= KeyboardPanStep; + ClampTranslation(); + e.Handled = true; + break; + } + } + + /// + /// Zoom relative to viewport center (used by keyboard shortcuts and toolbar buttons). + /// + private void ZoomRelative(double factor) + { + var target = _scale * factor; + var center = new Point((_host?.ActualWidth ?? ActualWidth) / 2.0, (_host?.ActualHeight ?? ActualHeight) / 2.0); + SetScale(target, center); + } + + private void OnDoubleTapped(object sender, DoubleTappedRoutedEventArgs e) + { + if (_scale < 1.5) + { + SetScale(2.0, e.GetPosition(this)); + } + else + { + ResetView(); + CenterImage(); + } + } + + private void OnPointerWheelChanged(object sender, PointerRoutedEventArgs e) + { + var point = e.GetCurrentPoint(this); + var delta = point.Properties.MouseWheelDelta; + if (delta == 0) + { + return; + } + + var zoomIn = delta > 0; + var factor = zoomIn ? 1.1 : 0.9; + var newScale = _scale * factor; + SetScale(newScale, point.Position); + e.Handled = true; + } + + /// + /// Applies zoom so the image point under stays fixed. + /// + /// + /// The image element uses RenderTransformOrigin="0.5,0.5" and is centered + /// in the viewport via layout alignment. This means the effective transform origin + /// in viewport coordinates is the viewport center. + /// + /// The full mapping from image-local to viewport space is: + /// screen = viewportCenter + (imgLocal - imgCenter) × scale + translate + /// + /// Because the image is layout-centered, the viewport center acts as the origin + /// for the transform group, giving us: + /// screen = origin + relativeOffset × scale + translate + /// + private void SetScale(double targetScale, Point pivot) + { + targetScale = Math.Clamp(targetScale, MinScale, MaxScale); + + var prevScale = _scale; + if (targetScale == prevScale) + { + return; + } + + // The effective transform origin is the viewport center + // (RenderTransformOrigin="0.5,0.5" + centered layout). + var vw = _host?.ActualWidth ?? ActualWidth; + var vh = _host?.ActualHeight ?? ActualHeight; + var originX = vw / 2.0; + var originY = vh / 2.0; + + // Convert pivot to image-relative-to-origin space using the old transform: + // pivot = origin + rel × oldScale + oldTranslate + // rel = (pivot - origin - oldTranslate) / oldScale + var relX = (pivot.X - originX - TranslateTransform.X) / prevScale; + var relY = (pivot.Y - originY - TranslateTransform.Y) / prevScale; + + _scale = targetScale; + ScaleTransform.ScaleX = _scale; + ScaleTransform.ScaleY = _scale; + + // Solve for new translate so the same image point stays under the pivot: + // pivot = origin + rel × newScale + newTranslate + // newTranslate = pivot - origin - rel × newScale + TranslateTransform.X = pivot.X - originX - (relX * _scale); + TranslateTransform.Y = pivot.Y - originY - (relY * _scale); + ClampTranslation(); + } + + private void OnPointerPressed(object sender, PointerRoutedEventArgs e) + { + var point = e.GetCurrentPoint(this); + if (point.Properties.IsLeftButtonPressed) + { + _isPanning = true; + _lastPanPoint = point.Position; + CapturePointer(e.Pointer); + } + } + + private void OnPointerMoved(object sender, PointerRoutedEventArgs e) + { + if (!_isPanning) + { + return; + } + + var point = e.GetCurrentPoint(this); + var pos = point.Position; + var dx = pos.X - _lastPanPoint.X; + var dy = pos.Y - _lastPanPoint.Y; + _lastPanPoint = pos; + + TranslateTransform.X += dx; + TranslateTransform.Y += dy; + ClampTranslation(); + } + + private void OnPointerReleased(object sender, PointerRoutedEventArgs e) + { + if (_isPanning) + { + _isPanning = false; + ReleasePointerCapture(e.Pointer); + } + } + + private void ClampTranslation() + { + var iw = ZoomImage.ActualWidth * ScaleTransform.ScaleX; + var ih = ZoomImage.ActualHeight * ScaleTransform.ScaleY; + var vw = _host?.ActualWidth ?? ActualWidth; + var vh = _host?.ActualHeight ?? ActualHeight; + if (iw <= 0 || ih <= 0 || vw <= 0 || vh <= 0) + { + return; + } + + double maxOffsetX; + double maxOffsetY; + if (iw <= vw) + { + maxOffsetX = 0; + TranslateTransform.X = 0; + } + else + { + maxOffsetX = Math.Max(0, ((iw - vw) / 2) + MinVisiblePadding); + } + + if (ih <= vh) + { + maxOffsetY = 0; + TranslateTransform.Y = 0; + } + else + { + maxOffsetY = Math.Max(0, ((ih - vh) / 2) + MinVisiblePadding); + } + + if (TranslateTransform.X > maxOffsetX) + { + TranslateTransform.X = maxOffsetX; + } + + if (TranslateTransform.X < -maxOffsetX) + { + TranslateTransform.X = -maxOffsetX; + } + + if (TranslateTransform.Y > maxOffsetY) + { + TranslateTransform.Y = maxOffsetY; + } + + if (TranslateTransform.Y < -maxOffsetY) + { + TranslateTransform.Y = -maxOffsetY; + } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/Tag.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/Tag.xaml.cs index 79180d1a45..45b2f7d704 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/Tag.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/Tag.xaml.cs @@ -72,7 +72,7 @@ public partial class Tag : Control if (GetTemplateChild(TagIconBox) is IconBox iconBox) { - iconBox.SourceRequested += IconCacheProvider.SourceRequested20; + iconBox.SourceRequested += IconProvider.SourceRequested20; iconBox.Visibility = HasIcon ? Visibility.Visible : Visibility.Collapsed; } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ContentTemplateSelector.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ContentTemplateSelector.cs index ddcf4e0de5..001da0c066 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ContentTemplateSelector.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ContentTemplateSelector.cs @@ -18,6 +18,10 @@ public partial class ContentTemplateSelector : DataTemplateSelector public DataTemplate? TreeTemplate { get; set; } + public DataTemplate? PlainTextTemplate { get; set; } + + public DataTemplate? ImageTemplate { get; set; } + protected override DataTemplate? SelectTemplateCore(object item) { return item is ContentViewModel element @@ -26,6 +30,8 @@ public partial class ContentTemplateSelector : DataTemplateSelector ContentFormViewModel => FormTemplate, ContentMarkdownViewModel => MarkdownTemplate, ContentTreeViewModel => TreeTemplate, + ContentImageViewModel => ImageTemplate, + ContentPlainTextViewModel => PlainTextTemplate, _ => null, } : null; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Dock/DockControl.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Dock/DockControl.xaml index fd7a05d0bf..4930ff6a99 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Dock/DockControl.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Dock/DockControl.xaml @@ -53,7 +53,7 @@ AutomationProperties.AccessibilityView="Raw" Foreground="{ThemeResource TextFillColorSecondaryBrush}" SourceKey="{x:Bind Icon, Mode=OneWay}" - SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested16}" /> + SourceRequested="{x:Bind help:IconProvider.SourceRequested16}" /> @@ -168,7 +168,7 @@ VerticalAlignment="Center" Foreground="{ThemeResource TextFillColorSecondaryBrush}" SourceKey="{x:Bind IconViewModel, Mode=OneWay}" - SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested20}" /> + SourceRequested="{x:Bind help:IconProvider.SourceRequested20}" /> + @@ -62,6 +69,21 @@ + + + + + + + + + + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/Controls/ImageContentViewer.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/Controls/ImageContentViewer.xaml new file mode 100644 index 0000000000..4ea72dec6b --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/Controls/ImageContentViewer.xaml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/Controls/ImageContentViewer.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/Controls/ImageContentViewer.xaml.cs new file mode 100644 index 0000000000..da92966a5f --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/Controls/ImageContentViewer.xaml.cs @@ -0,0 +1,416 @@ +// 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 System.Runtime.InteropServices.WindowsRuntime; +using CommunityToolkit.Mvvm.Messaging; +using Microsoft.CmdPal.UI.Controls; +using Microsoft.CmdPal.UI.Helpers; +using Microsoft.CmdPal.UI.ViewModels; +using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; +using Microsoft.UI.Xaml.Input; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; +using Windows.ApplicationModel.DataTransfer; +using Windows.Foundation; +using Windows.Graphics.Imaging; +using Windows.Storage.Streams; +using Windows.System; +using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue; +using DispatcherQueueTimer = Microsoft.UI.Dispatching.DispatcherQueueTimer; +using RS_ = Microsoft.CmdPal.UI.Helpers.ResourceLoaderInstance; + +namespace Microsoft.CmdPal.UI.ExtViews.Controls; + +public sealed partial class ImageContentViewer : UserControl +{ + private const double MaxHeightSafetyPadding = 12 + 20 + 20; // a few pixels to be safe + + public static readonly DependencyProperty UniformFitEnabledProperty = DependencyProperty.Register( + nameof(UniformFitEnabled), typeof(bool), typeof(ImageContentViewer), new PropertyMetadata(true)); + + private DispatcherQueueTimer? _resizeDebounceTimer; + private Microsoft.UI.Xaml.Controls.Page? _parentPage; + + public bool UniformFitEnabled + { + get => (bool)GetValue(UniformFitEnabledProperty); + set => SetValue(UniformFitEnabledProperty, value); + } + + public ContentImageViewModel? ViewModel + { + get => (ContentImageViewModel?)DataContext; + set => DataContext = value; + } + + public ImageContentViewer() + { + InitializeComponent(); + Loaded += ImageContentViewer_Loaded; + Unloaded += ImageContentViewer_Unloaded; + } + + private void ImageContentViewer_Loaded(object sender, RoutedEventArgs e) + { + ApplyImage(); + ApplyMaxDimensions(); + if (ViewModel is not null) + { + ViewModel.PropertyChanged += ViewModel_PropertyChanged; + } + + // Debounce timer for resize + var dq = DispatcherQueue.GetForCurrentThread(); + _resizeDebounceTimer = dq.CreateTimer(); + _resizeDebounceTimer.Interval = TimeSpan.FromMilliseconds(120); + _resizeDebounceTimer.IsRepeating = false; + _resizeDebounceTimer.Tick += ResizeDebounceTimer_Tick; + + // Hook to parent Page size changes to keep MaxHeight in sync + _parentPage = FindParentPage(); + if (_parentPage is not null) + { + _parentPage.SizeChanged += ParentPage_SizeChanged; + UpdateBorderMaxHeight(); + } + else + { + // Fallback to this control's ActualHeight + UpdateBorderMaxHeight(useSelf: true); + } + + // Initial overlay layout + LayoutOverlayButton(); + } + + private void ImageContentViewer_Unloaded(object sender, RoutedEventArgs e) + { + if (ViewModel is not null) + { + ViewModel.PropertyChanged -= ViewModel_PropertyChanged; + } + + if (_resizeDebounceTimer is not null) + { + _resizeDebounceTimer.Tick -= ResizeDebounceTimer_Tick; + } + + if (_parentPage is not null) + { + _parentPage.SizeChanged -= ParentPage_SizeChanged; + _parentPage = null; + } + } + + private void ParentPage_SizeChanged(object sender, SizeChangedEventArgs e) + { + // Debounce updates a bit to avoid frequent layout passes + _resizeDebounceTimer?.Start(); + LayoutOverlayButton(); + } + + private void ResizeDebounceTimer_Tick(DispatcherQueueTimer sender, object args) + { + UpdateBorderMaxHeight(); + LayoutOverlayButton(); + } + + private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + var name = e.PropertyName; + if (name == nameof(ContentImageViewModel.Image)) + { + ApplyImage(); + LayoutOverlayButton(); + } + else if (name is nameof(ContentImageViewModel.MaxWidth) or nameof(ContentImageViewModel.MaxHeight)) + { + ApplyMaxDimensions(); + UpdateBorderMaxHeight(); + LayoutOverlayButton(); + } + } + + private void ApplyImage() + { + Image.SourceKey = ViewModel?.Image; + } + + private void ApplyMaxDimensions() + { + // Apply optional max dimensions from the view model to the content container. + if (ViewModel is null) + { + return; + } + + ImageBorder.MaxWidth = ViewModel.MaxWidth; + } + + private async void CopyImage_Click(object sender, RoutedEventArgs e) + { + if (this.Image.Source is FontIconSource fontIconSource) + { + ClipboardHelper.SetText(fontIconSource.Glyph); + SendCopiedImageToast(); + return; + } + + try + { + var renderTarget = new RenderTargetBitmap(); + await renderTarget.RenderAsync(this.Image); + + var pixelBuffer = await renderTarget.GetPixelsAsync(); + var pixels = pixelBuffer.ToArray(); + + var stream = new InMemoryRandomAccessStream(); + var encoder = await BitmapEncoder.CreateAsync(BitmapEncoder.PngEncoderId, stream); + encoder.SetPixelData( + BitmapPixelFormat.Bgra8, + BitmapAlphaMode.Premultiplied, + (uint)renderTarget.PixelWidth, + (uint)renderTarget.PixelHeight, + 96, + 96, + pixels); + await encoder.FlushAsync(); + + var dataPackage = new DataPackage(); + dataPackage.SetBitmap(RandomAccessStreamReference.CreateFromStream(stream)); + Clipboard.SetContent(dataPackage); + SendCopiedImageToast(); + } + catch + { + CopyImageUri_Click(sender, e); + } + } + + private void CopyImageUri_Click(object sender, RoutedEventArgs e) + { + var iconVm = ViewModel?.Image; + var lightTheme = ActualTheme == ElementTheme.Light; + var data = lightTheme ? iconVm?.Light : iconVm?.Dark; + var srcKey = data?.Icon ?? string.Empty; + if (Uri.TryCreate(srcKey, UriKind.Absolute, out var uri) && + uri.Scheme is "http" or "https") + { + ClipboardHelper.SetText(srcKey); + WeakReferenceMessenger.Default.Send(new ShowToastMessage(RS_.GetString("ImageContentViewer_Toast_CopiedLink"))); + } + } + + private static void SendCopiedImageToast() + { + WeakReferenceMessenger.Default.Send(new ShowToastMessage(RS_.GetString("ImageContentViewer_Toast_CopiedImage"))); + } + + private void OpenZoomOverlay_Click(object sender, RoutedEventArgs e) + { + // Full-window overlay using a Popup attached to this control's XamlRoot + if (XamlRoot is null) + { + return; + } + + var popup = new Popup + { + IsLightDismissEnabled = false, + XamlRoot = XamlRoot, + }; + + var overlay = new Grid + { + Width = XamlRoot.Size.Width, + Height = XamlRoot.Size.Height, + Background = new AcrylicBrush + { + TintColor = Microsoft.UI.Colors.Black, + TintOpacity = 0.7, + TintLuminosityOpacity = 0.5, + FallbackColor = Microsoft.UI.Colors.Gray, + AlwaysUseFallback = false, + }, + TabFocusNavigation = KeyboardNavigationMode.Local, + }; + + // Close popup on Esc pressed at overlay level + overlay.KeyDown += (s, args) => + { + if (args.Key == VirtualKey.Escape) + { + popup.IsOpen = false; + args.Handled = true; + } + }; + + var closeBtn = new Button + { + Style = (Style)Application.Current.Resources["SubtleButtonStyle"], + Content = new SymbolIcon { Symbol = Symbol.Cancel }, + HorizontalAlignment = HorizontalAlignment.Right, + VerticalAlignment = VerticalAlignment.Top, + Margin = new Thickness(12), + }; + + closeBtn.Click += (_, __) => popup.IsOpen = false; + + // Zoom/pan viewer using current icon + var viewer = new ImageViewer(); + viewer.Initialize(Image.SourceKey); + viewer.HorizontalAlignment = HorizontalAlignment.Stretch; + viewer.VerticalAlignment = VerticalAlignment.Stretch; + + // Also close when viewer requests cancellation (e.g., Escape from viewer) + viewer.CancelRequested += (_, __) => + { + popup.IsOpen = false; + + // Move focus back; otherwise it might go to place where sun doesn't shine in after closing the popup + this.Focus(FocusState.Programmatic); + }; + + overlay.Children.Add(viewer); + overlay.Children.Add(closeBtn); + + popup.Child = overlay; + + TypedEventHandler? onRootChanged = (_, _) => + { + overlay.Width = popup.XamlRoot.Size.Width; + overlay.Height = popup.XamlRoot.Size.Height; + }; + + popup.XamlRoot.Changed += onRootChanged; + + popup.Closed += (_, __) => + { + popup.XamlRoot.Changed -= onRootChanged; + popup.Child = null; + }; + + popup.IsOpen = true; + overlay.Focus(FocusState.Programmatic); + } + + private void OpenZoomOverlayButton_Loaded(object sender, RoutedEventArgs e) + { + LayoutOverlayButton(); + } + + private void OpenZoomOverlayButton_SizeChanged(object sender, SizeChangedEventArgs e) + { + LayoutOverlayButton(); + } + + private void FitViewbox_SizeChanged(object sender, SizeChangedEventArgs e) + { + LayoutOverlayButton(); + } + + private Microsoft.UI.Xaml.Controls.Page? FindParentPage() + { + DependencyObject? current = this; + while (current is not null) + { + current = VisualTreeHelper.GetParent(current); + if (current is Microsoft.UI.Xaml.Controls.Page page) + { + return page; + } + } + + return null; + } + + private void UpdateBorderMaxHeight(bool useSelf = false) + { + var height = useSelf ? ActualHeight : (_parentPage?.ActualHeight ?? ActualHeight); + if (height > 0) + { + var pageLimit = Math.Max(0, height - MaxHeightSafetyPadding); + if (ViewModel?.MaxHeight is double vmHeight and > 0) + { + ImageBorder.MaxHeight = Math.Min(pageLimit, vmHeight); + } + else + { + ImageBorder.MaxHeight = pageLimit; + } + } + else if (ViewModel?.MaxHeight is double vmHeight2 and > 0) + { + ImageBorder.MaxHeight = vmHeight2; // fallback if page height not ready + } + } + + private void LayoutOverlayButton() + { + if (OpenZoomOverlayButton is null || FitViewbox is null || OverlayCanvas is null || Image is null) + { + return; + } + + // If layout isn't ready, skip + if (FitViewbox.ActualWidth <= 0 || FitViewbox.ActualHeight <= 0 || OverlayCanvas.ActualWidth <= 0 || OverlayCanvas.ActualHeight <= 0) + { + return; + } + + // Compute the transformed bounds of the image content relative to the overlay canvas. + // This accounts for Viewbox scaling/clipping due to MaxHeight constraints and custom max dimensions. + try + { + var gt = Image.TransformToVisual(OverlayCanvas); + var rect = gt.TransformBounds(new Rect(0, 0, Image.ActualWidth, Image.ActualHeight)); + + const double margin = 8.0; + var buttonWidth = OpenZoomOverlayButton.ActualWidth; + + var x = rect.Right - buttonWidth - margin; + var y = rect.Top + margin; + + // Clamp inside overlay bounds just in case + if (x < margin) + { + x = margin; + } + + if (y < margin) + { + y = margin; + } + + if (x > OverlayCanvas.ActualWidth - buttonWidth - margin) + { + x = OverlayCanvas.ActualWidth - buttonWidth - margin; + } + + if (y > OverlayCanvas.ActualHeight - OpenZoomOverlayButton.ActualHeight - margin) + { + y = OverlayCanvas.ActualHeight - OpenZoomOverlayButton.ActualHeight - margin; + } + + Canvas.SetLeft(OpenZoomOverlayButton, x); + Canvas.SetTop(OpenZoomOverlayButton, y); + } + catch + { + // Fallback: keep it at top-right of the viewbox area + const double margin = 8.0; + var buttonWidth = OpenZoomOverlayButton.ActualWidth; + var x = FitViewbox.ActualWidth - buttonWidth - margin; + var y = margin; + Canvas.SetLeft(OpenZoomOverlayButton, x); + Canvas.SetTop(OpenZoomOverlayButton, y); + } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/Controls/PlainTextContentViewer.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/Controls/PlainTextContentViewer.xaml new file mode 100644 index 0000000000..61749d419d --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/Controls/PlainTextContentViewer.xaml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/Controls/PlainTextContentViewer.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/Controls/PlainTextContentViewer.xaml.cs new file mode 100644 index 0000000000..949e29f97a --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/Controls/PlainTextContentViewer.xaml.cs @@ -0,0 +1,216 @@ +// 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.CommandPalette.Extensions.Toolkit; +using Microsoft.UI.Input; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using Microsoft.UI.Xaml.Media; +using Windows.System; +using Windows.UI.Core; + +namespace Microsoft.CmdPal.UI.ExtViews.Controls; + +public sealed partial class PlainTextContentViewer : UserControl +{ + private const double MinFontSize = 8.0; + private const double MaxFontSize = 72.0; + private const double FontSizeStep = 2.0; + + private double _defaultFontSize; + private double _fontSize; + + public static readonly DependencyProperty WordWrapEnabledProperty = DependencyProperty.Register( + nameof(WordWrapEnabled), typeof(bool), typeof(PlainTextContentViewer), new PropertyMetadata(false, OnWrapChanged)); + + public static readonly DependencyProperty UseMonospaceProperty = DependencyProperty.Register( + nameof(UseMonospace), typeof(bool), typeof(PlainTextContentViewer), new PropertyMetadata(false, OnFontChanged)); + + public static readonly DependencyProperty TextProperty = DependencyProperty.Register( + nameof(Text), typeof(string), typeof(PlainTextContentViewer), new PropertyMetadata(default(string), OnTextChanged)); + + public bool WordWrapEnabled + { + get => (bool)GetValue(WordWrapEnabledProperty); + set => SetValue(WordWrapEnabledProperty, value); + } + + public bool UseMonospace + { + get => (bool)GetValue(UseMonospaceProperty); + set => SetValue(UseMonospaceProperty, value); + } + + public string Text + { + get { return (string)GetValue(TextProperty); } + set { SetValue(TextProperty, value); } + } + + public PlainTextContentViewer() + { + InitializeComponent(); + UpdateFont(); + + _defaultFontSize = ContentTextBlock.FontSize; + _fontSize = _defaultFontSize; + + IsTabStop = true; + KeyDown += OnKeyDown; + } + + private static void OnTextChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) => (d as PlainTextContentViewer)?.UpdateText(); + + private static void OnFontChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) => (d as PlainTextContentViewer)?.UpdateFont(); + + private static void OnWrapChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) => (d as PlainTextContentViewer)?.UpdateWordWrap(); + + private void UpdateText() + { + if (ContentTextBlock is null) + { + return; + } + + ContentTextBlock.Text = Text; + } + + private void UpdateWordWrap() + { + ContentTextBlock.TextWrapping = WordWrapEnabled ? TextWrapping.Wrap : TextWrapping.NoWrap; + Scroller.HorizontalScrollBarVisibility = WordWrapEnabled ? ScrollBarVisibility.Disabled : ScrollBarVisibility.Auto; + InvalidateLayout(); + } + + private void UpdateFont() + { + if (ContentTextBlock is null) + { + return; + } + + try + { + ContentTextBlock.FontFamily = UseMonospace ? new FontFamily("Cascadia Mono, Consolas") : FontFamily.XamlAutoFontFamily; + } + catch + { + ContentTextBlock.FontFamily = FontFamily.XamlAutoFontFamily; + } + + InvalidateLayout(); + } + + private void CopySelection_Click(object sender, RoutedEventArgs e) + { + var txt = string.IsNullOrEmpty(ContentTextBlock?.SelectedText) ? ContentTextBlock?.Text : ContentTextBlock?.SelectedText; + if (!string.IsNullOrEmpty(txt)) + { + ClipboardHelper.SetText(txt); + } + } + + private void SelectAll_Click(object sender, RoutedEventArgs e) + { + ContentTextBlock.SelectAll(); + } + + private void ZoomIn() + { + _fontSize = Math.Min(_fontSize + FontSizeStep, MaxFontSize); + ApplyFontSize(); + } + + private void ZoomOut() + { + _fontSize = Math.Max(_fontSize - FontSizeStep, MinFontSize); + ApplyFontSize(); + } + + private void ResetZoom() + { + _fontSize = _defaultFontSize; + ApplyFontSize(); + } + + private void ApplyFontSize() + { + ContentTextBlock.FontSize = _fontSize; + InvalidateLayout(); + } + + /// + /// Changing font properties on a TextBlock inside a ScrollViewer can leave + /// stale layout state, causing the text to disappear until the next + /// interaction. Re-setting the Text property forces the TextBlock to + /// discard its cached layout and fully re-render. + /// + private void InvalidateLayout() + { + var text = ContentTextBlock.Text; + ContentTextBlock.Text = string.Empty; + ContentTextBlock.Text = text; + } + + private void ZoomIn_Click(object sender, RoutedEventArgs e) => ZoomIn(); + + private void ZoomOut_Click(object sender, RoutedEventArgs e) => ZoomOut(); + + private void ResetZoom_Click(object sender, RoutedEventArgs e) => ResetZoom(); + + private static bool IsCtrlDown() + { + var state = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control); + return (state & CoreVirtualKeyStates.Down) == CoreVirtualKeyStates.Down; + } + + private void OnKeyDown(object sender, KeyRoutedEventArgs e) + { + if (!IsCtrlDown()) + { + return; + } + + switch (e.Key) + { + case VirtualKey.Add: + case (VirtualKey)187: // =/+ key + ZoomIn(); + e.Handled = true; + break; + case VirtualKey.Subtract: + case (VirtualKey)189: // -/_ key + ZoomOut(); + e.Handled = true; + break; + case VirtualKey.Number0: + case VirtualKey.NumberPad0: + ResetZoom(); + e.Handled = true; + break; + } + } + + private void ContentTextBlock_PointerWheelChanged(object sender, PointerRoutedEventArgs e) + { + if (!IsCtrlDown()) + { + return; + } + + var point = e.GetCurrentPoint(ContentTextBlock); + var delta = point.Properties.MouseWheelDelta; + if (delta > 0) + { + ZoomIn(); + } + else if (delta < 0) + { + ZoomOut(); + } + + e.Handled = true; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml index 09c13a20aa..39aaed512e 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml @@ -365,7 +365,7 @@ AutomationProperties.AccessibilityView="Raw" Foreground="{ThemeResource TextFillColorSecondaryBrush}" SourceKey="{x:Bind Icon, Mode=OneWay}" - SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested20}" /> + SourceRequested="{x:Bind help:IconProvider.SourceRequested20}" /> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml index ddf98882b2..6d2ecee17a 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml @@ -92,7 +92,7 @@ Height="16" Margin="0,3,8,0" SourceKey="{x:Bind Icon, Mode=OneWay}" - SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested20}" /> + SourceRequested="{x:Bind help:IconProvider.SourceRequested20}" /> @@ -301,7 +301,7 @@ ui:VisualExtensions.NormalizedCenterPoint="0.5,0.5" Foreground="{ThemeResource TextFillColorSecondaryBrush}" SourceKey="{x:Bind ViewModel.CurrentPage.Icon, Mode=OneWay}" - SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested20}" + SourceRequested="{x:Bind help:IconProvider.SourceRequested20}" Visibility="{x:Bind ViewModel.CurrentPage.IsRootPage, Mode=OneWay, Converter={StaticResource BoolToInvertedVisibilityConverter}}"> + SourceRequested="{x:Bind helpers:IconProvider.SourceRequested20}" /> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionPage.xaml index 4643819e78..a109b743cd 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionPage.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionPage.xaml @@ -61,7 +61,7 @@ Height="20" AutomationProperties.AccessibilityView="Raw" SourceKey="{x:Bind ViewModel.Icon, Mode=OneWay}" - SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested20}" /> + SourceRequested="{x:Bind helpers:IconProvider.SourceRequested20}" /> @@ -95,7 +95,7 @@ Height="20" AutomationProperties.AccessibilityView="Raw" SourceKey="{x:Bind Icon, Mode=OneWay}" - SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested20}" /> + SourceRequested="{x:Bind helpers:IconProvider.SourceRequested20}" /> @@ -167,7 +167,7 @@ Height="20" AutomationProperties.AccessibilityView="Raw" SourceKey="{x:Bind Icon, Mode=OneWay}" - SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested20}" /> + SourceRequested="{x:Bind helpers:IconProvider.SourceRequested20}" /> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml index e717133887..b471b67d5d 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml @@ -241,7 +241,7 @@ Height="20" AutomationProperties.AccessibilityView="Raw" SourceKey="{x:Bind Icon, Mode=OneWay}" - SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested20}" /> + SourceRequested="{x:Bind helpers:IconProvider.SourceRequested20}" /> - @@ -958,13 +958,13 @@ Right-click to remove the key combination, thereby deactivating the shortcut. All available bands are already pinned. - + To pin commands, extensions or apps, use the Pin to Dock command in Command Palette. - + Bands - - + + Add band to start @@ -987,7 +987,79 @@ Right-click to remove the key combination, thereby deactivating the shortcut. NEW - Must be all caps + Must be all caps + + + Zoom & Scroll + + + Open image viewer + + + Copy image + + + Zoom in + + + Zoom out + + + Zoom to fit + + + Copy + + + Select all + + + Word wrap + + + Monospace font + + + Zoom in + + + Zoom out + + + Reset zoom + + + Copied image to clipboard + + + Copied link to clipboard + + + Zoom in + + + Zoom in (Ctrl+Plus key) + + + Zoom out + + + Zoom out (Ctrl+Minus key) + + + Zoom to fit + + + Zoom to fit (Ctrl+0) + + + Open image viewer + + + Open image viewer + + + Copy image link Pin to Dock diff --git a/src/modules/cmdpal/Microsoft.Terminal.UI/IconPathConverter.cpp b/src/modules/cmdpal/Microsoft.Terminal.UI/IconPathConverter.cpp index a1dee068e8..ab0683ebb8 100644 --- a/src/modules/cmdpal/Microsoft.Terminal.UI/IconPathConverter.cpp +++ b/src/modules/cmdpal/Microsoft.Terminal.UI/IconPathConverter.cpp @@ -2,7 +2,7 @@ #include "IconPathConverter.h" #include "IconPathConverter.g.cpp" - #include "FontIconGlyphClassifier.h" +#include "FontIconGlyphClassifier.h" #include #include @@ -115,10 +115,13 @@ namespace winrt::Microsoft::Terminal::UI::implementation { typename ImageIconSource::type iconSource; winrt::Microsoft::UI::Xaml::Media::Imaging::SvgImageSource source{ iconUri }; - source.RasterizePixelWidth(static_cast(targetSize)); - // Set only single dimension here; the image might not be square and - // this will preserve the aspect ratio (for the price of keeping height unbound). - // source.RasterizePixelHeight(static_cast(targetSize)); + if (targetSize > 0) + { + source.RasterizePixelWidth(static_cast(targetSize)); + // Set only single dimension here; the image might not be square and + // this will preserve the aspect ratio (for the price of keeping height unbound). + // source.RasterizePixelHeight(static_cast(targetSize)); + } iconSource.ImageSource(source); return iconSource; } @@ -126,10 +129,13 @@ namespace winrt::Microsoft::Terminal::UI::implementation { typename ImageIconSource::type iconSource; winrt::Microsoft::UI::Xaml::Media::Imaging::BitmapImage bitmapImage; - bitmapImage.DecodePixelWidth(targetSize); - // Set only single dimension here; the image might not be square and - // this will preserve the aspect ratio (for the price of keeping height unbound). - // bitmapImage.DecodePixelHeight(targetSize); + if (targetSize > 0) + { + bitmapImage.DecodePixelWidth(targetSize); + // Set only single dimension here; the image might not be square and + // this will preserve the aspect ratio (for the price of keeping height unbound). + // bitmapImage.DecodePixelHeight(targetSize); + } bitmapImage.UriSource(iconUri); iconSource.ImageSource(bitmapImage); return iconSource; @@ -210,7 +216,7 @@ namespace winrt::Microsoft::Terminal::UI::implementation typename FontIconSource::type icon; icon.FontFamily(winrt::Microsoft::UI::Xaml::Media::FontFamily{ family }); - icon.FontSize(targetSize); + icon.FontSize(targetSize > 0 ? targetSize : 8); icon.Glyph(glyph_kind == FontIconGlyphKind::Invalid ? L"\u25CC" : iconPath); iconSource = icon; } @@ -349,7 +355,7 @@ namespace winrt::Microsoft::Terminal::UI::implementation // * C:\Program Files\PowerShell\6-preview\pwsh.exe, 0 (this doesn't exist for me) // * C:\Program Files\PowerShell\7\pwsh.exe, 0 - const auto swBitmap{ _getBitmapFromIconFileAsync(winrt::hstring{ iconPathWithoutIndex }, index, targetSize) }; + const auto swBitmap{ _getBitmapFromIconFileAsync(winrt::hstring{ iconPathWithoutIndex }, index, targetSize >= 0 ? targetSize : 256) }; if (swBitmap == nullptr) { return nullptr; @@ -379,7 +385,8 @@ namespace winrt::Microsoft::Terminal::UI::implementation return imageIconSource; } - Microsoft::UI::Xaml::Controls::IconElement IconPathConverter::IconMUX(const winrt::hstring& iconPath) { + Microsoft::UI::Xaml::Controls::IconElement IconPathConverter::IconMUX(const winrt::hstring& iconPath) + { return IconMUX(iconPath, 24); } diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Assets/Images/win-11-bloom-6k.jpg b/src/modules/cmdpal/ext/SamplePagesExtension/Assets/Images/win-11-bloom-6k.jpg new file mode 100644 index 0000000000..8db91b07d0 Binary files /dev/null and b/src/modules/cmdpal/ext/SamplePagesExtension/Assets/Images/win-11-bloom-6k.jpg differ diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleContentPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleContentPage.cs index 0584d96ee6..915e39e52e 100644 --- a/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleContentPage.cs +++ b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleContentPage.cs @@ -39,6 +39,62 @@ internal sealed partial class SampleContentPage : ContentPage } } +[SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Sample code")] +internal sealed partial class SamplePlainTextContentPage : ContentPage +{ + private readonly PlainTextContent _samplePlainText = new() + { + Text = """ + # Sample Plain Text Content + This is a sample plain text content page. + + You can right-click the content and switch wrap mode on or off, or change the font. + + Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum. + """, + }; + + private readonly PlainTextContent _samplePlainText2 = new() + { + Text = """ + # Sample Plain Text Content + This is a sample plain text content page. This one is monospace and wraps by default. + + You can right-click the content and switch wrap mode on or off, or change the font. + + Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum. + """, + FontFamily = FontFamily.Monospace, + WrapWords = true, + }; + + public override IContent[] GetContent() => [_samplePlainText, _samplePlainText2]; + + public SamplePlainTextContentPage() + { + Name = "Plain Text"; + Title = "Sample Plain Text Content"; + Icon = new IconInfo("\uE8D2"); // Text Document + } +} + +[SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Sample code")] +internal sealed partial class SampleImageContentPage : ContentPage +{ + private readonly ImageContent _sampleImage = new(IconHelpers.FromRelativePath("Assets/Images/win-11-bloom-6k.jpg")); + private readonly ImageContent _sampleImage2 = new(IconHelpers.FromRelativePath("Assets/Images/win-11-bloom-6k.jpg")) { MaxWidth = 200, MaxHeight = 200 }; + private readonly ImageContent _sampleImage3 = new(IconHelpers.FromRelativePath("Assets/Images/FluentEmojiChipmunk.svg")) { MaxWidth = 200, MaxHeight = 200 }; + + public override IContent[] GetContent() => [_sampleImage, _sampleImage2, _sampleImage3]; + + public SampleImageContentPage() + { + Name = "Image"; + Title = "Sample Image Content"; + Icon = new IconInfo("\uE722"); // Picture + } +} + [SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Sample code")] internal sealed partial class SampleContentForm : FormContent { diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/SamplesListPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/SamplesListPage.cs index 3dd67086c8..9ed73efe26 100644 --- a/src/modules/cmdpal/ext/SamplePagesExtension/SamplesListPage.cs +++ b/src/modules/cmdpal/ext/SamplePagesExtension/SamplesListPage.cs @@ -66,6 +66,16 @@ public partial class SamplesListPage : ListPage Title = "Sample content page", Subtitle = "Display mixed forms, markdown, and other types of content", }, + new ListItem(new SamplePlainTextContentPage()) + { + Title = "Sample plain text content page", + Subtitle = "Display a page of plain text content", + }, + new ListItem(new SampleImageContentPage()) + { + Title = "Sample image content page", + Subtitle = "Display a page with an image", + }, new ListItem(new SampleTreeContentPage()) { Title = "Sample nested content", diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ImageContent.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ImageContent.cs new file mode 100644 index 0000000000..27138300e1 --- /dev/null +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ImageContent.cs @@ -0,0 +1,25 @@ +// 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.CommandPalette.Extensions.Toolkit; + +public partial class ImageContent : BaseObservable, IImageContent +{ + public const int Unlimited = -1; + + public IIconInfo? Image { get; set => SetProperty(ref field, value); } + + public int MaxHeight { get; set => SetProperty(ref field, value); } = Unlimited; + + public int MaxWidth { get; set => SetProperty(ref field, value); } = Unlimited; + + public ImageContent() + { + } + + public ImageContent(IIconInfo? image) + { + Image = image; + } +} diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/PlainTextContent.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/PlainTextContent.cs new file mode 100644 index 0000000000..d5b5d56761 --- /dev/null +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/PlainTextContent.cs @@ -0,0 +1,23 @@ +// 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.CommandPalette.Extensions.Toolkit; + +public partial class PlainTextContent : BaseObservable, IPlainTextContent +{ + public FontFamily FontFamily { get; set => SetProperty(ref field, value); } + + public bool WrapWords { get; set => SetProperty(ref field, value); } + + public string? Text { get; set => SetProperty(ref field, value); } + + public PlainTextContent() + { + } + + public PlainTextContent(string? text) + { + Text = text; + } +} diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/Microsoft.CommandPalette.Extensions.idl b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/Microsoft.CommandPalette.Extensions.idl index 7836a3aac3..1eaa6640bc 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/Microsoft.CommandPalette.Extensions.idl +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/Microsoft.CommandPalette.Extensions.idl @@ -348,6 +348,26 @@ namespace Microsoft.CommandPalette.Extensions IContent RootContent { get; }; IContent[] GetChildren(); } + + enum FontFamily + { + UserInterface, + Monospace, + }; + + [contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)] + interface IPlainTextContent requires IContent { + String Text { get; }; + FontFamily FontFamily { get; }; + Boolean WrapWords { get; }; + } + + [contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)] + interface IImageContent requires IContent { + IIconInfo Image { get; }; + Int32 MaxWidth { get; }; + Int32 MaxHeight { get; }; + } [contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)] interface IContentPage requires IPage, INotifyItemsChanged {