ContextMenu (windows only)

This commit is contained in:
2022-05-28 21:59:43 +02:00
parent 6e7742a08a
commit fbf36b890b
12 changed files with 508 additions and 30 deletions

View File

@@ -0,0 +1,30 @@
using System.Globalization;
using Avalonia.Controls;
using Avalonia.Data.Converters;
using FileTime.App.Core.ViewModels;
using FileTime.GuiApp.Services;
using Microsoft.Extensions.DependencyInjection;
namespace FileTime.GuiApp.Converters;
public class ContextMenuGenerator : IValueConverter
{
private IContextMenuProvider? _contextMenuProvider;
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
_contextMenuProvider ??= DI.ServiceProvider.GetRequiredService<IContextMenuProvider>();
if (value is IContainerViewModel containerViewModel)
{
return _contextMenuProvider.GetContextMenuForFolder(containerViewModel.Container);
}
return new object[] { new MenuItem() { Header = "asd" } };
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}

View File

@@ -24,7 +24,7 @@
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="0.10.14" />
<PackageReference Include="Avalonia.ReactiveUI" Version="0.10.14" />
<PackageReference Include="Avalonia.Svg.Skia" Version="0.10.13" />
<PackageReference Include="Avalonia.Svg.Skia" Version="0.10.14" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="6.0.0" />
<PackageReference Include="MvvmGen" Version="1.1.5" />

View File

@@ -0,0 +1,71 @@
using System.Drawing;
using System.Runtime.InteropServices;
using System.Text;
namespace FileTime.GuiApp.Helper;
public static class WindowsNativeMethods
{
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true, BestFitMapping = false, ThrowOnUnmappableChar = true)]
internal static extern IntPtr LoadLibrary(string lpLibFileName);
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true, BestFitMapping = false, ThrowOnUnmappableChar = true)]
internal static extern int LoadString(IntPtr hInstance, uint wID, StringBuilder lpBuffer, int nBufferMax);
[DllImport("user32.dll")]
public static extern IntPtr LoadIcon(IntPtr hInstance, IntPtr lpIconName);
[DllImport("kernel32.dll")]
public static extern int FreeLibrary(IntPtr hLibModule);
[DllImport("shell32.dll")]
public static extern IntPtr ExtractAssociatedIcon(IntPtr hInst, StringBuilder lpIconPath, out ushort lpiIcon);
}
public static class NativeMethodHelpers
{
public static string GetStringResource(string fileName, uint resourceId)
{
IntPtr? handle = null;
try
{
handle = WindowsNativeMethods.LoadLibrary(fileName);
StringBuilder buffer = new(8192); //Buffer for output from LoadString()
int length = WindowsNativeMethods.LoadString(handle.Value, resourceId, buffer, buffer.Capacity);
return buffer.ToString(0, length); //Return the part of the buffer that was used.
}
finally
{
if (handle is IntPtr validHandle)
{
WindowsNativeMethods.FreeLibrary(validHandle);
}
}
}
public static Icon GetIconResource(string fileName, uint resourceId)
{
IntPtr? handle = null;
try
{
handle = WindowsNativeMethods.LoadLibrary(fileName);
IntPtr handle2 = WindowsNativeMethods.LoadIcon(handle.Value, new IntPtr(resourceId));
return Icon.FromHandle(handle2);
}
finally
{
if (handle is IntPtr validHandle)
{
WindowsNativeMethods.FreeLibrary(validHandle);
}
}
}
/*public static Icon GetAssociatedIcon()
{
ushort uicon;
StringBuilder strB = new StringBuilder(fileName);
IntPtr handle = WindowsNativeMethods.ExtractAssociatedIcon(this.Handle, strB, out uicon);
return Icon.FromHandle(handle);
}*/
}

View File

