committed by
GitHub
135 changed files with 5334 additions and 1135 deletions
@ -0,0 +1,48 @@ |
|||
# Starter pipeline |
|||
# Start with a minimal pipeline that you can customize to build and deploy your code. |
|||
# Add steps that build, run tests, deploy, and more: |
|||
# https://aka.ms/yaml |
|||
|
|||
trigger: |
|||
- master |
|||
|
|||
jobs: |
|||
- job: Mac |
|||
pool: |
|||
name: 'AvaloniaMacPool' |
|||
|
|||
steps: |
|||
- script: ./tests/Avalonia.IntegrationTests.Appium/macos-clean-build-test.sh |
|||
displayName: 'run integration tests' |
|||
|
|||
|
|||
- job: Windows |
|||
pool: |
|||
vmImage: 'windows-2022' |
|||
|
|||
steps: |
|||
- task: UseDotNet@2 |
|||
displayName: 'Use .NET Core SDK 6.0.202' |
|||
inputs: |
|||
version: 6.0.202 |
|||
|
|||
- task: Windows Application Driver@0 |
|||
inputs: |
|||
OperationType: 'Start' |
|||
AgentResolution: '4K' |
|||
displayName: 'Start WinAppDriver' |
|||
|
|||
- task: DotNetCoreCLI@2 |
|||
inputs: |
|||
command: 'build' |
|||
projects: 'samples/IntegrationTestApp/IntegrationTestApp.csproj' |
|||
|
|||
- task: DotNetCoreCLI@2 |
|||
inputs: |
|||
command: 'test' |
|||
projects: 'tests/Avalonia.IntegrationTests.Appium/Avalonia.IntegrationTests.Appium.csproj' |
|||
|
|||
- task: Windows Application Driver@0 |
|||
inputs: |
|||
OperationType: 'Stop' |
|||
displayName: 'Stop WinAppDriver' |
|||
@ -1,29 +1,57 @@ |
|||
<UserControl xmlns="https://github.com/avaloniaui" |
|||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" |
|||
x:Class="ControlCatalog.Pages.DialogsPage"> |
|||
<StackPanel Orientation="Vertical" Spacing="4" Margin="4"> |
|||
<CheckBox Name="UseFilters">Use filters</CheckBox> |
|||
<Button Name="OpenFile">_Open File</Button> |
|||
<Button Name="OpenMultipleFiles">Open _Multiple File</Button> |
|||
<Button Name="SaveFile">_Save File</Button> |
|||
<Button Name="SelectFolder">Select Fo_lder</Button> |
|||
<Button Name="OpenBoth">Select _Both</Button> |
|||
<UserControl x:Class="ControlCatalog.Pages.DialogsPage" |
|||
xmlns="https://github.com/avaloniaui" |
|||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> |
|||
<StackPanel Margin="4" |
|||
Orientation="Vertical" |
|||
Spacing="4"> |
|||
|
|||
<TextBlock x:Name="PickerLastResultsVisible" |
|||
Classes="h2" |
|||
IsVisible="False" |
|||
Text="Last picker results:" /> |
|||
<ItemsPresenter x:Name="PickerLastResults" /> |
|||
<TextBlock Text="Windows:" /> |
|||
|
|||
<TextBlock Margin="0, 8, 0, 0" |
|||
Classes="h1" |
|||
Text="Window dialogs" /> |
|||
<Button Name="DecoratedWindow">Decorated _window</Button> |
|||
<Button Name="DecoratedWindowDialog">Decorated w_indow (dialog)</Button> |
|||
<Button Name="Dialog" ToolTip.Tip="Shows a dialog">_Dialog</Button> |
|||
<Button Name="DialogNoTaskbar">Dialog (_No taskbar icon)</Button> |
|||
<Button Name="OwnedWindow">Own_ed window</Button> |
|||
<Button Name="OwnedWindowNoTaskbar">Owned window (No tas_kbar icon)</Button> |
|||
<Expander Header="Window dialogs"> |
|||
<StackPanel Spacing="4"> |
|||
<Button Name="DecoratedWindow">Decorated _window</Button> |
|||
<Button Name="DecoratedWindowDialog">Decorated w_indow (dialog)</Button> |
|||
<Button Name="Dialog" ToolTip.Tip="Shows a dialog">_Dialog</Button> |
|||
<Button Name="DialogNoTaskbar">Dialog (_No taskbar icon)</Button> |
|||
<Button Name="OwnedWindow">Own_ed window</Button> |
|||
<Button Name="OwnedWindowNoTaskbar">Owned window (No tas_kbar icon)</Button> |
|||
</StackPanel> |
|||
</Expander> |
|||
|
|||
<TextBlock Margin="0,20,0,0" Text="Pickers:" /> |
|||
|
|||
<CheckBox Name="UseFilters">Use filters</CheckBox> |
|||
<Expander Header="FilePicker API"> |
|||
<StackPanel Spacing="4"> |
|||
<CheckBox Name="ForceManaged">Force managed dialog</CheckBox> |
|||
<CheckBox Name="OpenMultiple">Open multiple</CheckBox> |
|||
<Button Name="OpenFolderPicker">Select Fo_lder</Button> |
|||
<Button Name="OpenFilePicker">_Open File</Button> |
|||
<Button Name="SaveFilePicker">_Save File</Button> |
|||
<Button Name="OpenFileFromBookmark">Open File Bookmark</Button> |
|||
<Button Name="OpenFolderFromBookmark">Open Folder Bookmark</Button> |
|||
</StackPanel> |
|||
</Expander> |
|||
<Expander Header="Legacy OpenFileDialog"> |
|||
<StackPanel Spacing="4"> |
|||
<Button Name="OpenFile">_Open File</Button> |
|||
<Button Name="OpenMultipleFiles">Open _Multiple File</Button> |
|||
<Button Name="SaveFile">_Save File</Button> |
|||
<Button Name="SelectFolder">Select Fo_lder</Button> |
|||
<Button Name="OpenBoth">Select _Both</Button> |
|||
</StackPanel> |
|||
</Expander> |
|||
|
|||
<TextBlock x:Name="PickerLastResultsVisible" |
|||
Classes="h2" |
|||
IsVisible="False" |
|||
Text="Last picker results:" /> |
|||
<ItemsPresenter x:Name="PickerLastResults" /> |
|||
|
|||
<TextBox Name="BookmarkContainer" Watermark="Bookmark" /> |
|||
<TextBox Name="OpenedFileContent" |
|||
MaxLines="10" |
|||
Watermark="Picked file content" /> |
|||
|
|||
</StackPanel> |
|||
</UserControl> |
|||
|
|||
@ -0,0 +1,244 @@ |
|||
#nullable enable |
|||
|
|||
using System; |
|||
using System.Diagnostics.CodeAnalysis; |
|||
using System.IO; |
|||
using System.Linq; |
|||
using System.Threading.Tasks; |
|||
using Android.Content; |
|||
using Android.Provider; |
|||
using Avalonia.Logging; |
|||
using Avalonia.Platform.Storage; |
|||
using Java.Lang; |
|||
using AndroidUri = Android.Net.Uri; |
|||
using Exception = System.Exception; |
|||
using JavaFile = Java.IO.File; |
|||
|
|||
namespace Avalonia.Android.Platform.Storage; |
|||
|
|||
internal abstract class AndroidStorageItem : IStorageBookmarkItem |
|||
{ |
|||
private Context? _context; |
|||
|
|||
protected AndroidStorageItem(Context context, AndroidUri uri) |
|||
{ |
|||
_context = context; |
|||
Uri = uri; |
|||
} |
|||
|
|||
internal AndroidUri Uri { get; } |
|||
|
|||
protected Context Context => _context ?? throw new ObjectDisposedException(nameof(AndroidStorageItem)); |
|||
|
|||
public string Name => GetColumnValue(Context, Uri, MediaStore.IMediaColumns.DisplayName) |
|||
?? Uri.PathSegments?.LastOrDefault() ?? string.Empty; |
|||
|
|||
public bool CanBookmark => true; |
|||
|
|||
public Task<string?> SaveBookmark() |
|||
{ |
|||
Context.ContentResolver?.TakePersistableUriPermission(Uri, ActivityFlags.GrantWriteUriPermission | ActivityFlags.GrantReadUriPermission); |
|||
return Task.FromResult(Uri.ToString()); |
|||
} |
|||
|
|||
public Task ReleaseBookmark() |
|||
{ |
|||
Context.ContentResolver?.ReleasePersistableUriPermission(Uri, ActivityFlags.GrantWriteUriPermission | ActivityFlags.GrantReadUriPermission); |
|||
return Task.CompletedTask; |
|||
} |
|||
|
|||
public bool TryGetUri([NotNullWhen(true)] out Uri? uri) |
|||
{ |
|||
uri = new Uri(Uri.ToString()!); |
|||
return true; |
|||
} |
|||
|
|||
public abstract Task<StorageItemProperties> GetBasicPropertiesAsync(); |
|||
|
|||
protected string? GetColumnValue(Context context, AndroidUri contentUri, string column, string? selection = null, string[]? selectionArgs = null) |
|||
{ |
|||
try |
|||
{ |
|||
var projection = new[] { column }; |
|||
using var cursor = context.ContentResolver!.Query(contentUri, projection, selection, selectionArgs, null); |
|||
if (cursor?.MoveToFirst() == true) |
|||
{ |
|||
var columnIndex = cursor.GetColumnIndex(column); |
|||
if (columnIndex != -1) |
|||
return cursor.GetString(columnIndex); |
|||
} |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)?.Log(this, "File metadata reader failed: '{Exception}'", ex); |
|||
} |
|||
|
|||
return null; |
|||
} |
|||
|
|||
public Task<IStorageFolder?> GetParentAsync() |
|||
{ |
|||
using var javaFile = new JavaFile(Uri.Path!); |
|||
|
|||
// Java file represents files AND directories. Don't be confused.
|
|||
if (javaFile.ParentFile is {} parentFile |
|||
&& AndroidUri.FromFile(parentFile) is {} androidUri) |
|||
{ |
|||
return Task.FromResult<IStorageFolder?>(new AndroidStorageFolder(Context, androidUri)); |
|||
} |
|||
|
|||
return Task.FromResult<IStorageFolder?>(null); |
|||
} |
|||
|
|||
public void Dispose() |
|||
{ |
|||
_context = null; |
|||
} |
|||
} |
|||
|
|||
internal sealed class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder |
|||
{ |
|||
public AndroidStorageFolder(Context context, AndroidUri uri) : base(context, uri) |
|||
{ |
|||
} |
|||
|
|||
public override Task<StorageItemProperties> GetBasicPropertiesAsync() |
|||
{ |
|||
return Task.FromResult(new StorageItemProperties()); |
|||
} |
|||
} |
|||
|
|||
internal sealed class AndroidStorageFile : AndroidStorageItem, IStorageBookmarkFile |
|||
{ |
|||
public AndroidStorageFile(Context context, AndroidUri uri) : base(context, uri) |
|||
{ |
|||
} |
|||
|
|||
public bool CanOpenRead => true; |
|||
|
|||
public bool CanOpenWrite => true; |
|||
|
|||
public Task<Stream> OpenRead() => Task.FromResult(OpenContentStream(Context, Uri, false) |
|||
?? throw new InvalidOperationException("Failed to open content stream")); |
|||
|
|||
public Task<Stream> OpenWrite() => Task.FromResult(OpenContentStream(Context, Uri, true) |
|||
?? throw new InvalidOperationException("Failed to open content stream")); |
|||
|
|||
private Stream? OpenContentStream(Context context, AndroidUri uri, bool isOutput) |
|||
{ |
|||
var isVirtual = IsVirtualFile(context, uri); |
|||
if (isVirtual) |
|||
{ |
|||
Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)?.Log(this, "Content URI was virtual: '{Uri}'", uri); |
|||
return GetVirtualFileStream(context, uri, isOutput); |
|||
} |
|||
|
|||
return isOutput |
|||
? context.ContentResolver?.OpenOutputStream(uri) |
|||
: context.ContentResolver?.OpenInputStream(uri); |
|||
} |
|||
|
|||
private bool IsVirtualFile(Context context, AndroidUri uri) |
|||
{ |
|||
if (!DocumentsContract.IsDocumentUri(context, uri)) |
|||
return false; |
|||
|
|||
var value = GetColumnValue(context, uri, DocumentsContract.Document.ColumnFlags); |
|||
if (!string.IsNullOrEmpty(value) && int.TryParse(value, out var flagsInt)) |
|||
{ |
|||
var flags = (DocumentContractFlags)flagsInt; |
|||
return flags.HasFlag(DocumentContractFlags.VirtualDocument); |
|||
} |
|||
|
|||
return false; |
|||
} |
|||
|
|||
private Stream? GetVirtualFileStream(Context context, AndroidUri uri, bool isOutput) |
|||
{ |
|||
var mimeTypes = context.ContentResolver?.GetStreamTypes(uri, FilePickerFileTypes.All.MimeTypes![0]); |
|||
if (mimeTypes?.Length >= 1) |
|||
{ |
|||
var mimeType = mimeTypes[0]; |
|||
var asset = context.ContentResolver! |
|||
.OpenTypedAssetFileDescriptor(uri, mimeType, null); |
|||
|
|||
var stream = isOutput |
|||
? asset?.CreateOutputStream() |
|||
: asset?.CreateInputStream(); |
|||
|
|||
return stream; |
|||
} |
|||
|
|||
return null; |
|||
} |
|||
|
|||
public override Task<StorageItemProperties> GetBasicPropertiesAsync() |
|||
{ |
|||
ulong? size = null; |
|||
DateTimeOffset? itemDate = null; |
|||
DateTimeOffset? dateModified = null; |
|||
|
|||
try |
|||
{ |
|||
var projection = new[] |
|||
{ |
|||
MediaStore.IMediaColumns.Size, MediaStore.IMediaColumns.DateAdded, |
|||
MediaStore.IMediaColumns.DateModified |
|||
}; |
|||
using var cursor = Context.ContentResolver!.Query(Uri, projection, null, null, null); |
|||
|
|||
if (cursor?.MoveToFirst() == true) |
|||
{ |
|||
try |
|||
{ |
|||
var columnIndex = cursor.GetColumnIndex(MediaStore.IMediaColumns.Size); |
|||
if (columnIndex != -1) |
|||
{ |
|||
size = (ulong)cursor.GetLong(columnIndex); |
|||
} |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)? |
|||
.Log(this, "File Size metadata reader failed: '{Exception}'", ex); |
|||
} |
|||
|
|||
try |
|||
{ |
|||
var columnIndex = cursor.GetColumnIndex(MediaStore.IMediaColumns.DateAdded); |
|||
if (columnIndex != -1) |
|||
{ |
|||
var longValue = cursor.GetLong(columnIndex); |
|||
itemDate = longValue > 0 ? DateTimeOffset.FromUnixTimeMilliseconds(longValue) : null; |
|||
} |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)? |
|||
.Log(this, "File DateAdded metadata reader failed: '{Exception}'", ex); |
|||
} |
|||
|
|||
try |
|||
{ |
|||
var columnIndex = cursor.GetColumnIndex(MediaStore.IMediaColumns.DateModified); |
|||
if (columnIndex != -1) |
|||
{ |
|||
var longValue = cursor.GetLong(columnIndex); |
|||
dateModified = longValue > 0 ? DateTimeOffset.FromUnixTimeMilliseconds(longValue) : null; |
|||
} |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)? |
|||
.Log(this, "File DateAdded metadata reader failed: '{Exception}'", ex); |
|||
} |
|||
} |
|||
} |
|||
catch (UnsupportedOperationException) |
|||
{ |
|||
// It's not possible to get parameters of some files/folders.
|
|||
} |
|||
|
|||
return Task.FromResult(new StorageItemProperties(size, itemDate, dateModified)); |
|||
} |
|||
} |
|||
@ -0,0 +1,177 @@ |
|||
#nullable enable |
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using System.Threading.Tasks; |
|||
using Android.App; |
|||
using Android.Content; |
|||
using Android.Provider; |
|||
using Avalonia.Platform.Storage; |
|||
using AndroidUri = Android.Net.Uri; |
|||
|
|||
namespace Avalonia.Android.Platform.Storage; |
|||
|
|||
internal class AndroidStorageProvider : IStorageProvider |
|||
{ |
|||
private readonly AvaloniaActivity _activity; |
|||
private int _lastRequestCode = 20000; |
|||
|
|||
public AndroidStorageProvider(AvaloniaActivity activity) |
|||
{ |
|||
_activity = activity; |
|||
} |
|||
|
|||
public bool CanOpen => OperatingSystem.IsAndroidVersionAtLeast(19); |
|||
|
|||
public bool CanSave => OperatingSystem.IsAndroidVersionAtLeast(19); |
|||
|
|||
public bool CanPickFolder => OperatingSystem.IsAndroidVersionAtLeast(21); |
|||
|
|||
public Task<IStorageBookmarkFolder?> OpenFolderBookmarkAsync(string bookmark) |
|||
{ |
|||
var uri = AndroidUri.Parse(bookmark) ?? throw new ArgumentException("Couldn't parse Bookmark value", nameof(bookmark)); |
|||
return Task.FromResult<IStorageBookmarkFolder?>(new AndroidStorageFolder(_activity, uri)); |
|||
} |
|||
|
|||
public Task<IStorageBookmarkFile?> OpenFileBookmarkAsync(string bookmark) |
|||
{ |
|||
var uri = AndroidUri.Parse(bookmark) ?? throw new ArgumentException("Couldn't parse Bookmark value", nameof(bookmark)); |
|||
return Task.FromResult<IStorageBookmarkFile?>(new AndroidStorageFile(_activity, uri)); |
|||
} |
|||
|
|||
public async Task<IReadOnlyList<IStorageFile>> OpenFilePickerAsync(FilePickerOpenOptions options) |
|||
{ |
|||
var mimeTypes = options.FileTypeFilter?.Where(t => t != FilePickerFileTypes.All) |
|||
.SelectMany(f => f.MimeTypes ?? Array.Empty<string>()).Distinct().ToArray() ?? Array.Empty<string>(); |
|||
|
|||
var intent = new Intent(Intent.ActionOpenDocument) |
|||
.AddCategory(Intent.CategoryOpenable) |
|||
.PutExtra(Intent.ExtraAllowMultiple, options.AllowMultiple) |
|||
.SetType(FilePickerFileTypes.All.MimeTypes![0]); |
|||
if (mimeTypes.Length > 0) |
|||
{ |
|||
intent = intent.PutExtra(Intent.ExtraMimeTypes, mimeTypes); |
|||
} |
|||
|
|||
if (TryGetInitialUri(options.SuggestedStartLocation) is { } initialUri) |
|||
{ |
|||
intent = intent.PutExtra(DocumentsContract.ExtraInitialUri, initialUri); |
|||
} |
|||
|
|||
var pickerIntent = Intent.CreateChooser(intent, options.Title ?? "Select file"); |
|||
|
|||
var uris = await StartActivity(pickerIntent, false); |
|||
return uris.Select(u => new AndroidStorageFile(_activity, u)).ToArray(); |
|||
} |
|||
|
|||
public async Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options) |
|||
{ |
|||
var mimeTypes = options.FileTypeChoices?.Where(t => t != FilePickerFileTypes.All) |
|||
.SelectMany(f => f.MimeTypes ?? Array.Empty<string>()).Distinct().ToArray() ?? Array.Empty<string>(); |
|||
|
|||
var intent = new Intent(Intent.ActionCreateDocument) |
|||
.AddCategory(Intent.CategoryOpenable) |
|||
.SetType(FilePickerFileTypes.All.MimeTypes![0]); |
|||
if (mimeTypes.Length > 0) |
|||
{ |
|||
intent = intent.PutExtra(Intent.ExtraMimeTypes, mimeTypes); |
|||
} |
|||
|
|||
if (options.SuggestedFileName is { } fileName) |
|||
{ |
|||
if (options.DefaultExtension is { } ext) |
|||
{ |
|||
fileName += ext.StartsWith('.') ? ext : "." + ext; |
|||
} |
|||
intent = intent.PutExtra(Intent.ExtraTitle, fileName); |
|||
} |
|||
|
|||
if (TryGetInitialUri(options.SuggestedStartLocation) is { } initialUri) |
|||
{ |
|||
intent = intent.PutExtra(DocumentsContract.ExtraInitialUri, initialUri); |
|||
} |
|||
|
|||
var pickerIntent = Intent.CreateChooser(intent, options.Title ?? "Save file"); |
|||
|
|||
var uris = await StartActivity(pickerIntent, true); |
|||
return uris.Select(u => new AndroidStorageFile(_activity, u)).FirstOrDefault(); |
|||
} |
|||
|
|||
public async Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options) |
|||
{ |
|||
var intent = new Intent(Intent.ActionOpenDocumentTree) |
|||
.PutExtra(Intent.ExtraAllowMultiple, options.AllowMultiple); |
|||
if (TryGetInitialUri(options.SuggestedStartLocation) is { } initialUri) |
|||
{ |
|||
intent = intent.PutExtra(DocumentsContract.ExtraInitialUri, initialUri); |
|||
} |
|||
|
|||
var pickerIntent = Intent.CreateChooser(intent, options.Title ?? "Select folder"); |
|||
|
|||
var uris = await StartActivity(pickerIntent, false); |
|||
return uris.Select(u => new AndroidStorageFolder(_activity, u)).ToArray(); |
|||
} |
|||
|
|||
private async Task<List<AndroidUri>> StartActivity(Intent? pickerIntent, bool singleResult) |
|||
{ |
|||
var resultList = new List<AndroidUri>(1); |
|||
var tcs = new TaskCompletionSource<Intent?>(); |
|||
var currentRequestCode = _lastRequestCode++; |
|||
|
|||
_activity.ActivityResult += OnActivityResult; |
|||
_activity.StartActivityForResult(pickerIntent, currentRequestCode); |
|||
|
|||
var result = await tcs.Task; |
|||
|
|||
if (result != null) |
|||
{ |
|||
// ClipData first to avoid issue with multiple files selection.
|
|||
if (!singleResult && result.ClipData is { } clipData) |
|||
{ |
|||
for (var i = 0; i < clipData.ItemCount; i++) |
|||
{ |
|||
var uri = clipData.GetItemAt(i)?.Uri; |
|||
if (uri != null) |
|||
{ |
|||
resultList.Add(uri); |
|||
} |
|||
} |
|||
} |
|||
else if (result.Data is { } uri) |
|||
{ |
|||
resultList.Add(uri); |
|||
} |
|||
} |
|||
|
|||
if (result?.HasExtra("error") == true) |
|||
{ |
|||
throw new Exception(result.GetStringExtra("error")); |
|||
} |
|||
|
|||
return resultList; |
|||
|
|||
void OnActivityResult(int requestCode, Result resultCode, Intent data) |
|||
{ |
|||
if (currentRequestCode != requestCode) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
_activity.ActivityResult -= OnActivityResult; |
|||
|
|||
_ = tcs.TrySetResult(resultCode == Result.Ok ? data : null); |
|||
} |
|||
} |
|||
|
|||
private static AndroidUri? TryGetInitialUri(IStorageFolder? folder) |
|||
{ |
|||
if (OperatingSystem.IsAndroidVersionAtLeast(26) |
|||
&& (folder as AndroidStorageItem)?.Uri is { } uri) |
|||
{ |
|||
return uri; |
|||
} |
|||
|
|||
return null; |
|||
} |
|||
} |
|||
@ -1,20 +0,0 @@ |
|||
using System; |
|||
using System.Threading.Tasks; |
|||
using Avalonia.Controls; |
|||
using Avalonia.Controls.Platform; |
|||
|
|||
namespace Avalonia.Android |
|||
{ |
|||
internal class SystemDialogImpl : ISystemDialogImpl |
|||
{ |
|||
public Task<string[]> ShowFileDialogAsync(FileDialog dialog, Window parent) |
|||
{ |
|||
throw new NotImplementedException(); |
|||
} |
|||
|
|||
public Task<string> ShowFolderDialogAsync(OpenFolderDialog dialog, Window parent) |
|||
{ |
|||
throw new NotImplementedException(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,14 @@ |
|||
namespace Avalonia.Controls |
|||
{ |
|||
/// <summary>
|
|||
/// Internal interface for listening to changes in <see cref="Classes"/> in a more
|
|||
/// performant manner than subscribing to CollectionChanged.
|
|||
/// </summary>
|
|||
internal interface IClassesChangedListener |
|||
{ |
|||
/// <summary>
|
|||
/// Notifies the listener that the <see cref="Classes"/> collection has changed.
|
|||
/// </summary>
|
|||
void Changed(); |
|||
} |
|||
} |
|||
@ -0,0 +1,107 @@ |
|||
using System; |
|||
using System.Diagnostics.CodeAnalysis; |
|||
using System.IO; |
|||
using System.Security; |
|||
using System.Threading.Tasks; |
|||
using Avalonia.Metadata; |
|||
|
|||
namespace Avalonia.Platform.Storage.FileIO; |
|||
|
|||
[Unstable] |
|||
public class BclStorageFile : IStorageBookmarkFile |
|||
{ |
|||
private readonly FileInfo _fileInfo; |
|||
|
|||
public BclStorageFile(FileInfo fileInfo) |
|||
{ |
|||
_fileInfo = fileInfo ?? throw new ArgumentNullException(nameof(fileInfo)); |
|||
} |
|||
|
|||
public bool CanOpenRead => true; |
|||
|
|||
public bool CanOpenWrite => true; |
|||
|
|||
public string Name => _fileInfo.Name; |
|||
|
|||
public virtual bool CanBookmark => true; |
|||
|
|||
public Task<StorageItemProperties> GetBasicPropertiesAsync() |
|||
{ |
|||
var props = new StorageItemProperties(); |
|||
if (_fileInfo.Exists) |
|||
{ |
|||
props = new StorageItemProperties( |
|||
(ulong)_fileInfo.Length, |
|||
_fileInfo.CreationTimeUtc, |
|||
_fileInfo.LastAccessTimeUtc); |
|||
} |
|||
return Task.FromResult(props); |
|||
} |
|||
|
|||
public Task<IStorageFolder?> GetParentAsync() |
|||
{ |
|||
if (_fileInfo.Directory is { } directory) |
|||
{ |
|||
return Task.FromResult<IStorageFolder?>(new BclStorageFolder(directory)); |
|||
} |
|||
return Task.FromResult<IStorageFolder?>(null); |
|||
} |
|||
|
|||
public Task<Stream> OpenRead() |
|||
{ |
|||
return Task.FromResult<Stream>(_fileInfo.OpenRead()); |
|||
} |
|||
|
|||
public Task<Stream> OpenWrite() |
|||
{ |
|||
return Task.FromResult<Stream>(_fileInfo.OpenWrite()); |
|||
} |
|||
|
|||
public virtual Task<string?> SaveBookmark() |
|||
{ |
|||
return Task.FromResult<string?>(_fileInfo.FullName); |
|||
} |
|||
|
|||
public Task ReleaseBookmark() |
|||
{ |
|||
// No-op
|
|||
return Task.CompletedTask; |
|||
} |
|||
|
|||
public bool TryGetUri([NotNullWhen(true)] out Uri? uri) |
|||
{ |
|||
try |
|||
{ |
|||
if (_fileInfo.Directory is not null) |
|||
{ |
|||
uri = Path.IsPathRooted(_fileInfo.FullName) ? |
|||
new Uri(new Uri("file://"), _fileInfo.FullName) : |
|||
new Uri(_fileInfo.FullName, UriKind.Relative); |
|||
return true; |
|||
} |
|||
|
|||
uri = null; |
|||
return false; |
|||
} |
|||
catch (SecurityException) |
|||
{ |
|||
uri = null; |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
protected virtual void Dispose(bool disposing) |
|||
{ |
|||
} |
|||
|
|||
~BclStorageFile() |
|||
{ |
|||
Dispose(disposing: false); |
|||
} |
|||
|
|||
public void Dispose() |
|||
{ |
|||
Dispose(disposing: true); |
|||
GC.SuppressFinalize(this); |
|||
} |
|||
} |
|||
@ -0,0 +1,88 @@ |
|||
using System; |
|||
using System.Diagnostics.CodeAnalysis; |
|||
using System.IO; |
|||
using System.Security; |
|||
using System.Threading.Tasks; |
|||
using Avalonia.Metadata; |
|||
|
|||
namespace Avalonia.Platform.Storage.FileIO; |
|||
|
|||
[Unstable] |
|||
public class BclStorageFolder : IStorageBookmarkFolder |
|||
{ |
|||
private readonly DirectoryInfo _directoryInfo; |
|||
|
|||
public BclStorageFolder(DirectoryInfo directoryInfo) |
|||
{ |
|||
_directoryInfo = directoryInfo ?? throw new ArgumentNullException(nameof(directoryInfo)); |
|||
if (!_directoryInfo.Exists) |
|||
{ |
|||
throw new ArgumentException("Directory must exist", nameof(directoryInfo)); |
|||
} |
|||
} |
|||
|
|||
public string Name => _directoryInfo.Name; |
|||
|
|||
public bool CanBookmark => true; |
|||
|
|||
public Task<StorageItemProperties> GetBasicPropertiesAsync() |
|||
{ |
|||
var props = new StorageItemProperties( |
|||
null, |
|||
_directoryInfo.CreationTimeUtc, |
|||
_directoryInfo.LastAccessTimeUtc); |
|||
return Task.FromResult(props); |
|||
} |
|||
|
|||
public Task<IStorageFolder?> GetParentAsync() |
|||
{ |
|||
if (_directoryInfo.Parent is { } directory) |
|||
{ |
|||
return Task.FromResult<IStorageFolder?>(new BclStorageFolder(directory)); |
|||
} |
|||
return Task.FromResult<IStorageFolder?>(null); |
|||
} |
|||
|
|||
public virtual Task<string?> SaveBookmark() |
|||
{ |
|||
return Task.FromResult<string?>(_directoryInfo.FullName); |
|||
} |
|||
|
|||
public Task ReleaseBookmark() |
|||
{ |
|||
// No-op
|
|||
return Task.CompletedTask; |
|||
} |
|||
|
|||
public bool TryGetUri([NotNullWhen(true)] out Uri? uri) |
|||
{ |
|||
try |
|||
{ |
|||
uri = Path.IsPathRooted(_directoryInfo.FullName) ? |
|||
new Uri(new Uri("file://"), _directoryInfo.FullName) : |
|||
new Uri(_directoryInfo.FullName, UriKind.Relative); |
|||
|
|||
return true; |
|||
} |
|||
catch (SecurityException) |
|||
{ |
|||
uri = null; |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
protected virtual void Dispose(bool disposing) |
|||
{ |
|||
} |
|||
|
|||
~BclStorageFolder() |
|||
{ |
|||
Dispose(disposing: false); |
|||
} |
|||
|
|||
public void Dispose() |
|||
{ |
|||
Dispose(disposing: true); |
|||
GC.SuppressFinalize(this); |
|||
} |
|||
} |
|||
@ -0,0 +1,35 @@ |
|||
using System.Collections.Generic; |
|||
using System.IO; |
|||
using System.Threading.Tasks; |
|||
using Avalonia.Metadata; |
|||
|
|||
namespace Avalonia.Platform.Storage.FileIO; |
|||
|
|||
[Unstable] |
|||
public abstract class BclStorageProvider : IStorageProvider |
|||
{ |
|||
public abstract bool CanOpen { get; } |
|||
public abstract Task<IReadOnlyList<IStorageFile>> OpenFilePickerAsync(FilePickerOpenOptions options); |
|||
|
|||
public abstract bool CanSave { get; } |
|||
public abstract Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options); |
|||
|
|||
public abstract bool CanPickFolder { get; } |
|||
public abstract Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options); |
|||
|
|||
public virtual Task<IStorageBookmarkFile?> OpenFileBookmarkAsync(string bookmark) |
|||
{ |
|||
var file = new FileInfo(bookmark); |
|||
return file.Exists |
|||
? Task.FromResult<IStorageBookmarkFile?>(new BclStorageFile(file)) |
|||
: Task.FromResult<IStorageBookmarkFile?>(null); |
|||
} |
|||
|
|||
public virtual Task<IStorageBookmarkFolder?> OpenFolderBookmarkAsync(string bookmark) |
|||
{ |
|||
var folder = new DirectoryInfo(bookmark); |
|||
return folder.Exists |
|||
? Task.FromResult<IStorageBookmarkFolder?>(new BclStorageFolder(folder)) |
|||
: Task.FromResult<IStorageBookmarkFolder?>(null); |
|||
} |
|||
} |
|||
@ -0,0 +1,40 @@ |
|||
using System; |
|||
using System.IO; |
|||
using System.Linq; |
|||
using Avalonia.Metadata; |
|||
|
|||
namespace Avalonia.Platform.Storage.FileIO; |
|||
|
|||
[Unstable] |
|||
public static class StorageProviderHelpers |
|||
{ |
|||
public static string NameWithExtension(string path, string? defaultExtension, FilePickerFileType? filter) |
|||
{ |
|||
var name = Path.GetFileName(path); |
|||
if (name != null && !Path.HasExtension(name)) |
|||
{ |
|||
if (filter?.Patterns?.Count > 0) |
|||
{ |
|||
if (defaultExtension != null |
|||
&& filter.Patterns.Contains(defaultExtension)) |
|||
{ |
|||
return Path.ChangeExtension(path, defaultExtension.TrimStart('.')); |
|||
} |
|||
|
|||
var ext = filter.Patterns.FirstOrDefault(x => x != "*.*"); |
|||
ext = ext?.Split(new[] { "*." }, StringSplitOptions.RemoveEmptyEntries).LastOrDefault(); |
|||
if (ext != null) |
|||
{ |
|||
return Path.ChangeExtension(path, ext); |
|||
} |
|||
} |
|||
|
|||
if (defaultExtension != null) |
|||
{ |
|||
return Path.ChangeExtension(path, defaultExtension); |
|||
} |
|||
} |
|||
|
|||
return path; |
|||
} |
|||
} |
|||
@ -0,0 +1,44 @@ |
|||
using System.Collections.Generic; |
|||
|
|||
namespace Avalonia.Platform.Storage; |
|||
|
|||
/// <summary>
|
|||
/// Represents a name mapped to the associated file types (extensions).
|
|||
/// </summary>
|
|||
public sealed class FilePickerFileType |
|||
{ |
|||
public FilePickerFileType(string name) |
|||
{ |
|||
Name = name; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// File type name.
|
|||
/// </summary>
|
|||
public string Name { get; } |
|||
|
|||
/// <summary>
|
|||
/// List of extensions in GLOB format. I.e. "*.png" or "*.*".
|
|||
/// </summary>
|
|||
/// <remarks>
|
|||
/// Used on Windows and Linux systems.
|
|||
/// </remarks>
|
|||
public IReadOnlyList<string>? Patterns { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// List of extensions in MIME format.
|
|||
/// </summary>
|
|||
/// <remarks>
|
|||
/// Used on Android, Browser and Linux systems.
|
|||
/// </remarks>
|
|||
public IReadOnlyList<string>? MimeTypes { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// List of extensions in Apple uniform format.
|
|||
/// </summary>
|
|||
/// <remarks>
|
|||
/// Used only on Apple devices.
|
|||
/// See https://developer.apple.com/documentation/uniformtypeidentifiers/system_declared_uniform_type_identifiers.
|
|||
/// </remarks>
|
|||
public IReadOnlyList<string>? AppleUniformTypeIdentifiers { get; set; } |
|||
} |
|||
@ -0,0 +1,48 @@ |
|||
namespace Avalonia.Platform.Storage; |
|||
|
|||
/// <summary>
|
|||
/// Dictionary of well known file types.
|
|||
/// </summary>
|
|||
public static class FilePickerFileTypes |
|||
{ |
|||
public static FilePickerFileType All { get; } = new("All") |
|||
{ |
|||
Patterns = new[] { "*.*" }, |
|||
MimeTypes = new[] { "*/*" } |
|||
}; |
|||
|
|||
public static FilePickerFileType TextPlain { get; } = new("Plain Text") |
|||
{ |
|||
Patterns = new[] { "*.txt" }, |
|||
AppleUniformTypeIdentifiers = new[] { "public.plain-text" }, |
|||
MimeTypes = new[] { "text/plain" } |
|||
}; |
|||
|
|||
public static FilePickerFileType ImageAll { get; } = new("All Images") |
|||
{ |
|||
Patterns = new[] { "*.png", "*.jpg", "*.jpeg", "*.gif", "*.bmp" }, |
|||
AppleUniformTypeIdentifiers = new[] { "public.image" }, |
|||
MimeTypes = new[] { "image/*" } |
|||
}; |
|||
|
|||
public static FilePickerFileType ImageJpg { get; } = new("JPEG image") |
|||
{ |
|||
Patterns = new[] { "*.jpg", "*.jpeg" }, |
|||
AppleUniformTypeIdentifiers = new[] { "public.jpeg" }, |
|||
MimeTypes = new[] { "image/jpeg" } |
|||
}; |
|||
|
|||
public static FilePickerFileType ImagePng { get; } = new("PNG image") |
|||
{ |
|||
Patterns = new[] { "*.png" }, |
|||
AppleUniformTypeIdentifiers = new[] { "public.png" }, |
|||
MimeTypes = new[] { "image/png" } |
|||
}; |
|||
|
|||
public static FilePickerFileType Pdf { get; } = new("PDF document") |
|||
{ |
|||
Patterns = new[] { "*.pdf" }, |
|||
AppleUniformTypeIdentifiers = new[] { "com.adobe.pdf" }, |
|||
MimeTypes = new[] { "application/pdf" } |
|||
}; |
|||
} |
|||
@ -0,0 +1,19 @@ |
|||
using System.Collections.Generic; |
|||
|
|||
namespace Avalonia.Platform.Storage; |
|||
|
|||
/// <summary>
|
|||
/// Options class for <see cref="IStorageProvider.OpenFilePickerAsync"/> method.
|
|||
/// </summary>
|
|||
public class FilePickerOpenOptions : PickerOptions |
|||
{ |
|||
/// <summary>
|
|||
/// Gets or sets an option indicating whether open picker allows users to select multiple files.
|
|||
/// </summary>
|
|||
public bool AllowMultiple { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// Gets or sets the collection of file types that the file open picker displays.
|
|||
/// </summary>
|
|||
public IReadOnlyList<FilePickerFileType>? FileTypeFilter { get; set; } |
|||
} |
|||
@ -0,0 +1,29 @@ |
|||
using System.Collections.Generic; |
|||
|
|||
namespace Avalonia.Platform.Storage; |
|||
|
|||
/// <summary>
|
|||
/// Options class for <see cref="IStorageProvider.SaveFilePickerAsync"/> method.
|
|||
/// </summary>
|
|||
public class FilePickerSaveOptions : PickerOptions |
|||
{ |
|||
/// <summary>
|
|||
/// Gets or sets the file name that the file save picker suggests to the user.
|
|||
/// </summary>
|
|||
public string? SuggestedFileName { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// Gets or sets the default extension to be used to save the file.
|
|||
/// </summary>
|
|||
public string? DefaultExtension { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// Gets or sets the collection of valid file types that the user can choose to assign to a file.
|
|||
/// </summary>
|
|||
public IReadOnlyList<FilePickerFileType>? FileTypeChoices { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// Gets or sets a value indicating whether file open picker displays a warning if the user specifies the name of a file that already exists.
|
|||
/// </summary>
|
|||
public bool? ShowOverwritePrompt { get; set; } |
|||
} |
|||
@ -0,0 +1,12 @@ |
|||
namespace Avalonia.Platform.Storage; |
|||
|
|||
/// <summary>
|
|||
/// Options class for <see cref="IStorageProvider.OpenFolderPickerAsync"/> method.
|
|||
/// </summary>
|
|||
public class FolderPickerOpenOptions : PickerOptions |
|||
{ |
|||
/// <summary>
|
|||
/// Gets or sets an option indicating whether open picker allows users to select multiple folders.
|
|||
/// </summary>
|
|||
public bool AllowMultiple { get; set; } |
|||
} |
|||
@ -0,0 +1,20 @@ |
|||
using System.Threading.Tasks; |
|||
using Avalonia.Metadata; |
|||
|
|||
namespace Avalonia.Platform.Storage; |
|||
|
|||
[NotClientImplementable] |
|||
public interface IStorageBookmarkItem : IStorageItem |
|||
{ |
|||
Task ReleaseBookmark(); |
|||
} |
|||
|
|||
[NotClientImplementable] |
|||
public interface IStorageBookmarkFile : IStorageFile, IStorageBookmarkItem |
|||
{ |
|||
} |
|||
|
|||
[NotClientImplementable] |
|||
public interface IStorageBookmarkFolder : IStorageFolder, IStorageBookmarkItem |
|||
{ |
|||
} |
|||
@ -0,0 +1,32 @@ |
|||
using System.IO; |
|||
using System.Threading.Tasks; |
|||
using Avalonia.Metadata; |
|||
|
|||
namespace Avalonia.Platform.Storage; |
|||
|
|||
/// <summary>
|
|||
/// Represents a file. Provides information about the file and its contents, and ways to manipulate them.
|
|||
/// </summary>
|
|||
[NotClientImplementable] |
|||
public interface IStorageFile : IStorageItem |
|||
{ |
|||
/// <summary>
|
|||
/// Returns true, if file is readable.
|
|||
/// </summary>
|
|||
bool CanOpenRead { get; } |
|||
|
|||
/// <summary>
|
|||
/// Opens a stream for read access.
|
|||
/// </summary>
|
|||
Task<Stream> OpenRead(); |
|||
|
|||
/// <summary>
|
|||
/// Returns true, if file is writeable.
|
|||
/// </summary>
|
|||
bool CanOpenWrite { get; } |
|||
|
|||
/// <summary>
|
|||
/// Opens stream for writing to the file.
|
|||
/// </summary>
|
|||
Task<Stream> OpenWrite(); |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
using Avalonia.Metadata; |
|||
|
|||
namespace Avalonia.Platform.Storage; |
|||
|
|||
/// <summary>
|
|||
/// Manipulates folders and their contents, and provides information about them.
|
|||
/// </summary>
|
|||
[NotClientImplementable] |
|||
public interface IStorageFolder : IStorageItem |
|||
{ |
|||
} |
|||
@ -0,0 +1,53 @@ |
|||
using System; |
|||
using System.Diagnostics.CodeAnalysis; |
|||
using System.Threading.Tasks; |
|||
using Avalonia.Metadata; |
|||
|
|||
namespace Avalonia.Platform.Storage; |
|||
|
|||
/// <summary>
|
|||
/// Manipulates storage items (files and folders) and their contents, and provides information about them
|
|||
/// </summary>
|
|||
/// <remarks>
|
|||
/// This interface inherits <see cref="IDisposable"/> . It's recommended to dispose <see cref="IStorageItem"/> when it's not used anymore.
|
|||
/// </remarks>
|
|||
[NotClientImplementable] |
|||
public interface IStorageItem : IDisposable |
|||
{ |
|||
/// <summary>
|
|||
/// Gets the name of the item including the file name extension if there is one.
|
|||
/// </summary>
|
|||
string Name { get; } |
|||
|
|||
/// <summary>
|
|||
/// Gets the full file-system path of the item, if the item has a path.
|
|||
/// </summary>
|
|||
/// <remarks>
|
|||
/// Android backend might return file path with "content:" scheme.
|
|||
/// Browser and iOS backends might return relative uris.
|
|||
/// </remarks>
|
|||
bool TryGetUri([NotNullWhen(true)] out Uri? uri); |
|||
|
|||
/// <summary>
|
|||
/// Gets the basic properties of the current item.
|
|||
/// </summary>
|
|||
Task<StorageItemProperties> GetBasicPropertiesAsync(); |
|||
|
|||
/// <summary>
|
|||
/// Returns true is item can be bookmarked and reused later.
|
|||
/// </summary>
|
|||
bool CanBookmark { get; } |
|||
|
|||
/// <summary>
|
|||
/// Saves items to a bookmark.
|
|||
/// </summary>
|
|||
/// <returns>
|
|||
/// Returns identifier of a bookmark. Can be null if OS denied request.
|
|||
/// </returns>
|
|||
Task<string?> SaveBookmark(); |
|||
|
|||
/// <summary>
|
|||
/// Gets the parent folder of the current storage item.
|
|||
/// </summary>
|
|||
Task<IStorageFolder?> GetParentAsync(); |
|||
} |
|||
@ -0,0 +1,56 @@ |
|||
using System.Collections.Generic; |
|||
using System.Threading.Tasks; |
|||
using Avalonia.Metadata; |
|||
|
|||
namespace Avalonia.Platform.Storage; |
|||
|
|||
[NotClientImplementable] |
|||
public interface IStorageProvider |
|||
{ |
|||
/// <summary>
|
|||
/// Returns true if it's possible to open file picker on the current platform.
|
|||
/// </summary>
|
|||
bool CanOpen { get; } |
|||
|
|||
/// <summary>
|
|||
/// Opens file picker dialog.
|
|||
/// </summary>
|
|||
/// <returns>Array of selected <see cref="IStorageFile"/> or empty collection if user canceled the dialog.</returns>
|
|||
Task<IReadOnlyList<IStorageFile>> OpenFilePickerAsync(FilePickerOpenOptions options); |
|||
|
|||
/// <summary>
|
|||
/// Returns true if it's possible to open save file picker on the current platform.
|
|||
/// </summary>
|
|||
bool CanSave { get; } |
|||
|
|||
/// <summary>
|
|||
/// Opens save file picker dialog.
|
|||
/// </summary>
|
|||
/// <returns>Saved <see cref="IStorageFile"/> or null if user canceled the dialog.</returns>
|
|||
Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options); |
|||
|
|||
/// <summary>
|
|||
/// Returns true if it's possible to open folder picker on the current platform.
|
|||
/// </summary>
|
|||
bool CanPickFolder { get; } |
|||
|
|||
/// <summary>
|
|||
/// Opens folder picker dialog.
|
|||
/// </summary>
|
|||
/// <returns>Array of selected <see cref="IStorageFolder"/> or empty collection if user canceled the dialog.</returns>
|
|||
Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options); |
|||
|
|||
/// <summary>
|
|||
/// Open <see cref="IStorageBookmarkFile"/> from the bookmark ID.
|
|||
/// </summary>
|
|||
/// <param name="bookmark">Bookmark ID.</param>
|
|||
/// <returns>Bookmarked file or null if OS denied request.</returns>
|
|||
Task<IStorageBookmarkFile?> OpenFileBookmarkAsync(string bookmark); |
|||
|
|||
/// <summary>
|
|||
/// Open <see cref="IStorageBookmarkFolder"/> from the bookmark ID.
|
|||
/// </summary>
|
|||
/// <param name="bookmark">Bookmark ID.</param>
|
|||
/// <returns>Bookmarked folder or null if OS denied request.</returns>
|
|||
Task<IStorageBookmarkFolder?> OpenFolderBookmarkAsync(string bookmark); |
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
namespace Avalonia.Platform.Storage; |
|||
|
|||
/// <summary>
|
|||
/// Common options for <see cref="IStorageProvider.OpenFolderPickerAsync"/>, <see cref="IStorageProvider.OpenFilePickerAsync"/> and <see cref="IStorageProvider.SaveFilePickerAsync"/> methods.
|
|||
/// </summary>
|
|||
public class PickerOptions |
|||
{ |
|||
/// <summary>
|
|||
/// Gets or sets the text that appears in the title bar of a folder dialog.
|
|||
/// </summary>
|
|||
public string? Title { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// Gets or sets the initial location where the file open picker looks for files to present to the user.
|
|||
/// </summary>
|
|||
public IStorageFolder? SuggestedStartLocation { get; set; } |
|||
} |
|||
@ -0,0 +1,43 @@ |
|||
using System; |
|||
|
|||
namespace Avalonia.Platform.Storage; |
|||
|
|||
/// <summary>
|
|||
/// Provides access to the content-related properties of an item (like a file or folder).
|
|||
/// </summary>
|
|||
public class StorageItemProperties |
|||
{ |
|||
public StorageItemProperties( |
|||
ulong? size = null, |
|||
DateTimeOffset? dateCreated = null, |
|||
DateTimeOffset? dateModified = null) |
|||
{ |
|||
Size = size; |
|||
DateCreated = dateCreated; |
|||
DateModified = dateModified; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Gets the size of the file in bytes.
|
|||
/// </summary>
|
|||
/// <remarks>
|
|||
/// Can be null if property is not available.
|
|||
/// </remarks>
|
|||
public ulong? Size { get; } |
|||
|
|||
/// <summary>
|
|||
/// Gets the date and time that the current folder was created.
|
|||
/// </summary>
|
|||
/// <remarks>
|
|||
/// Can be null if property is not available.
|
|||
/// </remarks>
|
|||
public DateTimeOffset? DateCreated { get; } |
|||
|
|||
/// <summary>
|
|||
/// Gets the date and time of the last time the file was modified.
|
|||
/// </summary>
|
|||
/// <remarks>
|
|||
/// Can be null if property is not available.
|
|||
/// </remarks>
|
|||
public DateTimeOffset? DateModified { get; } |
|||
} |
|||
@ -0,0 +1,27 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Globalization; |
|||
using System.Linq; |
|||
using Avalonia.Data; |
|||
using Avalonia.Data.Converters; |
|||
|
|||
namespace Avalonia.Controls.Converters; |
|||
|
|||
/// <summary>
|
|||
/// Calls <see cref="string.Format(string, object[])"/> on the passed in values, where the first element in the list
|
|||
/// is the string, and everything after it is passed into the object array in order.
|
|||
/// </summary>
|
|||
public class StringFormatConverter : IMultiValueConverter |
|||
{ |
|||
public object? Convert(IList<object?> values, Type targetType, object? parameter, CultureInfo culture) |
|||
{ |
|||
try |
|||
{ |
|||
return string.Format((string)values[0]!, values.Skip(1).ToArray()); |
|||
} |
|||
catch (Exception e) |
|||
{ |
|||
return new BindingNotification(e, BindingErrorType.Error); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,12 @@ |
|||
#nullable enable |
|||
using Avalonia.Platform.Storage; |
|||
|
|||
namespace Avalonia.Controls.Platform; |
|||
|
|||
/// <summary>
|
|||
/// Factory allows to register custom storage provider instead of native implementation.
|
|||
/// </summary>
|
|||
public interface IStorageProviderFactory |
|||
{ |
|||
IStorageProvider CreateProvider(TopLevel topLevel); |
|||
} |
|||
@ -0,0 +1,74 @@ |
|||
using System; |
|||
using System.Linq; |
|||
using System.Threading.Tasks; |
|||
|
|||
#nullable enable |
|||
|
|||
namespace Avalonia.Controls.Platform |
|||
{ |
|||
/// <summary>
|
|||
/// Defines a platform-specific system dialog implementation.
|
|||
/// </summary>
|
|||
[Obsolete] |
|||
internal class SystemDialogImpl : ISystemDialogImpl |
|||
{ |
|||
public async Task<string[]?> ShowFileDialogAsync(FileDialog dialog, Window parent) |
|||
{ |
|||
if (dialog is OpenFileDialog openDialog) |
|||
{ |
|||
var filePicker = parent.StorageProvider; |
|||
if (!filePicker.CanOpen) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
var options = openDialog.ToFilePickerOpenOptions(); |
|||
|
|||
var files = await filePicker.OpenFilePickerAsync(options); |
|||
return files |
|||
.Select(file => file.TryGetUri(out var fullPath) |
|||
? fullPath.LocalPath |
|||
: file.Name) |
|||
.ToArray(); |
|||
} |
|||
else if (dialog is SaveFileDialog saveDialog) |
|||
{ |
|||
var filePicker = parent.StorageProvider; |
|||
if (!filePicker.CanSave) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
var options = saveDialog.ToFilePickerSaveOptions(); |
|||
|
|||
var file = await filePicker.SaveFilePickerAsync(options); |
|||
if (file is null) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
var filePath = file.TryGetUri(out var fullPath) |
|||
? fullPath.LocalPath |
|||
: file.Name; |
|||
return new[] { filePath }; |
|||
} |
|||
return null; |
|||
} |
|||
|
|||
public async Task<string?> ShowFolderDialogAsync(OpenFolderDialog dialog, Window parent) |
|||
{ |
|||
var filePicker = parent.StorageProvider; |
|||
if (!filePicker.CanPickFolder) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
var options = dialog.ToFolderPickerOpenOptions(); |
|||
|
|||
var folders = await filePicker.OpenFolderPickerAsync(options); |
|||
return folders |
|||
.Select(f => f.TryGetUri(out var uri) ? uri.LocalPath : null) |
|||
.FirstOrDefault(u => u is not null); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
using Avalonia.Metadata; |
|||
using Avalonia.Platform; |
|||
using Avalonia.Platform.Storage; |
|||
|
|||
namespace Avalonia.Controls.Platform; |
|||
|
|||
[Unstable] |
|||
public interface ITopLevelImplWithStorageProvider : ITopLevelImpl |
|||
{ |
|||
public IStorageProvider StorageProvider { get; } |
|||
} |
|||
@ -0,0 +1,574 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using Avalonia.Controls.Documents; |
|||
using Avalonia.Controls.Utils; |
|||
using Avalonia.Input; |
|||
using Avalonia.Input.Platform; |
|||
using Avalonia.Interactivity; |
|||
using Avalonia.Media; |
|||
using Avalonia.Media.TextFormatting; |
|||
using Avalonia.Metadata; |
|||
using Avalonia.Utilities; |
|||
|
|||
namespace Avalonia.Controls |
|||
{ |
|||
/// <summary>
|
|||
/// A control that displays a block of formatted text.
|
|||
/// </summary>
|
|||
public class RichTextBlock : TextBlock, IInlineHost |
|||
{ |
|||
public static readonly StyledProperty<bool> IsTextSelectionEnabledProperty = |
|||
AvaloniaProperty.Register<RichTextBlock, bool>(nameof(IsTextSelectionEnabled), false); |
|||
|
|||
public static readonly DirectProperty<RichTextBlock, int> SelectionStartProperty = |
|||
AvaloniaProperty.RegisterDirect<RichTextBlock, int>( |
|||
nameof(SelectionStart), |
|||
o => o.SelectionStart, |
|||
(o, v) => o.SelectionStart = v); |
|||
|
|||
public static readonly DirectProperty<RichTextBlock, int> SelectionEndProperty = |
|||
AvaloniaProperty.RegisterDirect<RichTextBlock, int>( |
|||
nameof(SelectionEnd), |
|||
o => o.SelectionEnd, |
|||
(o, v) => o.SelectionEnd = v); |
|||
|
|||
public static readonly DirectProperty<RichTextBlock, string> SelectedTextProperty = |
|||
AvaloniaProperty.RegisterDirect<RichTextBlock, string>( |
|||
nameof(SelectedText), |
|||
o => o.SelectedText); |
|||
|
|||
public static readonly StyledProperty<IBrush?> SelectionBrushProperty = |
|||
AvaloniaProperty.Register<RichTextBlock, IBrush?>(nameof(SelectionBrush), Brushes.Blue); |
|||
|
|||
public static readonly StyledProperty<IBrush?> SelectionForegroundBrushProperty = |
|||
AvaloniaProperty.Register<RichTextBlock, IBrush?>(nameof(SelectionForegroundBrush)); |
|||
|
|||
/// <summary>
|
|||
/// Defines the <see cref="Inlines"/> property.
|
|||
/// </summary>
|
|||
public static readonly StyledProperty<InlineCollection> InlinesProperty = |
|||
AvaloniaProperty.Register<RichTextBlock, InlineCollection>( |
|||
nameof(Inlines)); |
|||
|
|||
public static readonly DirectProperty<TextBox, bool> CanCopyProperty = |
|||
AvaloniaProperty.RegisterDirect<TextBox, bool>( |
|||
nameof(CanCopy), |
|||
o => o.CanCopy); |
|||
|
|||
public static readonly RoutedEvent<RoutedEventArgs> CopyingToClipboardEvent = |
|||
RoutedEvent.Register<RichTextBlock, RoutedEventArgs>( |
|||
nameof(CopyingToClipboard), RoutingStrategies.Bubble); |
|||
|
|||
private bool _canCopy; |
|||
private int _selectionStart; |
|||
private int _selectionEnd; |
|||
|
|||
static RichTextBlock() |
|||
{ |
|||
FocusableProperty.OverrideDefaultValue(typeof(RichTextBlock), true); |
|||
|
|||
AffectsRender<RichTextBlock>(SelectionStartProperty, SelectionEndProperty, SelectionForegroundBrushProperty, SelectionBrushProperty); |
|||
} |
|||
|
|||
public RichTextBlock() |
|||
{ |
|||
Inlines = new InlineCollection |
|||
{ |
|||
Parent = this, |
|||
InlineHost = this |
|||
}; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Gets or sets the brush that highlights selected text.
|
|||
/// </summary>
|
|||
public IBrush? SelectionBrush |
|||
{ |
|||
get => GetValue(SelectionBrushProperty); |
|||
set => SetValue(SelectionBrushProperty, value); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Gets or sets a value that defines the brush used for selected text.
|
|||
/// </summary>
|
|||
public IBrush? SelectionForegroundBrush |
|||
{ |
|||
get => GetValue(SelectionForegroundBrushProperty); |
|||
set => SetValue(SelectionForegroundBrushProperty, value); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Gets or sets a character index for the beginning of the current selection.
|
|||
/// </summary>
|
|||
public int SelectionStart |
|||
{ |
|||
get => _selectionStart; |
|||
set |
|||
{ |
|||
if (SetAndRaise(SelectionStartProperty, ref _selectionStart, value)) |
|||
{ |
|||
RaisePropertyChanged(SelectedTextProperty, "", ""); |
|||
} |
|||
} |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Gets or sets a character index for the end of the current selection.
|
|||
/// </summary>
|
|||
public int SelectionEnd |
|||
{ |
|||
get => _selectionEnd; |
|||
set |
|||
{ |
|||
if (SetAndRaise(SelectionEndProperty, ref _selectionEnd, value)) |
|||
{ |
|||
RaisePropertyChanged(SelectedTextProperty, "", ""); |
|||
} |
|||
} |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Gets the content of the current selection.
|
|||
/// </summary>
|
|||
public string SelectedText |
|||
{ |
|||
get => GetSelection(); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Gets or sets a value that indicates whether text selection is enabled, either through user action or calling selection-related API.
|
|||
/// </summary>
|
|||
public bool IsTextSelectionEnabled |
|||
{ |
|||
get => GetValue(IsTextSelectionEnabledProperty); |
|||
set => SetValue(IsTextSelectionEnabledProperty, value); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Gets or sets the inlines.
|
|||
/// </summary>
|
|||
[Content] |
|||
public InlineCollection Inlines |
|||
{ |
|||
get => GetValue(InlinesProperty); |
|||
set => SetValue(InlinesProperty, value); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Property for determining if the Copy command can be executed.
|
|||
/// </summary>
|
|||
public bool CanCopy |
|||
{ |
|||
get => _canCopy; |
|||
private set => SetAndRaise(CanCopyProperty, ref _canCopy, value); |
|||
} |
|||
|
|||
public event EventHandler<RoutedEventArgs>? CopyingToClipboard |
|||
{ |
|||
add => AddHandler(CopyingToClipboardEvent, value); |
|||
remove => RemoveHandler(CopyingToClipboardEvent, value); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Copies the current selection to the Clipboard.
|
|||
/// </summary>
|
|||
public async void Copy() |
|||
{ |
|||
if (_canCopy || !IsTextSelectionEnabled) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
var text = GetSelection(); |
|||
|
|||
if (string.IsNullOrEmpty(text)) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
var eventArgs = new RoutedEventArgs(CopyingToClipboardEvent); |
|||
|
|||
RaiseEvent(eventArgs); |
|||
|
|||
if (!eventArgs.Handled) |
|||
{ |
|||
await ((IClipboard)AvaloniaLocator.Current.GetRequiredService(typeof(IClipboard))) |
|||
.SetTextAsync(text); |
|||
} |
|||
} |
|||
|
|||
public override void Render(DrawingContext context) |
|||
{ |
|||
var selectionStart = SelectionStart; |
|||
var selectionEnd = SelectionEnd; |
|||
var selectionBrush = SelectionBrush; |
|||
|
|||
var selectionEnabled = IsTextSelectionEnabled; |
|||
|
|||
if (selectionEnabled && selectionStart != selectionEnd && selectionBrush != null) |
|||
{ |
|||
var start = Math.Min(selectionStart, selectionEnd); |
|||
var length = Math.Max(selectionStart, selectionEnd) - start; |
|||
|
|||
var rects = TextLayout.HitTestTextRange(start, length); |
|||
|
|||
foreach (var rect in rects) |
|||
{ |
|||
context.FillRectangle(selectionBrush, PixelRect.FromRect(rect, 1).ToRect(1)); |
|||
} |
|||
} |
|||
|
|||
base.Render(context); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Select all text in the TextBox
|
|||
/// </summary>
|
|||
public void SelectAll() |
|||
{ |
|||
if (!IsTextSelectionEnabled) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
var text = Text; |
|||
|
|||
SelectionStart = 0; |
|||
SelectionEnd = text?.Length ?? 0; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Clears the current selection/>
|
|||
/// </summary>
|
|||
public void ClearSelection() |
|||
{ |
|||
if (!IsTextSelectionEnabled) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
SelectionEnd = SelectionStart; |
|||
} |
|||
|
|||
protected override string? GetText() |
|||
{ |
|||
return _text ?? Inlines.Text; |
|||
} |
|||
|
|||
protected override void SetText(string? text) |
|||
{ |
|||
var oldValue = _text ?? Inlines?.Text; |
|||
|
|||
if (Inlines is not null && Inlines.HasComplexContent) |
|||
{ |
|||
Inlines.Text = text; |
|||
|
|||
_text = null; |
|||
} |
|||
else |
|||
{ |
|||
_text = text; |
|||
} |
|||
|
|||
RaisePropertyChanged(TextProperty, oldValue, text); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Creates the <see cref="TextLayout"/> used to render the text.
|
|||
/// </summary>
|
|||
/// <returns>A <see cref="TextLayout"/> object.</returns>
|
|||
protected override TextLayout CreateTextLayout(string? text) |
|||
{ |
|||
var defaultProperties = new GenericTextRunProperties( |
|||
new Typeface(FontFamily, FontStyle, FontWeight, FontStretch), |
|||
FontSize, |
|||
TextDecorations, |
|||
Foreground); |
|||
|
|||
var paragraphProperties = new GenericTextParagraphProperties(FlowDirection, TextAlignment, true, false, |
|||
defaultProperties, TextWrapping, LineHeight, 0); |
|||
|
|||
ITextSource textSource; |
|||
|
|||
var inlines = Inlines; |
|||
|
|||
if (inlines is not null && inlines.HasComplexContent) |
|||
{ |
|||
var textRuns = new List<TextRun>(); |
|||
|
|||
foreach (var inline in inlines) |
|||
{ |
|||
inline.BuildTextRun(textRuns); |
|||
} |
|||
|
|||
textSource = new InlinesTextSource(textRuns); |
|||
} |
|||
else |
|||
{ |
|||
textSource = new SimpleTextSource((text ?? "").AsMemory(), defaultProperties); |
|||
} |
|||
|
|||
return new TextLayout( |
|||
textSource, |
|||
paragraphProperties, |
|||
TextTrimming, |
|||
_constraint.Width, |
|||
_constraint.Height, |
|||
maxLines: MaxLines, |
|||
lineHeight: LineHeight); |
|||
} |
|||
|
|||
protected override void OnLostFocus(RoutedEventArgs e) |
|||
{ |
|||
base.OnLostFocus(e); |
|||
|
|||
ClearSelection(); |
|||
} |
|||
|
|||
protected override void OnKeyDown(KeyEventArgs e) |
|||
{ |
|||
var handled = false; |
|||
var modifiers = e.KeyModifiers; |
|||
var keymap = AvaloniaLocator.Current.GetRequiredService<PlatformHotkeyConfiguration>(); |
|||
|
|||
bool Match(List<KeyGesture> gestures) => gestures.Any(g => g.Matches(e)); |
|||
|
|||
if (Match(keymap.Copy)) |
|||
{ |
|||
Copy(); |
|||
|
|||
handled = true; |
|||
} |
|||
|
|||
e.Handled = handled; |
|||
} |
|||
|
|||
protected override void OnPointerPressed(PointerPressedEventArgs e) |
|||
{ |
|||
if (!IsTextSelectionEnabled) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
var text = Text; |
|||
var clickInfo = e.GetCurrentPoint(this); |
|||
|
|||
if (text != null && clickInfo.Properties.IsLeftButtonPressed) |
|||
{ |
|||
var point = e.GetPosition(this); |
|||
|
|||
var clickToSelect = e.KeyModifiers.HasFlag(KeyModifiers.Shift); |
|||
|
|||
var oldIndex = SelectionStart; |
|||
|
|||
var hit = TextLayout.HitTestPoint(point); |
|||
var index = hit.TextPosition; |
|||
|
|||
SelectionStart = SelectionEnd = index; |
|||
|
|||
#pragma warning disable CS0618 // Type or member is obsolete
|
|||
switch (e.ClickCount) |
|||
#pragma warning restore CS0618 // Type or member is obsolete
|
|||
{ |
|||
case 1: |
|||
if (clickToSelect) |
|||
{ |
|||
SelectionStart = Math.Min(oldIndex, index); |
|||
SelectionEnd = Math.Max(oldIndex, index); |
|||
} |
|||
else |
|||
{ |
|||
SelectionStart = SelectionEnd = index; |
|||
} |
|||
|
|||
break; |
|||
case 2: |
|||
if (!StringUtils.IsStartOfWord(text, index)) |
|||
{ |
|||
SelectionStart = StringUtils.PreviousWord(text, index); |
|||
} |
|||
|
|||
SelectionEnd = StringUtils.NextWord(text, index); |
|||
break; |
|||
case 3: |
|||
SelectAll(); |
|||
break; |
|||
} |
|||
} |
|||
|
|||
e.Pointer.Capture(this); |
|||
e.Handled = true; |
|||
} |
|||
|
|||
protected override void OnPointerMoved(PointerEventArgs e) |
|||
{ |
|||
if (!IsTextSelectionEnabled) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
// selection should not change during pointer move if the user right clicks
|
|||
if (e.Pointer.Captured == this && e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) |
|||
{ |
|||
var point = e.GetPosition(this); |
|||
|
|||
point = new Point( |
|||
MathUtilities.Clamp(point.X, 0, Math.Max(Bounds.Width - 1, 0)), |
|||
MathUtilities.Clamp(point.Y, 0, Math.Max(Bounds.Height - 1, 0))); |
|||
|
|||
var hit = TextLayout.HitTestPoint(point); |
|||
|
|||
SelectionEnd = hit.TextPosition; |
|||
} |
|||
} |
|||
|
|||
protected override void OnPointerReleased(PointerReleasedEventArgs e) |
|||
{ |
|||
if (!IsTextSelectionEnabled) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
if (e.Pointer.Captured != this) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
if (e.InitialPressMouseButton == MouseButton.Right) |
|||
{ |
|||
var point = e.GetPosition(this); |
|||
|
|||
var hit = TextLayout.HitTestPoint(point); |
|||
|
|||
var caretIndex = hit.TextPosition; |
|||
|
|||
// see if mouse clicked inside current selection
|
|||
// if it did not, we change the selection to where the user clicked
|
|||
var firstSelection = Math.Min(SelectionStart, SelectionEnd); |
|||
var lastSelection = Math.Max(SelectionStart, SelectionEnd); |
|||
var didClickInSelection = SelectionStart != SelectionEnd && |
|||
caretIndex >= firstSelection && caretIndex <= lastSelection; |
|||
if (!didClickInSelection) |
|||
{ |
|||
SelectionStart = SelectionEnd = caretIndex; |
|||
} |
|||
} |
|||
|
|||
e.Pointer.Capture(null); |
|||
} |
|||
|
|||
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) |
|||
{ |
|||
base.OnPropertyChanged(change); |
|||
|
|||
switch (change.Property.Name) |
|||
{ |
|||
case nameof(InlinesProperty): |
|||
{ |
|||
OnInlinesChanged(change.OldValue as InlineCollection, change.NewValue as InlineCollection); |
|||
InvalidateTextLayout(); |
|||
break; |
|||
} |
|||
case nameof(TextProperty): |
|||
{ |
|||
InvalidateTextLayout(); |
|||
break; |
|||
} |
|||
} |
|||
} |
|||
|
|||
private string GetSelection() |
|||
{ |
|||
if (!IsTextSelectionEnabled) |
|||
{ |
|||
return ""; |
|||
} |
|||
|
|||
var text = Inlines.Text ?? Text; |
|||
|
|||
if (string.IsNullOrEmpty(text)) |
|||
{ |
|||
return ""; |
|||
} |
|||
|
|||
var selectionStart = SelectionStart; |
|||
var selectionEnd = SelectionEnd; |
|||
var start = Math.Min(selectionStart, selectionEnd); |
|||
var end = Math.Max(selectionStart, selectionEnd); |
|||
|
|||
if (start == end || text.Length < end) |
|||
{ |
|||
return ""; |
|||
} |
|||
|
|||
var length = Math.Max(0, end - start); |
|||
|
|||
var selectedText = text.Substring(start, length); |
|||
|
|||
return selectedText; |
|||
} |
|||
|
|||
private void OnInlinesChanged(InlineCollection? oldValue, InlineCollection? newValue) |
|||
{ |
|||
if (oldValue is not null) |
|||
{ |
|||
oldValue.Parent = null; |
|||
oldValue.InlineHost = null; |
|||
oldValue.Invalidated -= (s, e) => InvalidateTextLayout(); |
|||
} |
|||
|
|||
if (newValue is not null) |
|||
{ |
|||
newValue.Parent = this; |
|||
newValue.InlineHost = this; |
|||
newValue.Invalidated += (s, e) => InvalidateTextLayout(); |
|||
} |
|||
} |
|||
|
|||
void IInlineHost.AddVisualChild(IControl child) |
|||
{ |
|||
if (child.VisualParent == null) |
|||
{ |
|||
VisualChildren.Add(child); |
|||
} |
|||
} |
|||
|
|||
void IInlineHost.Invalidate() |
|||
{ |
|||
InvalidateTextLayout(); |
|||
} |
|||
|
|||
private readonly struct InlinesTextSource : ITextSource |
|||
{ |
|||
private readonly IReadOnlyList<TextRun> _textRuns; |
|||
|
|||
public InlinesTextSource(IReadOnlyList<TextRun> textRuns) |
|||
{ |
|||
_textRuns = textRuns; |
|||
} |
|||
|
|||
public TextRun? GetTextRun(int textSourceIndex) |
|||
{ |
|||
var currentPosition = 0; |
|||
|
|||
foreach (var textRun in _textRuns) |
|||
{ |
|||
if (textRun.TextSourceLength == 0) |
|||
{ |
|||
continue; |
|||
} |
|||
|
|||
if (currentPosition >= textSourceIndex) |
|||
{ |
|||
return textRun; |
|||
} |
|||
|
|||
currentPosition += textRun.TextSourceLength; |
|||
} |
|||
|
|||
return null; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +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.Platform.Storage; |
|||
using Avalonia.Platform.Storage.FileIO; |
|||
|
|||
namespace Avalonia.Dialogs; |
|||
|
|||
public class ManagedStorageProvider<T> : BclStorageProvider where T : Window, new() |
|||
{ |
|||
private readonly Window _parent; |
|||
private readonly ManagedFileDialogOptions _managedOptions; |
|||
|
|||
public ManagedStorageProvider(Window parent, ManagedFileDialogOptions? managedOptions) |
|||
{ |
|||
_parent = parent; |
|||
_managedOptions = managedOptions ?? new ManagedFileDialogOptions(); |
|||
} |
|||
|
|||
public override bool CanSave => true; |
|||
public override bool CanOpen => true; |
|||
public override bool CanPickFolder => true; |
|||
|
|||
public override async Task<IReadOnlyList<IStorageFile>> OpenFilePickerAsync(FilePickerOpenOptions options) |
|||
{ |
|||
var model = new ManagedFileChooserViewModel(options, _managedOptions); |
|||
var results = await Show(model, _parent); |
|||
|
|||
return results.Select(f => new BclStorageFile(new FileInfo(f))).ToArray(); |
|||
} |
|||
|
|||
public override async Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options) |
|||
{ |
|||
var model = new ManagedFileChooserViewModel(options, _managedOptions); |
|||
var results = await Show(model, _parent); |
|||
|
|||
return results.FirstOrDefault() is { } result |
|||
? new BclStorageFile(new FileInfo(result)) |
|||
: null; |
|||
} |
|||
|
|||
public override async Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options) |
|||
{ |
|||
var model = new ManagedFileChooserViewModel(options, _managedOptions); |
|||
var results = await Show(model, _parent); |
|||
|
|||
return results.Select(f => new BclStorageFolder(new DirectoryInfo(f))).ToArray(); |
|||
} |
|||
|
|||
private async Task<string[]> Show(ManagedFileChooserViewModel model, Window parent) |
|||
{ |
|||
var dialog = new T |
|||
{ |
|||
Content = new ManagedFileChooser(), |
|||
Title = model.Title, |
|||
DataContext = model |
|||
}; |
|||
|
|||
dialog.Closed += delegate { model.Cancel(); }; |
|||
|
|||
string[]? result = null; |
|||
|
|||
model.CompleteRequested += items => |
|||
{ |
|||
result = items; |
|||
dialog.Close(); |
|||
}; |
|||
|
|||
model.OverwritePrompt += async (filename) => |
|||
{ |
|||
var overwritePromptDialog = new Window() |
|||
{ |
|||
Title = "Confirm Save As", |
|||
SizeToContent = SizeToContent.WidthAndHeight, |
|||
WindowStartupLocation = WindowStartupLocation.CenterOwner, |
|||
Padding = new Thickness(10), |
|||
MinWidth = 270 |
|||
}; |
|||
|
|||
string name = Path.GetFileName(filename); |
|||
|
|||
var panel = new DockPanel() |
|||
{ |
|||
HorizontalAlignment = Layout.HorizontalAlignment.Stretch |
|||
}; |
|||
|
|||
var label = new Label() |
|||
{ |
|||
Content = $"{name} already exists.\nDo you want to replace it?" |
|||
}; |
|||
|
|||
panel.Children.Add(label); |
|||
DockPanel.SetDock(label, Dock.Top); |
|||
|
|||
var buttonPanel = new StackPanel() |
|||
{ |
|||
HorizontalAlignment = Layout.HorizontalAlignment.Right, |
|||
Orientation = Layout.Orientation.Horizontal, |
|||
Spacing = 10 |
|||
}; |
|||
|
|||
var button = new Button() |
|||
{ |
|||
Content = "Yes", |
|||
HorizontalAlignment = Layout.HorizontalAlignment.Right |
|||
}; |
|||
|
|||
button.Click += (sender, args) => |
|||
{ |
|||
result = new string[1] { filename }; |
|||
overwritePromptDialog.Close(); |
|||
dialog.Close(); |
|||
}; |
|||
|
|||
buttonPanel.Children.Add(button); |
|||
|
|||
button = new Button() |
|||
{ |
|||
Content = "No", |
|||
HorizontalAlignment = Layout.HorizontalAlignment.Right |
|||
}; |
|||
|
|||
button.Click += (sender, args) => |
|||
{ |
|||
overwritePromptDialog.Close(); |
|||
}; |
|||
|
|||
buttonPanel.Children.Add(button); |
|||
|
|||
panel.Children.Add(buttonPanel); |
|||
DockPanel.SetDock(buttonPanel, Dock.Bottom); |
|||
|
|||
overwritePromptDialog.Content = panel; |
|||
|
|||
await overwritePromptDialog.ShowDialog(dialog); |
|||
}; |
|||
|
|||
model.CancelRequested += dialog.Close; |
|||
|
|||
await dialog.ShowDialog<object>(parent); |
|||
return result ?? Array.Empty<string>(); |
|||
} |
|||
} |
|||
@ -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<IFileChooser?> s_fileChooser = new(() => |
|||
{ |
|||
var fileChooser = DBusHelper.Connection?.CreateProxy<IFileChooser>("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<string[]?> ShowFileDialogAsync(FileDialog dialog, Window parent) |
|||
public override bool CanOpen => true; |
|||
|
|||
public override bool CanSave => true; |
|||
|
|||
public override bool CanPickFolder => true; |
|||
|
|||
public override async Task<IReadOnlyList<IStorageFile>> OpenFilePickerAsync(FilePickerOpenOptions options) |
|||
{ |
|||
var parentWindow = $"x11:{parent.PlatformImpl!.Handle.Handle.ToString("X")}"; |
|||
var parentWindow = $"x11:{_handle.Handle:X}"; |
|||
ObjectPath objectPath; |
|||
var options = new Dictionary<string, object>(); |
|||
if (dialog.Filters is not null) |
|||
options.Add("filters", ParseFilters(dialog)); |
|||
var chooserOptions = new Dictionary<string, object>(); |
|||
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<IRequest>("org.freedesktop.portal.Request", objectPath); |
|||
var tsc = new TaskCompletionSource<string[]?>(); |
|||
using var disposable = await request.WatchResponseAsync(x => tsc.SetResult(x.results["uris"] as string[]), tsc.SetException); |
|||
var uris = await tsc.Task ?? Array.Empty<string>(); |
|||
|
|||
return uris.Select(path => new BclStorageFile(new FileInfo(new Uri(path).AbsolutePath))).ToList(); |
|||
} |
|||
|
|||
switch (dialog) |
|||
public override async Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options) |
|||
{ |
|||
var parentWindow = $"x11:{_handle.Handle:X}"; |
|||
ObjectPath objectPath; |
|||
var chooserOptions = new Dictionary<string, object>(); |
|||
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<IRequest>("org.freedesktop.portal.Request", objectPath); |
|||
var tsc = new TaskCompletionSource<string[]?>(); |
|||
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<string?> ShowFolderDialogAsync(OpenFolderDialog dialog, Window parent) |
|||
public override async Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options) |
|||
{ |
|||
var parentWindow = $"x11:{parent.PlatformImpl!.Handle.Handle.ToString("X")}"; |
|||
var options = new Dictionary<string, object> |
|||
var parentWindow = $"x11:{_handle.Handle:X}"; |
|||
var chooserOptions = new Dictionary<string, object> |
|||
{ |
|||
{ "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<IRequest>("org.freedesktop.portal.Request", objectPath); |
|||
var tsc = new TaskCompletionSource<string[]?>(); |
|||
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<string>(); |
|||
|
|||
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<FilePickerFileType>? 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(); |
|||
} |
|||
} |
|||
} |
|||
|
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue