From 3d08aa37edf600475e65c8570e287b8c72e1c702 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=9B=E5=B0=98=E7=A9=BA=E5=BF=A7?= Date: Fri, 10 Jan 2025 11:50:11 +0800 Subject: [PATCH 1/6] Feat: add a new control PathPicker, add PathPickerDemo --- demo/Sandbox/Views/MainWindow.axaml | 87 ++++++-- demo/Ursa.Demo/Pages/PathPickerDemo.axaml | 82 +++++++ demo/Ursa.Demo/Pages/PathPickerDemo.axaml.cs | 13 ++ .../Ursa.Demo/ViewModels/MainViewViewModel.cs | 2 + demo/Ursa.Demo/ViewModels/MenuViewModel.cs | 4 +- .../ViewModels/PathPickerDemoViewModel.cs | 10 + .../Ursa.ReactiveUIExtension.csproj | 4 +- .../Controls/PathPicker.axaml | 66 ++++++ src/Ursa.Themes.Semi/Controls/_index.axaml | 1 + src/Ursa/Controls/PathPicker/PathPicker.cs | 211 ++++++++++++++++++ .../Controls/PathPicker/UsePickerTypes.cs | 8 + 11 files changed, 470 insertions(+), 18 deletions(-) create mode 100644 demo/Ursa.Demo/Pages/PathPickerDemo.axaml create mode 100644 demo/Ursa.Demo/Pages/PathPickerDemo.axaml.cs create mode 100644 demo/Ursa.Demo/ViewModels/PathPickerDemoViewModel.cs create mode 100644 src/Ursa.Themes.Semi/Controls/PathPicker.axaml create mode 100644 src/Ursa/Controls/PathPicker/PathPicker.cs create mode 100644 src/Ursa/Controls/PathPicker/UsePickerTypes.cs diff --git a/demo/Sandbox/Views/MainWindow.axaml b/demo/Sandbox/Views/MainWindow.axaml index d1f30fd..eced496 100644 --- a/demo/Sandbox/Views/MainWindow.axaml +++ b/demo/Sandbox/Views/MainWindow.axaml @@ -7,27 +7,84 @@ mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="Sandbox.Views.MainWindow" x:DataType="vm:MainWindowViewModel" + xmlns:sys="using:System" Icon="/Assets/avalonia-logo.ico" Title="Sandbox"> - + - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/demo/Ursa.Demo/Pages/PathPickerDemo.axaml b/demo/Ursa.Demo/Pages/PathPickerDemo.axaml new file mode 100644 index 0000000..fb3cd05 --- /dev/null +++ b/demo/Ursa.Demo/Pages/PathPickerDemo.axaml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/demo/Ursa.Demo/Pages/PathPickerDemo.axaml.cs b/demo/Ursa.Demo/Pages/PathPickerDemo.axaml.cs new file mode 100644 index 0000000..2651845 --- /dev/null +++ b/demo/Ursa.Demo/Pages/PathPickerDemo.axaml.cs @@ -0,0 +1,13 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace Ursa.Demo.Pages; + +public partial class PathPickerDemo : UserControl +{ + public PathPickerDemo() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/demo/Ursa.Demo/ViewModels/MainViewViewModel.cs b/demo/Ursa.Demo/ViewModels/MainViewViewModel.cs index 6b8d953..66038c1 100644 --- a/demo/Ursa.Demo/ViewModels/MainViewViewModel.cs +++ b/demo/Ursa.Demo/ViewModels/MainViewViewModel.cs @@ -4,6 +4,7 @@ using Avalonia; using Avalonia.Styling; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Messaging; +using Ursa.Controls; using Ursa.Themes.Semi; namespace Ursa.Demo.ViewModels; @@ -79,6 +80,7 @@ public partial class MainViewViewModel : ViewModelBase MenuKeys.MenuKeyTreeComboBox => new TreeComboBoxDemoViewModel(), MenuKeys.MenuKeyTwoTonePathIcon => new TwoTonePathIconDemoViewModel(), MenuKeys.AspectRatioLayout => new AspectRatioLayoutDemoViewModel(), + MenuKeys.PathPicker => new PathPickerDemoViewModel(), _ => throw new ArgumentOutOfRangeException(nameof(s), s, null) }; } diff --git a/demo/Ursa.Demo/ViewModels/MenuViewModel.cs b/demo/Ursa.Demo/ViewModels/MenuViewModel.cs index 9b90094..b0afd2a 100644 --- a/demo/Ursa.Demo/ViewModels/MenuViewModel.cs +++ b/demo/Ursa.Demo/ViewModels/MenuViewModel.cs @@ -59,7 +59,8 @@ public class MenuViewModel : ViewModelBase new() { MenuHeader = "ToolBar", Key = MenuKeys.MenuKeyToolBar }, new() { MenuHeader = "TreeComboBox", Key = MenuKeys.MenuKeyTreeComboBox }, new() { MenuHeader = "TwoTonePathIcon", Key = MenuKeys.MenuKeyTwoTonePathIcon }, - new() { MenuHeader = "AspectRatioLayout", Key = MenuKeys.AspectRatioLayout ,Status = "WIP"}, + new() { MenuHeader = "AspectRatioLayout", Key = MenuKeys.AspectRatioLayout, Status = "New" }, + new() { MenuHeader = "PathPicker", Key = MenuKeys.PathPicker, Status = "WIP" }, }; } } @@ -115,4 +116,5 @@ public static class MenuKeys public const string MenuKeyTreeComboBox = "TreeComboBox"; public const string MenuKeyTwoTonePathIcon = "TwoTonePathIcon"; public const string AspectRatioLayout = "AspectRatioLayout"; + public const string PathPicker = "PathPicker"; } \ No newline at end of file diff --git a/demo/Ursa.Demo/ViewModels/PathPickerDemoViewModel.cs b/demo/Ursa.Demo/ViewModels/PathPickerDemoViewModel.cs new file mode 100644 index 0000000..307556d --- /dev/null +++ b/demo/Ursa.Demo/ViewModels/PathPickerDemoViewModel.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Ursa.Demo.ViewModels; + +public partial class PathPickerDemoViewModel : ViewModelBase +{ + [ObservableProperty] private string? _path; + [ObservableProperty] private IReadOnlyList? _paths; +} \ No newline at end of file diff --git a/src/Ursa.ReactiveUIExtension/Ursa.ReactiveUIExtension.csproj b/src/Ursa.ReactiveUIExtension/Ursa.ReactiveUIExtension.csproj index b0d99e6..a40b3b8 100644 --- a/src/Ursa.ReactiveUIExtension/Ursa.ReactiveUIExtension.csproj +++ b/src/Ursa.ReactiveUIExtension/Ursa.ReactiveUIExtension.csproj @@ -14,14 +14,14 @@ 这个是一个Ursa拓展包。这个包整合并互相兼容了UrsaWindow和UrsaView与Avalonia.ReactiveUI的功能。【Avalonia.ReactiveUI参见:https://docs.avaloniaui.net/docs/concepts/reactiveui/】 - 1.0.1 + 1.0.2 https://github.com/irihitech/Ursa.Avalonia true snupkg - + diff --git a/src/Ursa.Themes.Semi/Controls/PathPicker.axaml b/src/Ursa.Themes.Semi/Controls/PathPicker.axaml new file mode 100644 index 0000000..07a243a --- /dev/null +++ b/src/Ursa.Themes.Semi/Controls/PathPicker.axaml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Ursa.Themes.Semi/Controls/_index.axaml b/src/Ursa.Themes.Semi/Controls/_index.axaml index 0c8421c..541005b 100644 --- a/src/Ursa.Themes.Semi/Controls/_index.axaml +++ b/src/Ursa.Themes.Semi/Controls/_index.axaml @@ -55,5 +55,6 @@ + diff --git a/src/Ursa/Controls/PathPicker/PathPicker.cs b/src/Ursa/Controls/PathPicker/PathPicker.cs new file mode 100644 index 0000000..ff62a6b --- /dev/null +++ b/src/Ursa/Controls/PathPicker/PathPicker.cs @@ -0,0 +1,211 @@ +using System.Windows.Input; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Metadata; +using Avalonia.Controls.Primitives; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Platform.Storage; +using Avalonia.Threading; +using Irihi.Avalonia.Shared.Common; + +namespace Ursa.Controls; + +[TemplatePart(Name = "PART_Button", Type = typeof(Button))] +public class PathPicker : TemplatedControl +{ + public static readonly StyledProperty SelectedPathProperty = + AvaloniaProperty.Register( + nameof(SelectedPath), defaultBindingMode: BindingMode.TwoWay, enableDataValidation: true, + validate: x => string.IsNullOrWhiteSpace(x) || File.Exists(x) || Directory.Exists(x)); + + + public static readonly StyledProperty SuggestedStartPathProperty = + AvaloniaProperty.Register( + nameof(SuggestedStartPath), string.Empty); + + public static readonly StyledProperty UsePickerTypeProperty = + AvaloniaProperty.Register( + nameof(UsePickerType)); + + public static readonly StyledProperty SuggestedFileNameProperty = + AvaloniaProperty.Register( + nameof(SuggestedFileName), string.Empty); + + public static readonly StyledProperty FileFilterProperty = AvaloniaProperty.Register( + nameof(FileFilter), string.Empty); + + public static readonly StyledProperty TitleProperty = AvaloniaProperty.Register( + nameof(Title), string.Empty); + + public static readonly StyledProperty DefaultFileExtensionProperty = + AvaloniaProperty.Register( + nameof(DefaultFileExtension), string.Empty); + + public static readonly DirectProperty> SelectedPathsProperty = + AvaloniaProperty.RegisterDirect>( + nameof(SelectedPaths), o => o.SelectedPaths, (o, v) => o.SelectedPaths = v); + + public static readonly StyledProperty CommandProperty = AvaloniaProperty.Register( + nameof(Command)); + + public static readonly StyledProperty AllowMultipleProperty = AvaloniaProperty.Register( + nameof(AllowMultiple)); + + private Button? _button; + + private IReadOnlyList _selectedPaths = []; + + public PathPicker() + { + KeyBindings.Add(new KeyBinding + { + Command = new IRIHI_CommandBase(() => + { + if (!SelectedPathProperty.ValidateValue!.Invoke(SelectedPath)) return; + SelectedPaths = string.IsNullOrWhiteSpace(SelectedPath) ? Array.Empty() : [SelectedPath!]; + Command?.Execute(Task.FromResult(SelectedPaths)); + }), + Gesture = new KeyGesture(Key.Enter) + }); + } + + public bool AllowMultiple + { + get => GetValue(AllowMultipleProperty); + set => SetValue(AllowMultipleProperty, value); + } + + public ICommand? Command + { + get => GetValue(CommandProperty); + set => SetValue(CommandProperty, value); + } + + public IReadOnlyList SelectedPaths + { + get => _selectedPaths; + private set => SetAndRaise(SelectedPathsProperty, ref _selectedPaths, value); + } + + public string SuggestedFileName + { + get => GetValue(SuggestedFileNameProperty); + set => SetValue(SuggestedFileNameProperty, value); + } + + public string DefaultFileExtension + { + get => GetValue(DefaultFileExtensionProperty); + set => SetValue(DefaultFileExtensionProperty, value); + } + + + public string Title + { + get => GetValue(TitleProperty); + set => SetValue(TitleProperty, value); + } + + public string FileFilter + { + get => GetValue(FileFilterProperty); + set => SetValue(FileFilterProperty, value); + } + + public UsePickerTypes UsePickerType + { + get => GetValue(UsePickerTypeProperty); + set => SetValue(UsePickerTypeProperty, value); + } + + public string SuggestedStartPath + { + get => GetValue(SuggestedStartPathProperty); + set => SetValue(SuggestedStartPathProperty, value); + } + + public string? SelectedPath + { + get => GetValue(SelectedPathProperty); + set => SetValue(SelectedPathProperty, value); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == SelectedPathsProperty) + SelectedPath = SelectedPaths.Count > 0 ? SelectedPaths[0] : string.Empty; + } + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + _button = e.NameScope.Find + Text="{TemplateBinding SelectedPathsText,Mode=TwoWay}"> + + + + + + + + + + + + + - + - - - - - - - - - - - - + + + + + + diff --git a/src/Ursa/Controls/PathPicker/PathPicker.cs b/src/Ursa/Controls/PathPicker/PathPicker.cs index 992c6b7..647147c 100644 --- a/src/Ursa/Controls/PathPicker/PathPicker.cs +++ b/src/Ursa/Controls/PathPicker/PathPicker.cs @@ -54,6 +54,16 @@ public class PathPicker : TemplatedControl AvaloniaProperty.Register( nameof(SelectedPathsText), defaultBindingMode: BindingMode.TwoWay); + public static readonly StyledProperty IsCancelingPickerAlsoTriggersProperty = + AvaloniaProperty.Register( + nameof(IsCancelingPickerAlsoTriggers)); + + public bool IsCancelingPickerAlsoTriggers + { + get => GetValue(IsCancelingPickerAlsoTriggersProperty); + set => SetValue(IsCancelingPickerAlsoTriggersProperty, value); + } + public string? SelectedPathsText { get => GetValue(SelectedPathsTextProperty); @@ -129,7 +139,7 @@ public class PathPicker : TemplatedControl { _twoConvertLock = true; var stringBuilder = new StringBuilder(); - stringBuilder.Append(SelectedPaths.FirstOrDefault()); + stringBuilder.Append(SelectedPaths[0]); foreach (var item in SelectedPaths.Skip(1)) { stringBuilder.AppendLine(item); @@ -268,7 +278,7 @@ public class PathPicker : TemplatedControl throw new ArgumentOutOfRangeException(); } - if (SelectedPaths.Count != 0) + if (SelectedPaths.Count != 0 || IsCancelingPickerAlsoTriggers) Command?.Execute(SelectedPaths); } catch (Exception exception) From c5824a3ddcbb72f522606c960baa516a11c0b922 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=9B=E5=B0=98=E7=A9=BA=E5=BF=A7?= Date: Mon, 13 Jan 2025 21:11:31 +0800 Subject: [PATCH 5/6] fix:Index out of bounds. --- src/Ursa/Controls/PathPicker/PathPicker.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Ursa/Controls/PathPicker/PathPicker.cs b/src/Ursa/Controls/PathPicker/PathPicker.cs index 647147c..10e96d4 100644 --- a/src/Ursa/Controls/PathPicker/PathPicker.cs +++ b/src/Ursa/Controls/PathPicker/PathPicker.cs @@ -139,10 +139,13 @@ public class PathPicker : TemplatedControl { _twoConvertLock = true; var stringBuilder = new StringBuilder(); - stringBuilder.Append(SelectedPaths[0]); - foreach (var item in SelectedPaths.Skip(1)) + if (SelectedPaths.Count != 0) { - stringBuilder.AppendLine(item); + stringBuilder.Append(SelectedPaths[0]); + foreach (var item in SelectedPaths.Skip(1)) + { + stringBuilder.AppendLine(item); + } } SelectedPathsText = stringBuilder.ToString(); From ead8591bfb59350fb6e2fa2b0a657d06f436a7a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=9B=E5=B0=98=E7=A9=BA=E5=BF=A7?= Date: Wed, 15 Jan 2025 17:00:16 +0800 Subject: [PATCH 6/6] change:IsCancelingPickerAlsoTriggers ->OmitCommandOnCancel feat:add property ClearSelectionOnCancel. support for not clearing the selection if the file picker is canceled. --- demo/Ursa.Demo/Pages/PathPickerDemo.axaml | 15 ++++++--- src/Ursa/Controls/PathPicker/PathPicker.cs | 38 ++++++++++++++++------ 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/demo/Ursa.Demo/Pages/PathPickerDemo.axaml b/demo/Ursa.Demo/Pages/PathPickerDemo.axaml index 8ffe642..50fa8c1 100644 --- a/demo/Ursa.Demo/Pages/PathPickerDemo.axaml +++ b/demo/Ursa.Demo/Pages/PathPickerDemo.axaml @@ -32,7 +32,11 @@ - + + @@ -53,7 +57,8 @@ SelectedPathsText="{Binding Path,Mode=OneWayToSource}" SelectedPaths="{Binding Paths,Mode=OneWayToSource}" Command="{Binding SelectedCommand}" - IsCancelingPickerAlsoTriggers="{Binding #IsCancelingPickerAlsoTriggers.IsChecked}"> + IsOmitCommandOnCancel="{Binding #IsOmitCommandOnCancel.IsChecked}" + IsClearSelectionOnCancel="{Binding #IsClearSelectionOnCancel.IsChecked}"> @@ -68,7 +73,8 @@ SelectedPathsText="{Binding Path,Mode=OneWayToSource}" SelectedPaths="{Binding Paths,Mode=OneWayToSource}" Command="{Binding SelectedCommand}" - IsCancelingPickerAlsoTriggers="{Binding #IsCancelingPickerAlsoTriggers.IsChecked}"> + IsOmitCommandOnCancel="{Binding #IsOmitCommandOnCancel.IsChecked}" + IsClearSelectionOnCancel="{Binding #IsClearSelectionOnCancel.IsChecked}"> @@ -83,7 +89,8 @@ SelectedPathsText="{Binding Path,Mode=OneWayToSource}" SelectedPaths="{Binding Paths,Mode=OneWayToSource}" Command="{Binding SelectedCommand}" - IsCancelingPickerAlsoTriggers="{Binding #IsCancelingPickerAlsoTriggers.IsChecked}"> + IsOmitCommandOnCancel="{Binding #IsOmitCommandOnCancel.IsChecked}" + IsClearSelectionOnCancel="{Binding #IsClearSelectionOnCancel.IsChecked}"> diff --git a/src/Ursa/Controls/PathPicker/PathPicker.cs b/src/Ursa/Controls/PathPicker/PathPicker.cs index 10e96d4..e4e69d2 100644 --- a/src/Ursa/Controls/PathPicker/PathPicker.cs +++ b/src/Ursa/Controls/PathPicker/PathPicker.cs @@ -54,14 +54,24 @@ public class PathPicker : TemplatedControl AvaloniaProperty.Register( nameof(SelectedPathsText), defaultBindingMode: BindingMode.TwoWay); - public static readonly StyledProperty IsCancelingPickerAlsoTriggersProperty = + public static readonly StyledProperty IsOmitCommandOnCancelProperty = AvaloniaProperty.Register( - nameof(IsCancelingPickerAlsoTriggers)); + nameof(IsOmitCommandOnCancel)); - public bool IsCancelingPickerAlsoTriggers + public static readonly StyledProperty IsClearSelectionOnCancelProperty = + AvaloniaProperty.Register( + nameof(IsClearSelectionOnCancel)); + + public bool IsClearSelectionOnCancel { - get => GetValue(IsCancelingPickerAlsoTriggersProperty); - set => SetValue(IsCancelingPickerAlsoTriggersProperty, value); + get => GetValue(IsClearSelectionOnCancelProperty); + set => SetValue(IsClearSelectionOnCancelProperty, value); + } + + public bool IsOmitCommandOnCancel + { + get => GetValue(IsOmitCommandOnCancelProperty); + set => SetValue(IsOmitCommandOnCancelProperty, value); } public string? SelectedPathsText @@ -246,7 +256,7 @@ public class PathPicker : TemplatedControl FileTypeFilter = ParseFileTypes(FileFilter) }; var resFiles = await storageProvider.OpenFilePickerAsync(filePickerOpenOptions); - SelectedPaths = resFiles.Select(x => x.TryGetLocalPath()).ToArray()!; + UpdateSelectedPaths(resFiles.Select(x => x.TryGetLocalPath()).ToArray()!); break; case UsePickerTypes.SaveFile: FilePickerSaveOptions filePickerSaveOptions = new() @@ -261,9 +271,9 @@ public class PathPicker : TemplatedControl var path = (await storageProvider.SaveFilePickerAsync(filePickerSaveOptions)) ?.TryGetLocalPath(); - SelectedPaths = string.IsNullOrEmpty(path) + UpdateSelectedPaths(string.IsNullOrEmpty(path) ? Array.Empty() - : [path!]; + : [path!]); break; case UsePickerTypes.OpenFolder: FolderPickerOpenOptions folderPickerOpenOptions = new() @@ -275,18 +285,26 @@ public class PathPicker : TemplatedControl SuggestedFileName = SuggestedFileName }; var resFolder = await storageProvider.OpenFolderPickerAsync(folderPickerOpenOptions); - SelectedPaths = resFolder.Select(x => x.TryGetLocalPath()).ToArray()!; + UpdateSelectedPaths(resFolder.Select(x => x.TryGetLocalPath()).ToArray()!); break; default: throw new ArgumentOutOfRangeException(); } - if (SelectedPaths.Count != 0 || IsCancelingPickerAlsoTriggers) + if (SelectedPaths.Count != 0 || IsOmitCommandOnCancel is false) Command?.Execute(SelectedPaths); } catch (Exception exception) { Logger.TryGet(LogEventLevel.Error, LogArea.Control)?.Log(this, $"{exception}"); } + + return; + } + + private void UpdateSelectedPaths(IReadOnlyList newList) + { + if (newList.Count != 0 || IsClearSelectionOnCancel && newList.Count == 0) + SelectedPaths = newList; } } \ No newline at end of file