@@ -0,0 +1,79 @@
using System.Drawing.Imaging;
using Avalonia.Media.Imaging;
using FileTime.Core.Models;
using FileTime.GuiApp.Helper;
using FileTime.GuiApp.Models;
namespace FileTime.GuiApp.IconProviders;
public static class WindowsSystemIconHelper
{
public static ImagePath? GetImageByDesktopIni(IContainer folder)
{
var file = new FileInfo(Path.Combine(folder.NativePath.Path, "desktop.ini"));
if (file.Exists)
{
var lines = File.ReadAllLines(file.FullName);
if (Array.Find(lines, l => l.StartsWith("iconresource", StringComparison.OrdinalIgnoreCase)) is string iconLine)
{
var nameLineValue = string.Join('=', iconLine.Split('=')[1..]);
return GetImagePathByIconPath(nameLineValue);
}
}
return null;
}
public static ImagePath GetImagePathByIconPath(string path)
{
var environemntVariables = Environment.GetEnvironmentVariables();
foreach (var keyo in environemntVariables.Keys)
{
if (keyo is string key && environemntVariables[key] is string value)
{
path = path.Replace($"%{key}%", value);
}
}
var parts = path.Split(',');
(var parsedResourceId, var path2) = parts.Length >= 2 && long.TryParse(parts[^1], out var id)
? (id, NormalizePath(string.Join(',', parts[..^1])))
: (0, NormalizePath(path));
if (parsedResourceId == 0)
{
using var extractedIconAsStream = new MemoryStream();
using var extractedIcon = System.Drawing.Icon.ExtractAssociatedIcon(path2).ToBitmap();
extractedIcon.Save(extractedIconAsStream, ImageFormat.Png);
extractedIconAsStream.Position = 0;
#pragma warning disable IDISP004 // Don't ignore created IDisposable
return new ImagePath(ImagePathType.Raw, new Bitmap(extractedIconAsStream));
#pragma warning restore IDISP004 // Don't ignore created IDisposable
}
else
{
if (parsedResourceId < 0) parsedResourceId *= -1;
using var extractedIcon = NativeMethodHelpers.GetIconResource(path2, (uint)parsedResourceId).ToBitmap();
using var extractedIconAsStream = new MemoryStream();
extractedIcon.Save(extractedIconAsStream, ImageFormat.Png);
extractedIconAsStream.Position = 0;
#pragma warning disable IDISP004 // Don't ignore created IDisposable
return new ImagePath(ImagePathType.Raw, new Bitmap(extractedIconAsStream));
#pragma warning restore IDISP004 // Don't ignore created IDisposable
}
}
private static string NormalizePath(string path)
{
if (path.StartsWith('\"') && path.EndsWith('\"'))
{
return path[1..^1];
}
return path;
}
}

View File

@@ -1,6 +1,13 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="using:FileTime.GuiApp.Converters">
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceInclude Source="avares://FileTime.GuiApp/Resources/Brushes.axaml"></ResourceInclude>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</ResourceDictionary.MergedDictionaries>
<converters:ItemViewModeToBrushConverter
AlternativeBrush="{StaticResource AlternativeItemForegroundBrush}"
DefaultBrush="{StaticResource ForegroundBrush}"
@@ -32,4 +39,5 @@
<converters:CommandToCommandNameConverter x:Key="CommandToCommandNameConverter" />
<converters:ItemToImageConverter x:Key="ItemToImageConverter" />
<converters:StringReplaceConverter x:Key="PathPreformatter" OldValue="://" NewValue="/"/>
<converters:ContextMenuGenerator x:Key="ContextMenuGenerator"/>
</ResourceDictionary>

View File

@@ -1,5 +1,13 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Styles.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceInclude Source="avares://FileTime.GuiApp/Resources/Converters.axaml"></ResourceInclude>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Styles.Resources>
<FluentTheme Mode="Dark" />
<Style Selector="TextBlock">
@@ -20,15 +28,15 @@
<Setter Property="Background" Value="Transparent" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
</Style>
<Style Selector="ListBox.ContentListView &gt; ListBoxItem">
<Style Selector="ListBox.ContentListView > ListBoxItem">
<Setter Property="Margin" Value="0" />
<Setter Property="Padding" Value="0" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<!--Setter Property="ContextMenu">
<ContextMenu Items="{Binding Converter={StaticResource ContextMenuGenerator}}"/>
</Setter-->
<Setter Property="ContextMenu">
<ContextMenu Items="{Binding Converter={StaticResource ContextMenuGenerator}}" />
</Setter>
</Style>
<Style Selector="Grid.SidebarContainerPresenter">

View File

