From eacd6796498b05c1dd74347668539d332cb6d843 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Fri, 24 Jun 2022 01:04:00 -0400 Subject: [PATCH] Update GTK and FreeDesktop implementations --- src/Avalonia.FreeDesktop/DBusSystemDialog.cs | 159 +++++++++---- src/Avalonia.X11/NativeDialogs/Gtk.cs | 3 + .../NativeDialogs/GtkNativeFileDialogs.cs | 225 ++++++++++-------- src/Avalonia.X11/X11Platform.cs | 7 +- src/Avalonia.X11/X11Window.cs | 9 +- 5 files changed, 263 insertions(+), 140 deletions(-) diff --git a/src/Avalonia.FreeDesktop/DBusSystemDialog.cs b/src/Avalonia.FreeDesktop/DBusSystemDialog.cs index d1905a4569a..c17d5b993c7 100644 --- a/src/Avalonia.FreeDesktop/DBusSystemDialog.cs +++ b/src/Avalonia.FreeDesktop/DBusSystemDialog.cs @@ -1,102 +1,171 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Text; using System.Threading.Tasks; -using Avalonia.Controls; -using Avalonia.Controls.Platform; using Avalonia.Logging; +using Avalonia.Platform; +using Avalonia.Platform.Storage; +using Avalonia.Platform.Storage.FileIO; + using Tmds.DBus; namespace Avalonia.FreeDesktop { - internal class DBusSystemDialog : ISystemDialogImpl + internal class DBusSystemDialog : BclStorageProvider { - private readonly IFileChooser _fileChooser; - - internal static DBusSystemDialog? TryCreate() + private static readonly Lazy s_fileChooser = new(() => { var fileChooser = DBusHelper.Connection?.CreateProxy("org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop"); if (fileChooser is null) return null; try { - fileChooser.GetVersionAsync().GetAwaiter().GetResult(); - return new DBusSystemDialog(fileChooser); + _ = fileChooser.GetVersionAsync(); + return fileChooser; } catch (Exception e) { Logger.TryGet(LogEventLevel.Error, LogArea.X11Platform)?.Log(null, $"Unable to connect to org.freedesktop.portal.Desktop: {e.Message}"); return null; } + }); + + internal static DBusSystemDialog? TryCreate(IPlatformHandle handle) + { + return handle.HandleDescriptor == "XID" && s_fileChooser.Value is { } fileChooser + ? new DBusSystemDialog(fileChooser, handle) : null; } - private DBusSystemDialog(IFileChooser fileChooser) + private readonly IFileChooser _fileChooser; + private readonly IPlatformHandle _handle; + + private DBusSystemDialog(IFileChooser fileChooser, IPlatformHandle handle) { _fileChooser = fileChooser; + _handle = handle; } - public async Task ShowFileDialogAsync(FileDialog dialog, Window parent) + public override bool CanOpen => true; + + public override bool CanSave => true; + + public override bool CanPickFolder => true; + + public override async Task> OpenFilePickerAsync(FilePickerOpenOptions options) { - var parentWindow = $"x11:{parent.PlatformImpl!.Handle.Handle.ToString("X")}"; + var parentWindow = $"x11:{_handle.Handle:X}"; ObjectPath objectPath; - var options = new Dictionary(); - if (dialog.Filters is not null) - options.Add("filters", ParseFilters(dialog)); + var chooserOptions = new Dictionary(); + var filters = ParseFilters(options.FileTypeFilter); + if (filters.Any()) + { + chooserOptions.Add("filters", filters); + } + + chooserOptions.Add("multiple", options.AllowMultiple); + + objectPath = await _fileChooser.OpenFileAsync(parentWindow, options.Title ?? string.Empty, chooserOptions); + + var request = DBusHelper.Connection!.CreateProxy("org.freedesktop.portal.Request", objectPath); + var tsc = new TaskCompletionSource(); + using var disposable = await request.WatchResponseAsync(x => tsc.SetResult(x.results["uris"] as string[]), tsc.SetException); + var uris = await tsc.Task ?? Array.Empty(); + + return uris.Select(path => new BclStorageFile(new FileInfo(new Uri(path).AbsolutePath))).ToList(); + } - switch (dialog) + public override async Task SaveFilePickerAsync(FilePickerSaveOptions options) + { + var parentWindow = $"x11:{_handle.Handle:X}"; + ObjectPath objectPath; + var chooserOptions = new Dictionary(); + var filters = ParseFilters(options.FileTypeChoices); + if (filters.Any()) { - case OpenFileDialog openFileDialog: - options.Add("multiple", openFileDialog.AllowMultiple); - objectPath = await _fileChooser.OpenFileAsync(parentWindow, openFileDialog.Title ?? string.Empty, options); - break; - case SaveFileDialog saveFileDialog: - if (saveFileDialog.InitialFileName is not null) - options.Add("current_name", saveFileDialog.InitialFileName); - if (saveFileDialog.Directory is not null) - options.Add("current_folder", Encoding.UTF8.GetBytes(saveFileDialog.Directory)); - objectPath = await _fileChooser.SaveFileAsync(parentWindow, saveFileDialog.Title ?? string.Empty, options); - break; + chooserOptions.Add("filters", filters); } + if (options.SuggestedFileName is { } currentName) + chooserOptions.Add("current_name", currentName); + if (options.SuggestedStartLocation?.TryGetUri(out var currentFolder) == true) + chooserOptions.Add("current_folder", Encoding.UTF8.GetBytes(currentFolder.ToString())); + objectPath = await _fileChooser.SaveFileAsync(parentWindow, options.Title ?? string.Empty, chooserOptions); + var request = DBusHelper.Connection!.CreateProxy("org.freedesktop.portal.Request", objectPath); var tsc = new TaskCompletionSource(); using var disposable = await request.WatchResponseAsync(x => tsc.SetResult(x.results["uris"] as string[]), tsc.SetException); var uris = await tsc.Task; - if (uris is null) + var path = uris?.FirstOrDefault() is { } filePath ? new Uri(filePath).AbsolutePath : null; + + if (path is null) + { return null; - for (var i = 0; i < uris.Length; i++) - uris[i] = new Uri(uris[i]).AbsolutePath; - return uris; + } + else + { + // WSL2 freedesktop automatically adds extension from selected file type, but we can't pass "default ext". So apply it manually. + path = StorageProviderHelpers.NameWithExtension(path, options.DefaultExtension, null); + + return new BclStorageFile(new FileInfo(path)); + } } - public async Task ShowFolderDialogAsync(OpenFolderDialog dialog, Window parent) + public override async Task> OpenFolderPickerAsync(FolderPickerOpenOptions options) { - var parentWindow = $"x11:{parent.PlatformImpl!.Handle.Handle.ToString("X")}"; - var options = new Dictionary + var parentWindow = $"x11:{_handle.Handle:X}"; + var chooserOptions = new Dictionary { - { "directory", true } + { "directory", true }, + { "multiple", options.AllowMultiple } }; - var objectPath = await _fileChooser.OpenFileAsync(parentWindow, dialog.Title ?? string.Empty, options); + var objectPath = await _fileChooser.OpenFileAsync(parentWindow, options.Title ?? string.Empty, chooserOptions); var request = DBusHelper.Connection!.CreateProxy("org.freedesktop.portal.Request", objectPath); var tsc = new TaskCompletionSource(); using var disposable = await request.WatchResponseAsync(x => tsc.SetResult(x.results["uris"] as string[]), tsc.SetException); - var uris = await tsc.Task; - if (uris is null) - return null; - return uris.Length != 1 ? string.Empty : new Uri(uris[0]).AbsolutePath; + var uris = await tsc.Task ?? Array.Empty(); + + return uris + .Select(path => new Uri(path).AbsolutePath) + // WSL2 freedesktop allows to select files as well in directory picker, filter it out. + .Where(Directory.Exists) + .Select(path => new BclStorageFolder(new DirectoryInfo(path))).ToList(); } - private static (string name, (uint style, string extension)[])[] ParseFilters(FileDialog dialog) + private static (string name, (uint style, string extension)[])[] ParseFilters(IReadOnlyList? fileTypes) { - var filters = new (string name, (uint style, string extension)[])[dialog.Filters!.Count]; - for (var i = 0; i < filters.Length; i++) + // Example: [('Images', [(0, '*.ico'), (1, 'image/png')]), ('Text', [(0, '*.txt')])] + + if (fileTypes is null) { - var extensions = dialog.Filters[i].Extensions.Select(static x => (0u, x)).ToArray(); - filters[i] = (dialog.Filters[i].Name ?? string.Empty, extensions); + return Array.Empty<(string name, (uint style, string extension)[])>(); + } + + var filters = new List<(string name, (uint style, string extension)[])>(); + foreach (var fileType in fileTypes) + { + const uint globStyle = 0u; + const uint mimeStyle = 1u; + + var extensions = Enumerable.Empty<(uint, string)>(); + + if (fileType.Patterns is { } patterns) + { + extensions = extensions.Concat(patterns.Select(static x => (globStyle, x))); + } + else if (fileType.MimeTypes is { } mimeTypes) + { + extensions = extensions.Concat(mimeTypes.Select(static x => (mimeStyle, x))); + } + + if (extensions.Any()) + { + filters.Add((fileType.Name, extensions.ToArray())); + } } - return filters; + return filters.ToArray(); } } } diff --git a/src/Avalonia.X11/NativeDialogs/Gtk.cs b/src/Avalonia.X11/NativeDialogs/Gtk.cs index 73e6a2d6a4b..8e2d920bc76 100644 --- a/src/Avalonia.X11/NativeDialogs/Gtk.cs +++ b/src/Avalonia.X11/NativeDialogs/Gtk.cs @@ -208,6 +208,9 @@ public static extern void [DllImport(GtkName)] public static extern IntPtr gtk_file_filter_add_pattern(IntPtr filter, Utf8Buffer pattern); + [DllImport(GtkName)] + public static extern IntPtr gtk_file_filter_add_mime_type (IntPtr filter, Utf8Buffer mimeType); + [DllImport(GtkName)] public static extern IntPtr gtk_file_chooser_add_filter(IntPtr chooser, IntPtr filter); diff --git a/src/Avalonia.X11/NativeDialogs/GtkNativeFileDialogs.cs b/src/Avalonia.X11/NativeDialogs/GtkNativeFileDialogs.cs index 9539f024b7f..89d08a39746 100644 --- a/src/Avalonia.X11/NativeDialogs/GtkNativeFileDialogs.cs +++ b/src/Avalonia.X11/NativeDialogs/GtkNativeFileDialogs.cs @@ -1,57 +1,147 @@ +#nullable enable + using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; -using Avalonia.Controls; using Avalonia.Controls.Platform; using Avalonia.Platform; using Avalonia.Platform.Interop; +using Avalonia.Platform.Storage; +using Avalonia.Platform.Storage.FileIO; using static Avalonia.X11.NativeDialogs.Glib; using static Avalonia.X11.NativeDialogs.Gtk; -// ReSharper disable AccessToModifiedClosure + namespace Avalonia.X11.NativeDialogs { - class GtkSystemDialog : ISystemDialogImpl + internal class GtkSystemDialog : BclStorageProvider { - private Task _initialized; + private Task? _initialized; + private readonly X11Window _window; + + public GtkSystemDialog(X11Window window) + { + _window = window; + } + + public override bool CanOpen => true; + + public override bool CanSave => true; + + public override bool CanPickFolder => true; + + public override async Task> OpenFilePickerAsync(FilePickerOpenOptions options) + { + await EnsureInitialized(); + + return await await RunOnGlibThread(async () => + { + var res = await ShowDialog(options.Title, _window, GtkFileChooserAction.Open, + options.AllowMultiple, options.SuggestedStartLocation, null, options.FileTypeFilter, null, false) + .ConfigureAwait(false); + return res?.Select(f => new BclStorageFile(new FileInfo(f))).ToArray() ?? Array.Empty(); + }); + } + + public override async Task> OpenFolderPickerAsync(FolderPickerOpenOptions options) + { + await EnsureInitialized(); + + return await await RunOnGlibThread(async () => + { + var res = await ShowDialog(options.Title, _window, GtkFileChooserAction.SelectFolder, + options.AllowMultiple, options.SuggestedStartLocation, null, + null, null, false).ConfigureAwait(false); + return res?.Select(f => new BclStorageFolder(new DirectoryInfo(f))).ToArray() ?? Array.Empty(); + }); + } + + public override async Task SaveFilePickerAsync(FilePickerSaveOptions options) + { + await EnsureInitialized(); + + return await await RunOnGlibThread(async () => + { + var res = await ShowDialog(options.Title, _window, GtkFileChooserAction.Save, + false, options.SuggestedStartLocation, options.SuggestedFileName, options.FileTypeChoices, options.DefaultExtension, options.ShowOverwritePrompt ?? false) + .ConfigureAwait(false); + return res?.FirstOrDefault() is { } file + ? new BclStorageFile(new FileInfo(file)) + : null; + }); + } - private unsafe Task ShowDialog(string title, IWindowImpl parent, GtkFileChooserAction action, - bool multiSelect, string initialDirectory, string initialFileName, IEnumerable filters, string defaultExtension, bool overwritePrompt) + private unsafe Task ShowDialog(string? title, IWindowImpl parent, GtkFileChooserAction action, + bool multiSelect, IStorageFolder? initialFolder, string? initialFileName, + IEnumerable? filters, string? defaultExtension, bool overwritePrompt) { IntPtr dlg; using (var name = new Utf8Buffer(title)) + { dlg = gtk_file_chooser_dialog_new(name, IntPtr.Zero, action, IntPtr.Zero); + } + UpdateParent(dlg, parent); if (multiSelect) + { gtk_file_chooser_set_select_multiple(dlg, true); + } gtk_window_set_modal(dlg, true); - var tcs = new TaskCompletionSource(); - List disposables = null; + var tcs = new TaskCompletionSource(); + List? disposables = null; void Dispose() { - // ReSharper disable once PossibleNullReferenceException - foreach (var d in disposables) d.Dispose(); + foreach (var d in disposables!) + { + d.Dispose(); + } + disposables.Clear(); } - var filtersDic = new Dictionary(); - if(filters != null) + var filtersDic = new Dictionary(); + if (filters != null) + { foreach (var f in filters) { - var filter = gtk_file_filter_new(); - filtersDic[filter] = f; - using (var b = new Utf8Buffer(f.Name)) - gtk_file_filter_set_name(filter, b); + if (f.Patterns?.Any() == true || f.MimeTypes?.Any() == true) + { + var filter = gtk_file_filter_new(); + filtersDic[filter] = f; + using (var b = new Utf8Buffer(f.Name)) + { + gtk_file_filter_set_name(filter, b); + } - foreach (var e in f.Extensions) - using (var b = new Utf8Buffer("*." + e)) - gtk_file_filter_add_pattern(filter, b); + if (f.Patterns is not null) + { + foreach (var e in f.Patterns) + { + using (var b = new Utf8Buffer(e)) + { + gtk_file_filter_add_pattern(filter, b); + } + } + } - gtk_file_chooser_add_filter(dlg, filter); + if (f.MimeTypes is not null) + { + foreach (var e in f.MimeTypes) + { + using (var b = new Utf8Buffer(e)) + { + gtk_file_filter_add_mime_type(filter, b); + } + } + } + + gtk_file_chooser_add_filter(dlg, filter); + } } + } disposables = new List { @@ -63,7 +153,7 @@ void Dispose() }), ConnectSignal(dlg, "response", (_, resp, __) => { - string[] result = null; + string[]? result = null; if (resp == GtkResponseType.Accept) { var resultList = new List(); @@ -71,20 +161,18 @@ void Dispose() var cgs = gs; while (cgs != null) { - if (cgs->Data != IntPtr.Zero) - resultList.Add(Utf8Buffer.StringFromPtr(cgs->Data)); - cgs = cgs->Next; + if (cgs->Data != IntPtr.Zero + && Utf8Buffer.StringFromPtr(cgs->Data) is string str) { resultList.Add(str); } cgs = cgs->Next; } g_slist_free(gs); result = resultList.ToArray(); - + // GTK doesn't auto-append the extension, so we need to do that manually if (action == GtkFileChooserAction.Save) { var currentFilter = gtk_file_chooser_get_filter(dlg); filtersDic.TryGetValue(currentFilter, out var selectedFilter); - for (var c = 0; c < result.Length; c++) - result[c] = NameWithExtension(result[c], defaultExtension, selectedFilter); + for (var c = 0; c < result.Length; c++) { result[c] = StorageProviderHelpers.NameWithExtension(result[c], defaultExtension, selectedFilter); } } } @@ -98,13 +186,19 @@ void Dispose() action == GtkFileChooserAction.Save ? "Save" : action == GtkFileChooserAction.SelectFolder ? "Select" : "Open")) + { gtk_dialog_add_button(dlg, open, GtkResponseType.Accept); + } + using (var open = new Utf8Buffer("Cancel")) + { gtk_dialog_add_button(dlg, open, GtkResponseType.Cancel); + } - if (initialDirectory != null) + Uri? folderPath = null; + if (initialFolder?.TryGetUri(out folderPath) == true) { - using var dir = new Utf8Buffer(initialDirectory); + using var dir = new Utf8Buffer(folderPath.LocalPath); gtk_file_chooser_set_current_folder(dlg, dir); } @@ -112,7 +206,7 @@ void Dispose() { // gtk_file_chooser_set_filename() expects full path using var fn = action == GtkFileChooserAction.Open - ? new Utf8Buffer(Path.Combine(initialDirectory ?? "", initialFileName)) + ? new Utf8Buffer(Path.Combine(folderPath?.LocalPath ?? "", initialFileName)) : new Utf8Buffer(initialFileName); if (action == GtkFileChooserAction.Save) @@ -131,84 +225,29 @@ void Dispose() return tcs.Task; } - string NameWithExtension(string path, string defaultExtension, FileDialogFilter filter) + private async Task EnsureInitialized() { - var name = Path.GetFileName(path); - if (name != null && !name.Contains(".")) + if (_initialized == null) { - if (filter?.Extensions?.Count > 0) - { - if (defaultExtension != null - && filter.Extensions.Contains(defaultExtension)) - return path + "." + defaultExtension.TrimStart('.'); - - var ext = filter.Extensions.FirstOrDefault(x => x != "*"); - if (ext != null) - return path + "." + ext.TrimStart('.'); - } - - if (defaultExtension != null) - path += "." + defaultExtension.TrimStart('.'); + _initialized = StartGtk(); } - return path; - } - - public async Task ShowFileDialogAsync(FileDialog dialog, Window parent) - { - await EnsureInitialized(); - - var platformImpl = parent?.PlatformImpl; - - return await await RunOnGlibThread(() => ShowDialog( - dialog.Title, platformImpl, - dialog is OpenFileDialog ? GtkFileChooserAction.Open : GtkFileChooserAction.Save, - (dialog as OpenFileDialog)?.AllowMultiple ?? false, - dialog.Directory, - dialog.InitialFileName, - dialog.Filters, - (dialog as SaveFileDialog)?.DefaultExtension, - (dialog as SaveFileDialog)?.ShowOverwritePrompt ?? false)); - } - - public async Task ShowFolderDialogAsync(OpenFolderDialog dialog, Window parent) - { - await EnsureInitialized(); - - var platformImpl = parent?.PlatformImpl; - - return await await RunOnGlibThread(async () => - { - var res = await ShowDialog( - dialog.Title, - platformImpl,GtkFileChooserAction.SelectFolder, - false, - dialog.Directory, - null, - null, - null, - false); - - return res?.FirstOrDefault(); - }); - } - - async Task EnsureInitialized() - { - if (_initialized == null) _initialized = StartGtk(); - if (!(await _initialized)) + { throw new Exception("Unable to initialize GTK on separate thread"); + } } - void UpdateParent(IntPtr chooser, IWindowImpl parentWindow) + private static void UpdateParent(IntPtr chooser, IWindowImpl parentWindow) { var xid = parentWindow.Handle.Handle; gtk_widget_realize(chooser); var window = gtk_widget_get_window(chooser); var parent = GetForeignWindow(xid); if (window != IntPtr.Zero && parent != IntPtr.Zero) + { gdk_window_set_transient_for(window, parent); + } } } } diff --git a/src/Avalonia.X11/X11Platform.cs b/src/Avalonia.X11/X11Platform.cs index fa7ae69759d..1e5789e540b 100644 --- a/src/Avalonia.X11/X11Platform.cs +++ b/src/Avalonia.X11/X11Platform.cs @@ -80,7 +80,6 @@ public void Initialize(X11PlatformOptions options) .Bind().ToConstant(new X11Clipboard(this)) .Bind().ToConstant(new PlatformSettingsStub()) .Bind().ToConstant(new X11IconLoader(Info)) - .Bind().ToConstant(DBusSystemDialog.TryCreate() as ISystemDialogImpl ?? new ManagedFileDialogExtensions.ManagedSystemDialogImpl()) .Bind().ToConstant(new LinuxMountedVolumeInfoProvider()) .Bind().ToConstant(new X11PlatformLifetimeEvents(this)); @@ -214,6 +213,12 @@ public class X11PlatformOptions /// public bool UseDBusMenu { get; set; } = true; + /// + /// Enables GTK file picker instead of default FreeDesktop. + /// The default value is true. And FreeDesktop file picker is used instead if available. + /// + public bool UseGtkFilePicker { get; set; } = false; + /// /// Deferred renderer would be used when set to true. Immediate renderer when set to false. The default value is true. /// diff --git a/src/Avalonia.X11/X11Window.cs b/src/Avalonia.X11/X11Window.cs index 4975a59f330..69761ae5166 100644 --- a/src/Avalonia.X11/X11Window.cs +++ b/src/Avalonia.X11/X11Window.cs @@ -17,6 +17,7 @@ using Avalonia.OpenGL; using Avalonia.OpenGL.Egl; using Avalonia.Platform; +using Avalonia.Platform.Storage; using Avalonia.Rendering; using Avalonia.Threading; using Avalonia.X11.Glx; @@ -28,7 +29,8 @@ namespace Avalonia.X11 unsafe partial class X11Window : IWindowImpl, IPopupImpl, IXI2Client, ITopLevelImplWithNativeMenuExporter, ITopLevelImplWithNativeControlHost, - ITopLevelImplWithTextInputMethod + ITopLevelImplWithTextInputMethod, + ITopLevelImplWithStorageProvider { private readonly AvaloniaX11Platform _platform; private readonly bool _popup; @@ -211,6 +213,10 @@ public X11Window(AvaloniaX11Platform platform, IWindowImpl popupParent) XChangeProperty(_x11.Display, _handle, _x11.Atoms._NET_WM_SYNC_REQUEST_COUNTER, _x11.Atoms.XA_CARDINAL, 32, PropertyMode.Replace, ref _xSyncCounter, 1); } + + var canUseFreeDekstopPicker = !platform.Options.UseGtkFilePicker && platform.Options.UseDBusMenu; + StorageProvider = canUseFreeDekstopPicker && DBusSystemDialog.TryCreate(Handle) is {} dBusStorage + ? dBusStorage : new NativeDialogs.GtkSystemDialog(this); } class SurfaceInfo : EglGlPlatformSurface.IEglWindowGlPlatformSurfaceInfo @@ -1192,6 +1198,7 @@ public void SetWindowManagerAddShadowHint(bool enabled) public bool NeedsManagedDecorations => false; + public IStorageProvider StorageProvider { get; } public class SurfacePlatformHandle : IPlatformNativeSurfaceHandle {