@@ -0,0 +1,11 @@
using FileTime.Core.Models;
namespace FileTime.GuiApp.Services;
public class LinuxContextMenuProvider : IContextMenuProvider
{
public List<object> GetContextMenuForFolder(IContainer container)
{
return new List<object>();
}
}

View File

@@ -0,0 +1,249 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using Avalonia.Controls;
using Avalonia.Media;
using FileTime.Core.Models;
using FileTime.GuiApp.Helper;
using FileTime.GuiApp.IconProviders;
using FileTime.Providers.Local;
using Microsoft.Win32;
namespace FileTime.GuiApp.Services;
public class WindowsContextMenuProvider : IContextMenuProvider
{
public List<object> GetContextMenuForFolder(IContainer container)
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) throw new NotSupportedException();
var menuItems = new List<object>();
if (container.Provider is ILocalContentProvider)
{
using var directoryKey = Registry.ClassesRoot.OpenSubKey("Directory");
ProcessRegistryKey(directoryKey, menuItems, container!.NativePath!.Path);
}
return menuItems;
}
private void ProcessRegistryKey(RegistryKey? contextMenuContainer, List<object> menuItems, string folderPath)
{
using var shell = contextMenuContainer?.OpenSubKey("shell");
if (shell == null) return;
var shellSubKeys = shell.GetSubKeyNames();
foreach (var shellKey in shellSubKeys.Select(k => shell.OpenSubKey(k)).OfType<RegistryKey>())
{
var textBase = shellKey.GetValue(null) as string ?? shellKey.GetValue("MUIVerb") as string;
if (textBase == null) continue;
string? text = null;
if (textBase.StartsWith("@"))
{
var parts = textBase[1..].Split(',');
if (parts.Length == 2 && long.TryParse(parts[1], out var parsedResourceId))
{
if (parsedResourceId < 0) parsedResourceId *= -1;
text = NativeMethodHelpers.GetStringResource(string.Join(',', parts[..^1]), (uint) parsedResourceId);
}
}
else
{
text = textBase;
}
if (text != null)
{
text = text.Replace("&", "");
object? image = null;
try
{
if (shellKey.GetValue("Icon") is string iconPath)
{
var imagePath = WindowsSystemIconHelper.GetImagePathByIconPath(iconPath);
if (imagePath.Type == Models.ImagePathType.Raw)
{
image = new Image()
{
Source = (IImage) imagePath.Image!
};
}
}
}
catch
{
}
using var commandKey = shellKey.OpenSubKey("command");
if (shellKey.GetSubKeyNames().Contains("command") && commandKey?.GetValue(null) is string commandString)
{
var item = new MenuItem() {Header = text, Icon = image};
item.Click += (o, e) => MenuItemClick(folderPath, commandString);
menuItems.Add(item);
}
else if (shellKey.GetValue("ExtendedSubCommandsKey") is string extendedCommands)
{
var rootMenu = new MenuItem() {Header = text, Icon = image};
var rootMenuItems = new List<object>();
ProcessRegistryKey(Registry.ClassesRoot.OpenSubKey(extendedCommands), rootMenuItems, folderPath);
rootMenu.Items = rootMenuItems.ToArray();
menuItems.Add(rootMenu);
}
}
}
}
private static void MenuItemClick(string folderPath, string commandString)
{
var commandPartsWithoutAp = commandString.Split('\"').ToList();
var commandParts = new List<List<string>>();
for (var i = 0; i < commandPartsWithoutAp.Count; i++)
{
if (i % 2 == 0)
{
commandParts.Add(commandPartsWithoutAp[i].Split(' ').ToList());
}
else
{
commandParts.Add(new List<string> {commandPartsWithoutAp[i]});
}
}
for (var i = 0; i < commandParts.Count; i++)
{
for (var i2 = 0; i2 < commandParts[i].Count; i2++)
{
commandParts[i][i2] = commandParts[i][i2].Replace("%1", folderPath).Replace("%V", folderPath);
}
}
var commandPartsWithoutEmpty = commandParts.SelectMany(c => c).Where(c => !string.IsNullOrWhiteSpace(c)).ToList();
if (commandPartsWithoutEmpty.Count == 1)
{
Process.Start(commandPartsWithoutEmpty[0]);
}
else if (commandPartsWithoutEmpty.Count > 1)
{
var paramStartIndex1 = -1;
var paramStartIndex2 = -1;
var found = false;
for (var x = 0; x < commandParts.Count && paramStartIndex1 == -1; x++)
{
for (var y = 0; y < commandParts[x].Count; y++)
{
if (found)
{
paramStartIndex1 = x;
paramStartIndex2 = y;
break;
}
if (commandParts[x][y] == commandPartsWithoutEmpty[0])
{
found = true;
}
}
}
var arguments = SumList(commandParts, paramStartIndex1, paramStartIndex2);
try
{
using var process = new Process();
process.StartInfo.FileName = commandPartsWithoutEmpty[0];
process.StartInfo.Arguments = arguments;
process.Start();
}
catch
{
if (commandParts[0].Count > 0)
{
var executable = "";
var lastExecutablePart = 0;
for (lastExecutablePart = 0; !File.Exists(executable) && lastExecutablePart < commandParts[0].Count; lastExecutablePart++)
{
executable += (lastExecutablePart == 0 ? "" : " ") + commandParts[0][lastExecutablePart];
}
lastExecutablePart--;
if (File.Exists(executable))
{
try
{
var (paramStartX, paramStartY) = GetCoordinatesFrom(commandParts, 1, 0, lastExecutablePart);
arguments = SumList(commandParts, paramStartX, paramStartY);
using var process = new Process();
process.StartInfo.FileName = executable;
process.StartInfo.Arguments = arguments;
process.Start();
}
catch
{
//TODO: error message
}
}
}
//TODO: ELSE error message
}
}
}
private static string SumList(List<List<string>> data, int paramStartIndex1, int paramStartIndex2)
{
var result = "";
for (var x = paramStartIndex1; x < data.Count; x++)
{
if (x % 2 == 1) result += "\"";
result += string.Join(
' ',
x == paramStartIndex1
? data[x].Skip(paramStartIndex2)
: data[x]
);
if (x % 2 == 1) result += "\"";
}
return result;
}
private static (int, int) GetCoordinatesFrom(List<List<string>> data, int skip, int startX = 0, int startY = 0)
{
int skipping = 0;
var x = startX;
var y = startY;
var resultX = -1;
var resultY = -1;
for (; x < data.Count && resultX == -1; x++, y = 0)
{
for (; y < data[x].Count; y++)
{
if (skipping == skip)
{
resultX = x;
resultY = y;
break;
}
skipping++;
}
}
return (resultX, resultY);
}
}

View File

@@ -25,6 +25,18 @@
Closed="OnWindowClosed"
TransparencyLevelHint="Blur"
mc:Ignorable="d">
<Window.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceInclude Source="avares://FileTime.GuiApp/Resources/SolarizedDarkTheme.axaml"></ResourceInclude>
<ResourceInclude Source="avares://FileTime.GuiApp/Resources/Brushes.axaml"></ResourceInclude>
<ResourceInclude Source="avares://FileTime.GuiApp/Resources/Converters.axaml"></ResourceInclude>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Window.Resources>
<Window.Styles>
<StyleInclude Source="avares://FileTime.GuiApp/Resources/Styles.axaml" />
</Window.Styles>
<Grid Background="{DynamicResource AppBackgroundBrush}">
<Grid
@@ -140,7 +152,8 @@
</Grid>
</Border>
<Border Grid.Row="1" CornerRadius="10" Background="{DynamicResource ContainerBackgroundBrush}" Padding="0,10" Margin="10">
<Border Grid.Row="1" CornerRadius="10" Background="{DynamicResource ContainerBackgroundBrush}"
Padding="0,10" Margin="10">
<Grid RowDefinitions="Auto,Auto">
<TextBlock
@@ -152,8 +165,10 @@
Items="{Binding AppState.Places}">
<ItemsRepeater.ItemTemplate>
<DataTemplate x:DataType="vm:PlaceInfo">
<Grid Classes="SidebarContainerPresenter" PointerPressed="OnHasContainerPointerPressed" Cursor="Hand">
<StackPanel Orientation="Horizontal" Margin="10,5" HorizontalAlignment="Stretch">
<Grid Classes="SidebarContainerPresenter"
PointerPressed="OnHasContainerPointerPressed" Cursor="Hand">
<StackPanel Orientation="Horizontal" Margin="10,5"
HorizontalAlignment="Stretch">
<Image
Width="20"
Height="20"
@@ -172,7 +187,7 @@
</Grid>
</Border>
<!--Border Grid.Row="2" CornerRadius="10" Background="{DynamicResource ContainerBackgroundBrush}" Padding="0,10" Margin="10">
<!--Border Grid.Row="2" CornerRadius="10" Background="{DynamicResource ContainerBackgroundBrush}" Padding="0,10" Margin="10">
<Grid RowDefinitions="Auto,Auto">
<TextBlock
@@ -265,7 +280,7 @@
<TextBlock
Margin="5,0,0,0"
VerticalAlignment="Center"
Text="{Binding CurrentLocation^.Name}" />
Text="{Binding CurrentLocation^.Name, FallbackValue=Loading...}" />
</StackPanel>
<Rectangle
@@ -280,14 +295,7 @@
<Grid
Grid.Row="2"
Margin="20,0,0,0">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="15*" />
<ColumnDefinition Width="10" />
<ColumnDefinition Width="40*" />
<ColumnDefinition Width="10" />
<ColumnDefinition Width="45*" />
</Grid.ColumnDefinitions>
<Grid ColumnDefinitions="15*,10,40*,10,45*">
<ListBox
x:CompileBindings="False"
@@ -318,7 +326,7 @@
<Grid
Grid.Column="2"
RowDefinitions="Auto,*">
<Grid IsVisible="{Binding AppState.SelectedTab^.CurrentLocation^.IsLoading^}">
<Grid IsVisible="{Binding AppState.SelectedTab^.CurrentLocation^.IsLoading^, FallbackValue=False}">
<Image
Width="40"
Height="40"
@@ -422,11 +430,11 @@
<Grid
IsVisible="{Binding ItemPreviewService.ItemPreview^,Converter={x:Static ObjectConverters.IsNotNull}}">
<TextBlock HorizontalAlignment="Center" Text="Don't know how to preview this item."
IsVisible="{Binding ItemPreviewService.ItemPreview^.Mode, Converter={StaticResource EqualityConverter}, ConverterParameter={x:Static appCoreModels:ItemPreviewMode.Unknown}}" />
IsVisible="{Binding ItemPreviewService.ItemPreview^.Mode, Converter={StaticResource EqualityConverter}, ConverterParameter={x:Static appCoreModels:ItemPreviewMode.Unknown},FallbackValue={x:Static appCoreModels:ItemPreviewMode.Unknown}}" />
<TextBlock HorizontalAlignment="Center" Text="Empty"
IsVisible="{Binding ItemPreviewService.ItemPreview^.Mode, Converter={StaticResource EqualityConverter}, ConverterParameter={x:Static appCoreModels:ItemPreviewMode.Empty}}" />
IsVisible="{Binding ItemPreviewService.ItemPreview^.Mode, Converter={StaticResource EqualityConverter}, ConverterParameter={x:Static appCoreModels:ItemPreviewMode.Empty},FallbackValue={x:Static appCoreModels:ItemPreviewMode.Unknown}}" />
<ScrollViewer
IsVisible="{Binding ItemPreviewService.ItemPreview^.Mode, Converter={StaticResource EqualityConverter}, ConverterParameter={x:Static appCoreModels:ItemPreviewMode.Text}}">
IsVisible="{Binding ItemPreviewService.ItemPreview^.Mode, Converter={StaticResource EqualityConverter}, ConverterParameter={x:Static appCoreModels:ItemPreviewMode.Text},FallbackValue={x:Static appCoreModels:ItemPreviewMode.Unknown}}">
<TextBox
IsReadOnly="True"
x:CompileBindings="False"
@@ -439,11 +447,8 @@
<Grid Grid.Row="3">
<Grid
IsVisible="{Binding AppState.ViewMode^, Converter={StaticResource EqualityConverter},ConverterParameter=RapidTravel}">
<Grid.RowDefinitions>
<RowDefinition Height="1" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
IsVisible="{Binding AppState.ViewMode^, Converter={StaticResource EqualityConverter},ConverterParameter=RapidTravel}"
RowDefinitions="1,Auto">
<Rectangle
Height="1"