From 0b51e0dbb75f4624b6b8deb59e3125013f042e87 Mon Sep 17 00:00:00 2001 From: robloo Date: Mon, 30 Jan 2023 23:06:47 -0500 Subject: [PATCH 01/17] Remove sender parameter from internal OnEvent methods --- src/Avalonia.Controls/SplitView.cs | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Avalonia.Controls/SplitView.cs b/src/Avalonia.Controls/SplitView.cs index 35b135e152..200f0df607 100644 --- a/src/Avalonia.Controls/SplitView.cs +++ b/src/Avalonia.Controls/SplitView.cs @@ -206,24 +206,24 @@ namespace Avalonia.Controls if (value) { - OnPaneOpening(this, EventArgs.Empty); + OnPaneOpening(EventArgs.Empty); SetAndRaise(IsPaneOpenProperty, ref _isPaneOpen, value); PseudoClasses.Add(":open"); PseudoClasses.Remove(":closed"); - OnPaneOpened(this, EventArgs.Empty); + OnPaneOpened(EventArgs.Empty); } else { SplitViewPaneClosingEventArgs args = new SplitViewPaneClosingEventArgs(false); - OnPaneClosing(this, args); + OnPaneClosing(args); if (!args.Cancel) { SetAndRaise(IsPaneOpenProperty, ref _isPaneOpen, value); PseudoClasses.Add(":closed"); PseudoClasses.Remove(":open"); - OnPaneClosed(this, EventArgs.Empty); + OnPaneClosed(EventArgs.Empty); } } } @@ -394,24 +394,24 @@ namespace Avalonia.Controls return (DisplayMode == SplitViewDisplayMode.CompactOverlay || DisplayMode == SplitViewDisplayMode.Overlay); } - protected virtual void OnPaneOpening(SplitView sender, EventArgs args) + protected virtual void OnPaneOpening(EventArgs args) { - PaneOpening?.Invoke(sender, args); + PaneOpening?.Invoke(this, args); } - protected virtual void OnPaneOpened(SplitView sender, EventArgs args) + protected virtual void OnPaneOpened(EventArgs args) { - PaneOpened?.Invoke(sender, args); + PaneOpened?.Invoke(this, args); } - protected virtual void OnPaneClosing(SplitView sender, SplitViewPaneClosingEventArgs args) + protected virtual void OnPaneClosing(SplitViewPaneClosingEventArgs args) { - PaneClosing?.Invoke(sender, args); + PaneClosing?.Invoke(this, args); } - protected virtual void OnPaneClosed(SplitView sender, EventArgs args) + protected virtual void OnPaneClosed(EventArgs args) { - PaneClosed?.Invoke(sender, args); + PaneClosed?.Invoke(this, args); } private void OnCompactPaneLengthChanged(AvaloniaPropertyChangedEventArgs e) From 553deb41fd7285fa5e50c361ecfd13c9891f3561 Mon Sep 17 00:00:00 2001 From: robloo Date: Mon, 30 Jan 2023 23:34:20 -0500 Subject: [PATCH 02/17] Switch SplitView.IsPaneOpen to a StyledProperty Includes other small formatting changes and updates --- src/Avalonia.Controls/SplitView.cs | 141 +++++++++++++++++++---------- 1 file changed, 92 insertions(+), 49 deletions(-) diff --git a/src/Avalonia.Controls/SplitView.cs b/src/Avalonia.Controls/SplitView.cs index 200f0df607..b45e0f2adb 100644 --- a/src/Avalonia.Controls/SplitView.cs +++ b/src/Avalonia.Controls/SplitView.cs @@ -1,15 +1,13 @@ -using Avalonia.Controls.Metadata; +using System; +using Avalonia.Controls.Metadata; +using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; using Avalonia.Input; using Avalonia.Interactivity; +using Avalonia.LogicalTree; using Avalonia.Media; using Avalonia.Metadata; -using Avalonia.VisualTree; -using System; -using Avalonia.Reactive; -using Avalonia.Controls.Presenters; -using Avalonia.Controls.Templates; -using Avalonia.LogicalTree; namespace Avalonia.Controls { @@ -54,10 +52,13 @@ namespace Avalonia.Controls internal SplitViewTemplateSettings() { } public static readonly StyledProperty ClosedPaneWidthProperty = - AvaloniaProperty.Register(nameof(ClosedPaneWidth), 0d); + AvaloniaProperty.Register(nameof(ClosedPaneWidth), + 0d); public static readonly StyledProperty PaneColumnGridLengthProperty = - AvaloniaProperty.Register(nameof(PaneColumnGridLength)); + AvaloniaProperty.Register( + nameof(PaneColumnGridLength)); public double ClosedPaneWidth { @@ -93,26 +94,34 @@ namespace Avalonia.Controls /// Defines the property /// public static readonly StyledProperty CompactPaneLengthProperty = - AvaloniaProperty.Register(nameof(CompactPaneLength), defaultValue: 48); + AvaloniaProperty.Register( + nameof(CompactPaneLength), + defaultValue: 48); /// /// Defines the property /// public static readonly StyledProperty DisplayModeProperty = - AvaloniaProperty.Register(nameof(DisplayMode), defaultValue: SplitViewDisplayMode.Overlay); + AvaloniaProperty.Register( + nameof(DisplayMode), + defaultValue: SplitViewDisplayMode.Overlay); /// /// Defines the property /// - public static readonly DirectProperty IsPaneOpenProperty = - AvaloniaProperty.RegisterDirect(nameof(IsPaneOpen), - x => x.IsPaneOpen, (x, v) => x.IsPaneOpen = v); + public static readonly StyledProperty IsPaneOpenProperty = + AvaloniaProperty.Register( + nameof(IsPaneOpen), + defaultValue: false, + coerce: CoerceIsPaneOpen); /// /// Defines the property /// public static readonly StyledProperty OpenPaneLengthProperty = - AvaloniaProperty.Register(nameof(OpenPaneLength), defaultValue: 320); + AvaloniaProperty.Register( + nameof(OpenPaneLength), + defaultValue: 320); /// /// Defines the property @@ -150,7 +159,6 @@ namespace Avalonia.Controls public static readonly StyledProperty TemplateSettingsProperty = AvaloniaProperty.Register(nameof(TemplateSettings)); - private bool _isPaneOpen; private Panel? _pane; private IDisposable? _pointerDisposable; @@ -169,7 +177,7 @@ namespace Avalonia.Controls PanePlacementProperty.Changed.AddClassHandler((x, v) => x.OnPanePlacementChanged(v)); DisplayModeProperty.Changed.AddClassHandler((x, v) => x.OnDisplayModeChanged(v)); - PaneProperty.Changed.AddClassHandler((x, e) => x.PaneChanged(e)); + PaneProperty.Changed.AddClassHandler((x, e) => x.OnPaneChanged(e)); } /// @@ -196,37 +204,8 @@ namespace Avalonia.Controls /// public bool IsPaneOpen { - get => _isPaneOpen; - set - { - if (value == _isPaneOpen) - { - return; - } - - if (value) - { - OnPaneOpening(EventArgs.Empty); - SetAndRaise(IsPaneOpenProperty, ref _isPaneOpen, value); - - PseudoClasses.Add(":open"); - PseudoClasses.Remove(":closed"); - OnPaneOpened(EventArgs.Empty); - } - else - { - SplitViewPaneClosingEventArgs args = new SplitViewPaneClosingEventArgs(false); - OnPaneClosing(args); - if (!args.Cancel) - { - SetAndRaise(IsPaneOpenProperty, ref _isPaneOpen, value); - - PseudoClasses.Add(":closed"); - PseudoClasses.Remove(":open"); - OnPaneClosed(EventArgs.Empty); - } - } - } + get => GetValue(IsPaneOpenProperty); + set => SetValue(IsPaneOpenProperty, value); } /// @@ -351,6 +330,30 @@ namespace Avalonia.Controls _pointerDisposable?.Dispose(); } + /// + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == IsPaneOpenProperty) + { + bool isPaneOpen = change.GetNewValue(); + + if (isPaneOpen) + { + PseudoClasses.Add(":open"); + PseudoClasses.Remove(":closed"); + OnPaneOpened(EventArgs.Empty); + } + else + { + PseudoClasses.Add(":closed"); + PseudoClasses.Remove(":open"); + OnPaneClosed(EventArgs.Empty); + } + } + } + private void PointerPressedOutside(object? sender, PointerPressedEventArgs e) { if (!IsPaneOpen) @@ -485,7 +488,7 @@ namespace Avalonia.Controls PseudoClasses.Set(":lightdismiss", mode); } - private void PaneChanged(AvaloniaPropertyChangedEventArgs e) + private void OnPaneChanged(AvaloniaPropertyChangedEventArgs e) { if (e.OldValue is ILogical oldChild) { @@ -497,5 +500,45 @@ namespace Avalonia.Controls LogicalChildren.Add(newChild); } } + + /// + /// Called when the property has to be coerced. + /// + /// The value to coerce. + protected virtual bool OnCoerceIsPaneOpen(bool value) + { + if (value) + { + OnPaneOpening(EventArgs.Empty); + } + else + { + var eventArgs = new SplitViewPaneClosingEventArgs(false); + OnPaneClosing(eventArgs); + + if (eventArgs.Cancel) + { + return !value; + } + } + + return value; + } + + /// + /// Coerces/validates the property value. + /// + /// The instance. + /// The value to coerce. + /// The coerced/validated value. + private static bool CoerceIsPaneOpen(AvaloniaObject instance, bool value) + { + if (instance is SplitView splitView) + { + return splitView.OnCoerceIsPaneOpen(value); + } + + return value; + } } } From 04df472194e194559d33619e089f3aa918e3f5a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Pedro?= Date: Thu, 9 Feb 2023 03:10:19 +0000 Subject: [PATCH 03/17] Added unit test for #10226. --- .../Styling/StyledElementTests_Theming.cs | 40 ++++++++++++++++--- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/tests/Avalonia.Base.UnitTests/Styling/StyledElementTests_Theming.cs b/tests/Avalonia.Base.UnitTests/Styling/StyledElementTests_Theming.cs index b5a9b35134..60603937d9 100644 --- a/tests/Avalonia.Base.UnitTests/Styling/StyledElementTests_Theming.cs +++ b/tests/Avalonia.Base.UnitTests/Styling/StyledElementTests_Theming.cs @@ -104,7 +104,7 @@ public class StyledElementTests_Theming target.Theme = null; Assert.Equal("style", target.Tag); } - + [Fact] public void TemplatedParent_Theme_Is_Detached_From_Template_Controls_When_Theme_Property_Cleared() { @@ -539,12 +539,42 @@ public class StyledElementTests_Theming Assert.Same(target.Theme, theme3); } + [Fact] + public void TemplatedParent_Theme_Change_Applies_To_Children() + { + var theme = CreateDerivedTheme(); + var target = CreateTarget(); + + Assert.Null(target.Theme); + Assert.Null(target.Template); + + var root = CreateRoot(target, theme.BasedOn); + + Assert.NotNull(target.Theme); + Assert.NotNull(target.Template); + + root.Styles.Add(new Style(x => x.OfType().Class("foo")) + { + Setters = { new Setter(StyledElement.ThemeProperty, theme) } + }); + + root.LayoutManager.ExecuteLayoutPass(); + + var border = Assert.IsType(target.VisualChild); + Assert.Equal(Brushes.Red, border.Background); + + target.Classes.Add("foo"); + root.LayoutManager.ExecuteLayoutPass(); + + Assert.Equal(Brushes.Green, border.Background); + } + private static ThemedControl CreateTarget() { return new ThemedControl(); } - private static TestRoot CreateRoot(Control child) + private static TestRoot CreateRoot(Control child, ControlTheme? theme = null) { var result = new TestRoot() { @@ -552,7 +582,7 @@ public class StyledElementTests_Theming { new Style(x => x.OfType()) { - Setters = { new Setter(StyledElement.ThemeProperty, CreateTheme()) } + Setters = { new Setter(StyledElement.ThemeProperty, theme ?? CreateTheme()) } } } }; @@ -580,8 +610,8 @@ public class StyledElementTests_Theming { new Style(x => x.Nesting().Template().OfType()) { - Setters = - { + Setters = + { new Setter(Border.BackgroundProperty, Brushes.Red), new Setter(Control.TagProperty, tag), } From b8a987e84748b3c26d37dbfa787e720c0a188aed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Pedro?= Date: Wed, 8 Feb 2023 20:29:25 +0000 Subject: [PATCH 04/17] Ensure templated parent control theme is applied. --- src/Avalonia.Base/Layout/Layoutable.cs | 6 ++++++ src/Avalonia.Base/StyledElement.cs | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Avalonia.Base/Layout/Layoutable.cs b/src/Avalonia.Base/Layout/Layoutable.cs index 775b8adddd..c45fc16929 100644 --- a/src/Avalonia.Base/Layout/Layoutable.cs +++ b/src/Avalonia.Base/Layout/Layoutable.cs @@ -795,6 +795,12 @@ namespace Avalonia.Layout InvalidateMeasure(); } + internal override void OnTemplatedParentControlThemeChanged() + { + base.OnTemplatedParentControlThemeChanged(); + InvalidateMeasure(); + } + /// /// Called when the layout manager raises a LayoutUpdated event. /// diff --git a/src/Avalonia.Base/StyledElement.cs b/src/Avalonia.Base/StyledElement.cs index 5bf022cd51..b910012444 100644 --- a/src/Avalonia.Base/StyledElement.cs +++ b/src/Avalonia.Base/StyledElement.cs @@ -390,7 +390,7 @@ namespace Avalonia /// public bool ApplyStyling() { - if (_initCount == 0 && (!_stylesApplied || !_themeApplied)) + if (_initCount == 0 && (!_stylesApplied || !_themeApplied || !_templatedParentThemeApplied)) { GetValueStore().BeginStyling(); From 104023bfc88a9e7ee6e7033c4181494f302b6e61 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Sat, 18 Feb 2023 00:27:21 -0500 Subject: [PATCH 05/17] Remove specific data type methods from the IDataObject, add new Files format --- .../ControlCatalog/Pages/DialogsPage.xaml.cs | 41 ++++++++------- .../ControlCatalog/Pages/DragAndDropPage.xaml | 3 +- .../Pages/DragAndDropPage.xaml.cs | 48 +++++++++++++++--- src/Avalonia.Base/Input/DataFormats.cs | 10 +++- src/Avalonia.Base/Input/DataObject.cs | 25 ++++------ .../Input/DataObjectExtensions.cs | 50 +++++++++++++++++++ src/Avalonia.Base/Input/IDataObject.cs | 17 ++----- .../Platform/Storage/FileIO/BclStorageFile.cs | 5 -- .../Storage/FileIO/BclStorageFolder.cs | 9 ---- .../Storage/FileIO/StorageProviderHelpers.cs | 17 +++++++ .../Platform/Storage/PickerOptions.cs | 2 + .../Storage/StorageProviderExtensions.cs | 12 +++++ src/Avalonia.Native/ClipboardImpl.cs | 41 +++++++++------ .../Avalonia.Win32/ClipboardFormats.cs | 3 ++ src/Windows/Avalonia.Win32/DataObject.cs | 15 ++---- src/Windows/Avalonia.Win32/OleDataObject.cs | 18 +++---- 16 files changed, 213 insertions(+), 103 deletions(-) create mode 100644 src/Avalonia.Base/Input/DataObjectExtensions.cs diff --git a/samples/ControlCatalog/Pages/DialogsPage.xaml.cs b/samples/ControlCatalog/Pages/DialogsPage.xaml.cs index e24860e3e1..e5f29abb68 100644 --- a/samples/ControlCatalog/Pages/DialogsPage.xaml.cs +++ b/samples/ControlCatalog/Pages/DialogsPage.xaml.cs @@ -306,25 +306,8 @@ namespace ControlCatalog.Pages resultText += @$" Content: "; -#if NET6_0_OR_GREATER - await using var stream = await file.OpenReadAsync(); -#else - using var stream = await file.OpenReadAsync(); -#endif - using var reader = new System.IO.StreamReader(stream); - // 4GB file test, shouldn't load more than 10000 chars into a memory. - const int length = 10000; - var buffer = ArrayPool.Shared.Rent(length); - try - { - var charsRead = await reader.ReadAsync(buffer, 0, length); - resultText += new string(buffer, 0, charsRead); - } - finally - { - ArrayPool.Shared.Return(buffer); - } + resultText += await ReadTextFromFile(file, 10000); } openedFileContent.Text = resultText; @@ -354,6 +337,28 @@ namespace ControlCatalog.Pages } } + public static async Task ReadTextFromFile(IStorageFile file, int length) + { +#if NET6_0_OR_GREATER + await using var stream = await file.OpenReadAsync(); +#else + using var stream = await file.OpenReadAsync(); +#endif + using var reader = new System.IO.StreamReader(stream); + + // 4GB file test, shouldn't load more than 10000 chars into a memory. + var buffer = ArrayPool.Shared.Rent(length); + try + { + var charsRead = await reader.ReadAsync(buffer, 0, length); + return new string(buffer, 0, charsRead); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { base.OnAttachedToVisualTree(e); diff --git a/samples/ControlCatalog/Pages/DragAndDropPage.xaml b/samples/ControlCatalog/Pages/DragAndDropPage.xaml index 3f8a023060..390fa32b9c 100644 --- a/samples/ControlCatalog/Pages/DragAndDropPage.xaml +++ b/samples/ControlCatalog/Pages/DragAndDropPage.xaml @@ -25,7 +25,6 @@ BorderThickness="2"> Drag Me (custom) - + + diff --git a/samples/ControlCatalog/Pages/DragAndDropPage.xaml.cs b/samples/ControlCatalog/Pages/DragAndDropPage.xaml.cs index e384db88b3..26430b4b61 100644 --- a/samples/ControlCatalog/Pages/DragAndDropPage.xaml.cs +++ b/samples/ControlCatalog/Pages/DragAndDropPage.xaml.cs @@ -1,27 +1,29 @@ using System; +using System.IO; using System.Linq; using System.Reflection; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Markup.Xaml; +using Avalonia.Platform.Storage; namespace ControlCatalog.Pages { public class DragAndDropPage : UserControl { - TextBlock _DropState; + private readonly TextBlock _dropState; private const string CustomFormat = "application/xxx-avalonia-controlcatalog-custom"; public DragAndDropPage() { this.InitializeComponent(); - _DropState = this.Get("DropState"); + _dropState = this.Get("DropState"); int textCount = 0; SetupDnd("Text", d => d.Set(DataFormats.Text, $"Text was dragged {++textCount} times"), DragDropEffects.Copy | DragDropEffects.Move | DragDropEffects.Link); SetupDnd("Custom", d => d.Set(CustomFormat, "Test123"), DragDropEffects.Move); - SetupDnd("Files", d => d.Set(DataFormats.FileNames, new[] { Assembly.GetEntryAssembly()?.GetModules().FirstOrDefault()?.FullyQualifiedName }), DragDropEffects.Copy); + SetupDnd("Files", d => d.Set(DataFormats.Files, new[] { Assembly.GetEntryAssembly()?.GetModules().FirstOrDefault()?.FullyQualifiedName }), DragDropEffects.Copy); } void SetupDnd(string suffix, Action factory, DragDropEffects effects) @@ -68,12 +70,12 @@ namespace ControlCatalog.Pages // Only allow if the dragged data contains text or filenames. if (!e.Data.Contains(DataFormats.Text) - && !e.Data.Contains(DataFormats.FileNames) + && !e.Data.Contains(DataFormats.Files) && !e.Data.Contains(CustomFormat)) e.DragEffects = DragDropEffects.None; } - void Drop(object? sender, DragEventArgs e) + async void Drop(object? sender, DragEventArgs e) { if (e.Source is Control c && c.Name == "MoveTarget") { @@ -85,11 +87,41 @@ namespace ControlCatalog.Pages } if (e.Data.Contains(DataFormats.Text)) - _DropState.Text = e.Data.GetText(); + { + _dropState.Text = e.Data.GetText(); + } + else if (e.Data.Contains(DataFormats.Files)) + { + var files = e.Data.GetFiles() ?? Array.Empty(); + var contentStr = ""; + + foreach (var item in files) + { + if (item is IStorageFile file) + { + var content = await DialogsPage.ReadTextFromFile(file, 1000); + contentStr += $"File {item.Name}:{Environment.NewLine}{content}{Environment.NewLine}{Environment.NewLine}"; + } + else if (item is IStorageFolder folder) + { + var items = await folder.GetItemsAsync(); + contentStr += $"Folder {item.Name}: items {items.Count}{Environment.NewLine}{Environment.NewLine}"; + } + } + + _dropState.Text = contentStr; + } +#pragma warning disable CS0618 // Type or member is obsolete else if (e.Data.Contains(DataFormats.FileNames)) - _DropState.Text = string.Join(Environment.NewLine, e.Data.GetFileNames() ?? Array.Empty()); + { + var files = e.Data.GetFileNames(); + _dropState.Text = string.Join(Environment.NewLine, files ?? Array.Empty()); + } +#pragma warning restore CS0618 // Type or member is obsolete else if (e.Data.Contains(CustomFormat)) - _DropState.Text = "Custom: " + e.Data.Get(CustomFormat); + { + _dropState.Text = "Custom: " + e.Data.Get(CustomFormat); + } } dragMe.PointerPressed += DoDrag; diff --git a/src/Avalonia.Base/Input/DataFormats.cs b/src/Avalonia.Base/Input/DataFormats.cs index cf5a6592e1..35d50e669a 100644 --- a/src/Avalonia.Base/Input/DataFormats.cs +++ b/src/Avalonia.Base/Input/DataFormats.cs @@ -1,4 +1,6 @@ -namespace Avalonia.Input +using System; + +namespace Avalonia.Input { public static class DataFormats { @@ -7,9 +9,15 @@ /// public static readonly string Text = nameof(Text); + /// + /// Dataformat for one or more files. + /// + public static readonly string Files = nameof(Files); + /// /// Dataformat for one or more filenames /// + [Obsolete("Use DataFormats.Files, this format is supported only on desktop platforms.")] public static readonly string FileNames = nameof(FileNames); } } diff --git a/src/Avalonia.Base/Input/DataObject.cs b/src/Avalonia.Base/Input/DataObject.cs index 688f5f9cc8..93a6baa03c 100644 --- a/src/Avalonia.Base/Input/DataObject.cs +++ b/src/Avalonia.Base/Input/DataObject.cs @@ -2,37 +2,34 @@ namespace Avalonia.Input { + /// + /// Specific and mutable implementation of the IDataObject interface. + /// public class DataObject : IDataObject { - private readonly Dictionary _items = new Dictionary(); + private readonly Dictionary _items = new(); + /// public bool Contains(string dataFormat) { return _items.ContainsKey(dataFormat); } + /// public object? Get(string dataFormat) { - if (_items.ContainsKey(dataFormat)) - return _items[dataFormat]; - return null; + return _items.TryGetValue(dataFormat, out var item) ? item : null; } + /// public IEnumerable GetDataFormats() { return _items.Keys; } - public IEnumerable? GetFileNames() - { - return Get(DataFormats.FileNames) as IEnumerable; - } - - public string? GetText() - { - return Get(DataFormats.Text) as string; - } - + /// + /// Sets a value to the internal store of the data object with as a key. + /// public void Set(string dataFormat, object value) { _items[dataFormat] = value; diff --git a/src/Avalonia.Base/Input/DataObjectExtensions.cs b/src/Avalonia.Base/Input/DataObjectExtensions.cs new file mode 100644 index 0000000000..807c242914 --- /dev/null +++ b/src/Avalonia.Base/Input/DataObjectExtensions.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; +using System.Linq; +using Avalonia.Platform.Storage; + +namespace Avalonia.Input +{ + public static class DataObjectExtensions + { + /// + /// Returns a list of files if the DataObject contains files or filenames. + /// . + /// + /// + /// Collection of storage items - files or folders. If format isn't avaialble, returns null. + /// + public static IEnumerable? GetFiles(this IDataObject dataObject) + { + return dataObject.Get(DataFormats.Files) as IEnumerable; + } + + /// + /// Returns a list of filenames if the DataObject contains filenames. + /// + /// + /// + /// Collection of file names. If format isn't avaialble, returns null. + /// + [System.Obsolete("Use GetFiles, this method is supported only on desktop platforms.")] + public static IEnumerable? GetFileNames(this IDataObject dataObject) + { + return (dataObject.Get(DataFormats.FileNames) as IEnumerable) + ?? dataObject.GetFiles()? + .Select(f => f.TryGetLocalPath()) + .Where(p => !string.IsNullOrEmpty(p)) + .OfType(); + } + + /// + /// Returns the dragged text if the DataObject contains any text. + /// + /// + /// + /// A text string. If format isn't avaialble, returns null. + /// + public static string? GetText(this IDataObject dataObject) + { + return dataObject.Get(DataFormats.Text) as string; + } + } +} diff --git a/src/Avalonia.Base/Input/IDataObject.cs b/src/Avalonia.Base/Input/IDataObject.cs index 1db008aa3a..b6fcd8c7db 100644 --- a/src/Avalonia.Base/Input/IDataObject.cs +++ b/src/Avalonia.Base/Input/IDataObject.cs @@ -1,4 +1,6 @@ using System.Collections.Generic; +using System.Linq; +using Avalonia.Platform.Storage; namespace Avalonia.Input { @@ -19,21 +21,12 @@ namespace Avalonia.Input /// bool Contains(string dataFormat); - /// - /// Returns the dragged text if the DataObject contains any text. - /// - /// - string? GetText(); - - /// - /// Returns a list of filenames if the DataObject contains filenames. - /// - /// - IEnumerable? GetFileNames(); - /// /// Tries to get the data of the given DataFormat. /// + /// + /// Object data. If format isn't avaialble, returns null. + /// object? Get(string dataFormat); } } diff --git a/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFile.cs b/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFile.cs index 5bf9ff9d9a..543fb0ab74 100644 --- a/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFile.cs +++ b/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFile.cs @@ -7,11 +7,6 @@ namespace Avalonia.Platform.Storage.FileIO; internal class BclStorageFile : IStorageBookmarkFile { - public BclStorageFile(string fileName) - { - FileInfo = new FileInfo(fileName); - } - public BclStorageFile(FileInfo fileInfo) { FileInfo = fileInfo ?? throw new ArgumentNullException(nameof(fileInfo)); diff --git a/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFolder.cs b/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFolder.cs index 1e21c197bb..d8e3d91f75 100644 --- a/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFolder.cs +++ b/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFolder.cs @@ -9,15 +9,6 @@ namespace Avalonia.Platform.Storage.FileIO; internal class BclStorageFolder : IStorageBookmarkFolder { - public BclStorageFolder(string path) - { - DirectoryInfo = new DirectoryInfo(path); - if (!DirectoryInfo.Exists) - { - throw new ArgumentException("Directory must exist"); - } - } - public BclStorageFolder(DirectoryInfo directoryInfo) { DirectoryInfo = directoryInfo ?? throw new ArgumentNullException(nameof(directoryInfo)); diff --git a/src/Avalonia.Base/Platform/Storage/FileIO/StorageProviderHelpers.cs b/src/Avalonia.Base/Platform/Storage/FileIO/StorageProviderHelpers.cs index 55e84ee937..a8cbffb417 100644 --- a/src/Avalonia.Base/Platform/Storage/FileIO/StorageProviderHelpers.cs +++ b/src/Avalonia.Base/Platform/Storage/FileIO/StorageProviderHelpers.cs @@ -7,6 +7,23 @@ namespace Avalonia.Platform.Storage.FileIO; internal static class StorageProviderHelpers { + public static IStorageItem? TryCreateBclStorageItem(string path) + { + var directory = new DirectoryInfo(path); + if (directory.Exists) + { + return new BclStorageFolder(directory); + } + + var file = new FileInfo(path); + if (file.Exists) + { + return new BclStorageFile(file); + } + + return null; + } + public static Uri FilePathToUri(string path) { var uriPath = new StringBuilder(path) diff --git a/src/Avalonia.Base/Platform/Storage/PickerOptions.cs b/src/Avalonia.Base/Platform/Storage/PickerOptions.cs index 6f97916a26..ed061aa2d5 100644 --- a/src/Avalonia.Base/Platform/Storage/PickerOptions.cs +++ b/src/Avalonia.Base/Platform/Storage/PickerOptions.cs @@ -12,6 +12,8 @@ public class PickerOptions /// /// Gets or sets the initial location where the file open picker looks for files to present to the user. + /// Can be obtained from previously picked folder or using + /// or . /// public IStorageFolder? SuggestedStartLocation { get; set; } } diff --git a/src/Avalonia.Base/Platform/Storage/StorageProviderExtensions.cs b/src/Avalonia.Base/Platform/Storage/StorageProviderExtensions.cs index 6f8b945cd6..1febb4506a 100644 --- a/src/Avalonia.Base/Platform/Storage/StorageProviderExtensions.cs +++ b/src/Avalonia.Base/Platform/Storage/StorageProviderExtensions.cs @@ -11,12 +11,24 @@ public static class StorageProviderExtensions /// public static Task TryGetFileFromPathAsync(this IStorageProvider provider, string filePath) { + // We can avoid double escaping of the path by checking for BclStorageProvider. + if (provider is BclStorageProvider) + { + return Task.FromResult(StorageProviderHelpers.TryCreateBclStorageItem(filePath) as IStorageFile); + } + return provider.TryGetFileFromPathAsync(StorageProviderHelpers.FilePathToUri(filePath)); } /// public static Task TryGetFolderFromPathAsync(this IStorageProvider provider, string folderPath) { + // We can avoid double escaping of the path by checking for BclStorageProvider. + if (provider is BclStorageProvider) + { + return Task.FromResult(StorageProviderHelpers.TryCreateBclStorageItem(folderPath) as IStorageFolder); + } + return provider.TryGetFolderFromPathAsync(StorageProviderHelpers.FilePathToUri(folderPath)); } diff --git a/src/Avalonia.Native/ClipboardImpl.cs b/src/Avalonia.Native/ClipboardImpl.cs index 9f1c8883aa..5a6b0df801 100644 --- a/src/Avalonia.Native/ClipboardImpl.cs +++ b/src/Avalonia.Native/ClipboardImpl.cs @@ -2,11 +2,11 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using System.Runtime.InteropServices; using Avalonia.Input; using Avalonia.Input.Platform; using Avalonia.Native.Interop; -using Avalonia.Platform.Interop; +using Avalonia.Platform.Storage; +using Avalonia.Platform.Storage.FileIO; namespace Avalonia.Native { @@ -56,8 +56,13 @@ namespace Avalonia.Native { if(fmt.String == NSPasteboardTypeString) rv.Add(DataFormats.Text); - if(fmt.String == NSFilenamesPboardType) - rv.Add(DataFormats.FileNames); + if (fmt.String == NSFilenamesPboardType) + { +#pragma warning disable CS0618 // Type or member is obsolete + rv.Add(DataFormats.FileNames); +#pragma warning restore CS0618 // Type or member is obsolete + rv.Add(DataFormats.Files); + } } } } @@ -74,7 +79,13 @@ namespace Avalonia.Native public IEnumerable GetFileNames() { using (var strings = _native.GetStrings(NSFilenamesPboardType)) - return strings.ToStringArray(); + return strings?.ToStringArray(); + } + + public IEnumerable GetFiles() + { + return GetFileNames()?.Select(f => StorageProviderHelpers.TryCreateBclStorageItem(f)!) + .Where(f => f is not null); } public unsafe Task SetDataObjectAsync(IDataObject data) @@ -102,8 +113,12 @@ namespace Avalonia.Native { if (format == DataFormats.Text) return await GetTextAsync(); +#pragma warning disable CS0618 // Type or member is obsolete if (format == DataFormats.FileNames) return GetFileNames(); +#pragma warning restore CS0618 // Type or member is obsolete + if (format == DataFormats.Files) + return GetFiles(); using (var n = _native.GetBytes(format)) return n.Bytes; } @@ -131,20 +146,16 @@ namespace Avalonia.Native public bool Contains(string dataFormat) => Formats.Contains(dataFormat); - public string GetText() - { - // bad idea in general, but API is synchronous anyway - return _clipboard.GetTextAsync().Result; - } - - public IEnumerable GetFileNames() => _clipboard.GetFileNames(); - public object Get(string dataFormat) { if (dataFormat == DataFormats.Text) - return GetText(); + return _clipboard.GetTextAsync().Result; + if (dataFormat == DataFormats.Files) + return _clipboard.GetFiles(); +#pragma warning disable CS0618 if (dataFormat == DataFormats.FileNames) - return GetFileNames(); +#pragma warning restore CS0618 + return _clipboard.GetFileNames(); return null; } } diff --git a/src/Windows/Avalonia.Win32/ClipboardFormats.cs b/src/Windows/Avalonia.Win32/ClipboardFormats.cs index 5fc4f21b2e..00fdeb2a1d 100644 --- a/src/Windows/Avalonia.Win32/ClipboardFormats.cs +++ b/src/Windows/Avalonia.Win32/ClipboardFormats.cs @@ -29,7 +29,10 @@ namespace Avalonia.Win32 private static readonly List s_formatList = new() { new ClipboardFormat(DataFormats.Text, (ushort)UnmanagedMethods.ClipboardFormat.CF_UNICODETEXT, (ushort)UnmanagedMethods.ClipboardFormat.CF_TEXT), + new ClipboardFormat(DataFormats.Files, (ushort)UnmanagedMethods.ClipboardFormat.CF_HDROP), +#pragma warning disable CS0618 // Type or member is obsolete new ClipboardFormat(DataFormats.FileNames, (ushort)UnmanagedMethods.ClipboardFormat.CF_HDROP), +#pragma warning restore CS0618 // Type or member is obsolete }; diff --git a/src/Windows/Avalonia.Win32/DataObject.cs b/src/Windows/Avalonia.Win32/DataObject.cs index 272300cbf3..a215a0a322 100644 --- a/src/Windows/Avalonia.Win32/DataObject.cs +++ b/src/Windows/Avalonia.Win32/DataObject.cs @@ -10,6 +10,7 @@ using System.Runtime.InteropServices.ComTypes; using System.Runtime.Serialization.Formatters.Binary; using Avalonia.Input; using Avalonia.MicroCom; +using Avalonia.Platform.Storage; using Avalonia.Win32.Interop; using FORMATETC = Avalonia.Win32.Interop.FORMATETC; @@ -124,16 +125,6 @@ namespace Avalonia.Win32 return _wrapped.GetDataFormats(); } - IEnumerable? IDataObject.GetFileNames() - { - return _wrapped.GetFileNames(); - } - - string? IDataObject.GetText() - { - return _wrapped.GetText(); - } - object? IDataObject.Get(string dataFormat) { return _wrapped.Get(dataFormat); @@ -260,8 +251,12 @@ namespace Avalonia.Win32 object data = _wrapped.Get(dataFormat)!; if (dataFormat == DataFormats.Text || data is string) return WriteStringToHGlobal(ref hGlobal, Convert.ToString(data) ?? string.Empty); +#pragma warning disable CS0618 // Type or member is obsolete if (dataFormat == DataFormats.FileNames && data is IEnumerable files) return WriteFileListToHGlobal(ref hGlobal, files); +#pragma warning restore CS0618 // Type or member is obsolete + if (dataFormat == DataFormats.Files && data is IEnumerable items) + return WriteFileListToHGlobal(ref hGlobal, items.Select(f => f.TryGetLocalPath()).Where(f => f is not null)!); if (data is Stream stream) { var length = (int)(stream.Length - stream.Position); diff --git a/src/Windows/Avalonia.Win32/OleDataObject.cs b/src/Windows/Avalonia.Win32/OleDataObject.cs index 247d0340c3..824303b7fa 100644 --- a/src/Windows/Avalonia.Win32/OleDataObject.cs +++ b/src/Windows/Avalonia.Win32/OleDataObject.cs @@ -8,6 +8,7 @@ using System.Runtime.InteropServices; using System.Runtime.InteropServices.ComTypes; using System.Runtime.Serialization.Formatters.Binary; using Avalonia.Input; +using Avalonia.Platform.Storage.FileIO; using Avalonia.Utilities; using Avalonia.Win32.Interop; using MicroCom.Runtime; @@ -34,16 +35,6 @@ namespace Avalonia.Win32 return GetDataFormatsCore().Distinct(); } - public string? GetText() - { - return (string?)GetDataFromOleHGLOBAL(DataFormats.Text, DVASPECT.DVASPECT_CONTENT); - } - - public IEnumerable? GetFileNames() - { - return (IEnumerable?)GetDataFromOleHGLOBAL(DataFormats.FileNames, DVASPECT.DVASPECT_CONTENT); - } - public object? Get(string dataFormat) { return GetDataFromOleHGLOBAL(dataFormat, DVASPECT.DVASPECT_CONTENT); @@ -67,8 +58,15 @@ namespace Avalonia.Win32 { if (format == DataFormats.Text) return ReadStringFromHGlobal(medium.unionmember); +#pragma warning disable CS0618 if (format == DataFormats.FileNames) +#pragma warning restore CS0618 return ReadFileNamesFromHGlobal(medium.unionmember); + if (format == DataFormats.Files) + return ReadFileNamesFromHGlobal(medium.unionmember) + .Select(f => StorageProviderHelpers.TryCreateBclStorageItem(f)!) + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + .Where(f => f is not null); byte[] data = ReadBytesFromHGlobal(medium.unionmember); From dcb73b9fefee2fa5a6509fe1764590a175a6aa36 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Sat, 18 Feb 2023 00:27:40 -0500 Subject: [PATCH 06/17] Browser drag n drop target support --- src/Browser/Avalonia.Browser/AvaloniaView.cs | 55 +++++++++++ .../Avalonia.Browser/BrowserDataObject.cs | 91 +++++++++++++++++++ .../Avalonia.Browser/BrowserTopLevelImpl.cs | 9 ++ src/Browser/Avalonia.Browser/ClipboardImpl.cs | 2 +- .../Interop/AvaloniaModule.cs | 21 +++-- .../Interop/GeneralHelpers.cs | 22 +++++ .../Avalonia.Browser/Interop/InputHelper.cs | 5 +- .../Avalonia.Browser/Interop/StorageHelper.cs | 3 + .../Storage/BrowserStorageProvider.cs | 16 ++-- .../webapp/modules/avalonia.ts | 4 +- .../webapp/modules/avalonia/generalHelpers.ts | 19 ++++ .../webapp/modules/avalonia/input.ts | 22 +++++ .../webapp/modules/storage/storageItem.ts | 42 ++++++++- .../webapp/modules/storage/storageProvider.ts | 8 +- 14 files changed, 289 insertions(+), 30 deletions(-) create mode 100644 src/Browser/Avalonia.Browser/BrowserDataObject.cs create mode 100644 src/Browser/Avalonia.Browser/Interop/GeneralHelpers.cs create mode 100644 src/Browser/Avalonia.Browser/webapp/modules/avalonia/generalHelpers.ts diff --git a/src/Browser/Avalonia.Browser/AvaloniaView.cs b/src/Browser/Avalonia.Browser/AvaloniaView.cs index 3bb7260e55..76947c949c 100644 --- a/src/Browser/Avalonia.Browser/AvaloniaView.cs +++ b/src/Browser/Avalonia.Browser/AvaloniaView.cs @@ -106,6 +106,8 @@ namespace Avalonia.Browser InputHelper.SubscribePointerEvents(_containerElement, OnPointerMove, OnPointerDown, OnPointerUp, OnPointerCancel, OnWheel); + InputHelper.SubscribeDropEvents(_containerElement, OnDragEvent); + var skiaOptions = AvaloniaLocator.Current.GetService(); _dpi = DomHelper.ObserveDpi(OnDpiChanged); @@ -293,6 +295,59 @@ namespace Avalonia.Browser return modifiers; } + public bool OnDragEvent(JSObject args) + { + var eventType = args?.GetPropertyAsString("type") switch + { + "dragenter" => RawDragEventType.DragEnter, + "dragover" => RawDragEventType.DragOver, + "dragleave" => RawDragEventType.DragLeave, + "drop" => RawDragEventType.Drop, + _ => (RawDragEventType)(int)-1 + }; + var dataObject = args?.GetPropertyAsJSObject("dataTransfer"); + if (args is null || eventType < 0 || dataObject is null) + { + return false; + } + + // If file is dropped, we need storage js to be referenced. + // TODO: restructure JS files, so it's not needed. + _ = AvaloniaModule.ImportStorage(); + + var position = new Point(args.GetPropertyAsDouble("offsetX"), args.GetPropertyAsDouble("offsetY")); + var modifiers = GetModifiers(args); + + var effectAllowedStr = dataObject.GetPropertyAsString("effectAllowed") ?? "none"; + var effectAllowed = DragDropEffects.None; + if (effectAllowedStr.Contains("copy", StringComparison.OrdinalIgnoreCase)) + { + effectAllowed |= DragDropEffects.Copy; + } + if (effectAllowedStr.Contains("link", StringComparison.OrdinalIgnoreCase)) + { + effectAllowed |= DragDropEffects.Link; + } + if (effectAllowedStr.Contains("move", StringComparison.OrdinalIgnoreCase)) + { + effectAllowed |= DragDropEffects.Move; + } + if (effectAllowedStr.Equals("all", StringComparison.OrdinalIgnoreCase)) + { + effectAllowed |= DragDropEffects.Move | DragDropEffects.Copy | DragDropEffects.Link; + } + if (effectAllowed == DragDropEffects.None) + { + return false; + } + + var dropEffect = _topLevelImpl.RawDragEvent(eventType, position, modifiers, new BrowserDataObject(dataObject), effectAllowed); + dataObject.SetProperty("dropEffect", dropEffect.ToString().ToLowerInvariant()); + + return eventType is RawDragEventType.Drop or RawDragEventType.DragOver + && dropEffect != DragDropEffects.None; + } + private bool OnKeyDown (string code, string key, int modifier) { var handled = _topLevelImpl.RawKeyboardEvent(RawKeyEventType.KeyDown, code, key, (RawInputModifiers)modifier); diff --git a/src/Browser/Avalonia.Browser/BrowserDataObject.cs b/src/Browser/Avalonia.Browser/BrowserDataObject.cs new file mode 100644 index 0000000000..f1e30ee3fe --- /dev/null +++ b/src/Browser/Avalonia.Browser/BrowserDataObject.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices.JavaScript; +using Avalonia.Browser.Interop; +using Avalonia.Browser.Storage; +using Avalonia.Input; +using Avalonia.Platform.Storage; + +namespace Avalonia.Browser; + +internal class BrowserDataObject : IDataObject +{ + private readonly JSObject _dataObject; + + public BrowserDataObject(JSObject dataObject) + { + _dataObject = dataObject; + } + + public IEnumerable GetDataFormats() + { + var types = new HashSet(_dataObject.GetPropertyAsStringArray("types")); + var dataFormats = new HashSet(types.Count); + + foreach (var type in types) + { + if (type.StartsWith("text/", StringComparison.Ordinal)) + { + dataFormats.Add(DataFormats.Text); + } + else if (type.Equals("Files", StringComparison.Ordinal)) + { + dataFormats.Add(DataFormats.Files); + } + dataFormats.Add(type); + } + + // If drag'n'drop an image from the another web page, if won't add "Files" to the supported types, but only a "text/uri-list". + // With "text/uri-list" browser can add actual file as well. + var filesCount = _dataObject.GetPropertyAsJSObject("files")?.GetPropertyAsInt32("count"); + if (filesCount > 0) + { + dataFormats.Add(DataFormats.Files); + } + + return dataFormats; + } + + public bool Contains(string dataFormat) + { + return GetDataFormats().Contains(dataFormat); + } + + public object? Get(string dataFormat) + { + if (dataFormat == DataFormats.Files) + { + var files = _dataObject.GetPropertyAsJSObject("files"); + if (files is not null) + { + return StorageHelper.FilesToItemsArray(files) + .Select(reference => reference.GetPropertyAsString("kind") switch + { + "directory" => (IStorageItem)new JSStorageFolder(reference), + "file" => new JSStorageFile(reference), + _ => null + }) + .Where(i => i is not null) + .ToArray()!; + } + + return null; + } + + if (dataFormat == DataFormats.Text) + { + if (_dataObject.CallMethodString("getData", "text/plain") is { Length :> 0 } textData) + { + return textData; + } + } + + if (_dataObject.CallMethodString("getData", dataFormat) is { Length: > 0 } data) + { + return data; + } + + return null; + } +} diff --git a/src/Browser/Avalonia.Browser/BrowserTopLevelImpl.cs b/src/Browser/Avalonia.Browser/BrowserTopLevelImpl.cs index f1cd441f45..1bf4636f61 100644 --- a/src/Browser/Avalonia.Browser/BrowserTopLevelImpl.cs +++ b/src/Browser/Avalonia.Browser/BrowserTopLevelImpl.cs @@ -164,6 +164,15 @@ namespace Avalonia.Browser return false; } + + public DragDropEffects RawDragEvent(RawDragEventType eventType, Point position, RawInputModifiers modifiers, BrowserDataObject dataObject, DragDropEffects dropEffect) + { + var device = AvaloniaLocator.Current.GetRequiredService(); + var eventArgs = new RawDragEvent(device, eventType, _inputRoot!, position, dataObject, dropEffect, modifiers); + Console.WriteLine($"{eventArgs.Location} {eventArgs.Effects} {eventArgs.Type} {eventArgs.KeyModifiers}"); + Input?.Invoke(eventArgs); + return eventArgs.Effects; + } public void Dispose() { diff --git a/src/Browser/Avalonia.Browser/ClipboardImpl.cs b/src/Browser/Avalonia.Browser/ClipboardImpl.cs index b94fe2df9e..c4f5e90777 100644 --- a/src/Browser/Avalonia.Browser/ClipboardImpl.cs +++ b/src/Browser/Avalonia.Browser/ClipboardImpl.cs @@ -24,6 +24,6 @@ namespace Avalonia.Browser public Task GetFormatsAsync() => Task.FromResult(Array.Empty()); - public Task GetDataAsync(string format) => Task.FromResult(new()); + public Task GetDataAsync(string format) => Task.FromResult(null); } } diff --git a/src/Browser/Avalonia.Browser/Interop/AvaloniaModule.cs b/src/Browser/Avalonia.Browser/Interop/AvaloniaModule.cs index f1936a8d97..394f191dab 100644 --- a/src/Browser/Avalonia.Browser/Interop/AvaloniaModule.cs +++ b/src/Browser/Avalonia.Browser/Interop/AvaloniaModule.cs @@ -1,24 +1,29 @@ -using System.Runtime.InteropServices.JavaScript; +using System; +using System.Runtime.InteropServices.JavaScript; using System.Threading.Tasks; namespace Avalonia.Browser.Interop; internal static partial class AvaloniaModule { - public const string MainModuleName = "avalonia"; - public const string StorageModuleName = "storage"; - - public static Task ImportMain() + private static readonly Lazy s_importMain = new(() => { var options = AvaloniaLocator.Current.GetService() ?? new BrowserPlatformOptions(); return JSHost.ImportAsync(MainModuleName, options.FrameworkAssetPathResolver!("avalonia.js")); - } + }); - public static Task ImportStorage() + private static readonly Lazy s_importStorage = new(() => { var options = AvaloniaLocator.Current.GetService() ?? new BrowserPlatformOptions(); return JSHost.ImportAsync(StorageModuleName, options.FrameworkAssetPathResolver!("storage.js")); - } + }); + + public const string MainModuleName = "avalonia"; + public const string StorageModuleName = "storage"; + + public static Task ImportMain() => s_importMain.Value; + + public static Task ImportStorage() => s_importStorage.Value; [JSImport("Caniuse.isMobile", AvaloniaModule.MainModuleName)] public static partial bool IsMobile(); diff --git a/src/Browser/Avalonia.Browser/Interop/GeneralHelpers.cs b/src/Browser/Avalonia.Browser/Interop/GeneralHelpers.cs new file mode 100644 index 0000000000..6e3b41c05b --- /dev/null +++ b/src/Browser/Avalonia.Browser/Interop/GeneralHelpers.cs @@ -0,0 +1,22 @@ +using System.Runtime.InteropServices.JavaScript; + +namespace Avalonia.Browser.Interop; + +internal static partial class GeneralHelpers +{ + [JSImport("GeneralHelpers.itemsArrayAt", AvaloniaModule.MainModuleName)] + public static partial JSObject[] ItemsArrayAt(JSObject jsObject, string key); + public static JSObject[] GetPropertyAsJSObjectArray(this JSObject jsObject, string key) => ItemsArrayAt(jsObject, key); + + [JSImport("GeneralHelpers.itemsArrayAt", AvaloniaModule.MainModuleName)] + public static partial string[] ItemsArrayAtAsStrings(JSObject jsObject, string key); + public static string[] GetPropertyAsStringArray(this JSObject jsObject, string key) => ItemsArrayAtAsStrings(jsObject, key); + + [JSImport("GeneralHelpers.callMethod", AvaloniaModule.MainModuleName)] + public static partial string IntCallMethodString(JSObject jsObject, string name); + [JSImport("GeneralHelpers.callMethod", AvaloniaModule.MainModuleName)] + public static partial string IntCallMethodStringString(JSObject jsObject, string name, string arg1); + + public static string CallMethodString(this JSObject jsObject, string name) => IntCallMethodString(jsObject, name); + public static string CallMethodString(this JSObject jsObject, string name, string arg1) => IntCallMethodStringString(jsObject, name, arg1); +} diff --git a/src/Browser/Avalonia.Browser/Interop/InputHelper.cs b/src/Browser/Avalonia.Browser/Interop/InputHelper.cs index a816e39da8..a978c18f9b 100644 --- a/src/Browser/Avalonia.Browser/Interop/InputHelper.cs +++ b/src/Browser/Avalonia.Browser/Interop/InputHelper.cs @@ -43,13 +43,16 @@ internal static partial class InputHelper [JSMarshalAs>] Func wheel); - [JSImport("InputHelper.subscribeInputEvents", AvaloniaModule.MainModuleName)] public static partial void SubscribeInputEvents( JSObject htmlElement, [JSMarshalAs>] Func input); + [JSImport("InputHelper.subscribeDropEvents", AvaloniaModule.MainModuleName)] + public static partial void SubscribeDropEvents(JSObject containerElement, + [JSMarshalAs>] Func dragEvent); + [JSImport("InputHelper.getCoalescedEvents", AvaloniaModule.MainModuleName)] [return: JSMarshalAs>] public static partial JSObject[] GetCoalescedEvents(JSObject pointerEvent); diff --git a/src/Browser/Avalonia.Browser/Interop/StorageHelper.cs b/src/Browser/Avalonia.Browser/Interop/StorageHelper.cs index 11beba6f2c..2d96ee8d1f 100644 --- a/src/Browser/Avalonia.Browser/Interop/StorageHelper.cs +++ b/src/Browser/Avalonia.Browser/Interop/StorageHelper.cs @@ -46,6 +46,9 @@ internal static partial class StorageHelper [JSImport("StorageItems.itemsArray", AvaloniaModule.StorageModuleName)] public static partial JSObject[] ItemsArray(JSObject item); + + [JSImport("StorageItems.filesToItemsArray", AvaloniaModule.StorageModuleName)] + public static partial JSObject[] FilesToItemsArray(JSObject item); [JSImport("StorageProvider.createAcceptType", AvaloniaModule.StorageModuleName)] public static partial JSObject CreateAcceptType(string description, string[] mimeTypes, string[]? extensions); diff --git a/src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs b/src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs index 5b76d53a9d..fc32b3b4f7 100644 --- a/src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs +++ b/src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs @@ -1,10 +1,8 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Runtime.InteropServices.JavaScript; -using System.Runtime.Versioning; using System.Threading.Tasks; using Avalonia.Browser.Interop; using Avalonia.Platform.Storage; @@ -18,15 +16,13 @@ internal class BrowserStorageProvider : IStorageProvider internal const string PickerCancelMessage = "The user aborted a request"; internal const string NoPermissionsMessage = "Permissions denied"; - private readonly Lazy _lazyModule = new(() => AvaloniaModule.ImportStorage()); - public bool CanOpen => true; public bool CanSave => StorageHelper.HasNativeFilePicker(); public bool CanPickFolder => true; public async Task> OpenFilePickerAsync(FilePickerOpenOptions options) { - await _lazyModule.Value; + await AvaloniaModule.ImportStorage(); var startIn = (options.SuggestedStartLocation as JSStorageItem)?.FileHandle; var (types, excludeAll) = ConvertFileTypes(options.FileTypeFilter); @@ -60,7 +56,7 @@ internal class BrowserStorageProvider : IStorageProvider public async Task SaveFilePickerAsync(FilePickerSaveOptions options) { - await _lazyModule.Value; + await AvaloniaModule.ImportStorage(); var startIn = (options.SuggestedStartLocation as JSStorageItem)?.FileHandle; var (types, excludeAll) = ConvertFileTypes(options.FileTypeChoices); @@ -88,7 +84,7 @@ internal class BrowserStorageProvider : IStorageProvider public async Task> OpenFolderPickerAsync(FolderPickerOpenOptions options) { - await _lazyModule.Value; + await AvaloniaModule.ImportStorage(); var startIn = (options.SuggestedStartLocation as JSStorageItem)?.FileHandle; try @@ -104,14 +100,14 @@ internal class BrowserStorageProvider : IStorageProvider public async Task OpenFileBookmarkAsync(string bookmark) { - await _lazyModule.Value; + await AvaloniaModule.ImportStorage(); var item = await StorageHelper.OpenBookmark(bookmark); return item is not null ? new JSStorageFile(item) : null; } public async Task OpenFolderBookmarkAsync(string bookmark) { - await _lazyModule.Value; + await AvaloniaModule.ImportStorage(); var item = await StorageHelper.OpenBookmark(bookmark); return item is not null ? new JSStorageFolder(item) : null; } @@ -128,7 +124,7 @@ internal class BrowserStorageProvider : IStorageProvider public async Task TryGetWellKnownFolderAsync(WellKnownFolder wellKnownFolder) { - await _lazyModule.Value; + await AvaloniaModule.ImportStorage(); var directory = StorageHelper.CreateWellKnownDirectory(wellKnownFolder switch { WellKnownFolder.Desktop => "desktop", diff --git a/src/Browser/Avalonia.Browser/webapp/modules/avalonia.ts b/src/Browser/Avalonia.Browser/webapp/modules/avalonia.ts index 3fb4124c96..80faca7a50 100644 --- a/src/Browser/Avalonia.Browser/webapp/modules/avalonia.ts +++ b/src/Browser/Avalonia.Browser/webapp/modules/avalonia.ts @@ -5,6 +5,7 @@ import { Caniuse } from "./avalonia/caniuse"; import { StreamHelper } from "./avalonia/stream"; import { NativeControlHost } from "./avalonia/nativeControlHost"; import { NavigationHelper } from "./avalonia/navigationHelper"; +import { GeneralHelpers } from "./avalonia/generalHelpers"; export { Caniuse, @@ -15,5 +16,6 @@ export { AvaloniaDOM, StreamHelper, NativeControlHost, - NavigationHelper + NavigationHelper, + GeneralHelpers }; diff --git a/src/Browser/Avalonia.Browser/webapp/modules/avalonia/generalHelpers.ts b/src/Browser/Avalonia.Browser/webapp/modules/avalonia/generalHelpers.ts new file mode 100644 index 0000000000..fa001006ab --- /dev/null +++ b/src/Browser/Avalonia.Browser/webapp/modules/avalonia/generalHelpers.ts @@ -0,0 +1,19 @@ +export class GeneralHelpers { + public static itemsArrayAt(instance: any, key: string): any[] { + const items = instance[key]; + if (!items) { + return []; + } + + const retItems = []; + for (let i = 0; i < items.length; i++) { + retItems[i] = items[i]; + } + return retItems; + } + + public static callMethod(instance: any, name: string /*, args */): any { + const args = Array.prototype.slice.call(arguments, 2); + return instance[name].apply(instance, args); + } +} diff --git a/src/Browser/Avalonia.Browser/webapp/modules/avalonia/input.ts b/src/Browser/Avalonia.Browser/webapp/modules/avalonia/input.ts index 0f0e5eb512..fb94352192 100644 --- a/src/Browser/Avalonia.Browser/webapp/modules/avalonia/input.ts +++ b/src/Browser/Avalonia.Browser/webapp/modules/avalonia/input.ts @@ -174,6 +174,28 @@ export class InputHelper { }; } + public static subscribeDropEvents( + element: HTMLInputElement, + dragEvent: (args: any) => boolean + ) { + const dragHandler = (args: Event) => { + if (dragEvent(args as any)) { + args.preventDefault(); + } + }; + element.addEventListener("dragover", dragHandler); + element.addEventListener("dragenter", dragHandler); + element.addEventListener("dragleave", dragHandler); + element.addEventListener("drop", dragHandler); + + return () => { + element.removeEventListener("dragover", dragHandler); + element.removeEventListener("dragenter", dragHandler); + element.removeEventListener("dragleave", dragHandler); + element.removeEventListener("drop", dragHandler); + }; + } + public static getCoalescedEvents(pointerEvent: PointerEvent): PointerEvent[] { return pointerEvent.getCoalescedEvents(); } diff --git a/src/Browser/Avalonia.Browser/webapp/modules/storage/storageItem.ts b/src/Browser/Avalonia.Browser/webapp/modules/storage/storageItem.ts index 8f47e61100..f444717094 100644 --- a/src/Browser/Avalonia.Browser/webapp/modules/storage/storageItem.ts +++ b/src/Browser/Avalonia.Browser/webapp/modules/storage/storageItem.ts @@ -3,8 +3,9 @@ import { FileSystemFileHandle, FileSystemDirectoryHandle, FileSystemWritableFile import { Caniuse } from "../avalonia"; export class StorageItem { - constructor( + private constructor( public handle?: FileSystemFileHandle | FileSystemDirectoryHandle, + private readonly file?: File, private readonly bookmarkId?: string, public wellKnownType?: WellKnownDirectory ) { @@ -14,6 +15,9 @@ export class StorageItem { if (this.handle) { return this.handle.name; } + if (this.file) { + return this.file.name; + } return this.wellKnownType ?? ""; } @@ -21,14 +25,29 @@ export class StorageItem { if (this.handle) { return this.handle.kind; } + if (this.file) { + return "file"; + } return "directory"; } + public static createFromHandle(handle: FileSystemFileHandle | FileSystemDirectoryHandle, bookmarkId?: string) { + return new StorageItem(handle, undefined, bookmarkId, undefined); + } + + public static createFromFile(file: File) { + return new StorageItem(undefined, file, undefined, undefined); + } + public static createWellKnownDirectory(type: WellKnownDirectory) { - return new StorageItem(undefined, undefined, type); + return new StorageItem(undefined, undefined, undefined, type); } public static async openRead(item: StorageItem): Promise { + if (item.file) { + return item.file; + } + if (!item.handle || item.kind !== "file") { throw new Error("StorageItem is not a file"); } @@ -41,7 +60,7 @@ export class StorageItem { public static async openWrite(item: StorageItem): Promise { if (!item.handle || item.kind !== "file") { - throw new Error("StorageItem is not a file"); + throw new Error("StorageItem is not a writeable file"); } await item.verityPermissions("readwrite"); @@ -52,8 +71,9 @@ export class StorageItem { public static async getProperties(item: StorageItem): Promise<{ Size: number; LastModified: number; Type: string } | null> { // getFile can fail with an exception depending if we use polyfill with a save file dialog or not. try { - const file = item.handle instanceof FileSystemFileHandle && - await item.handle.getFile(); + const file = item.handle && "getFile" in item.handle + ? await item.handle.getFile() + : item.file; if (!file) { return null; @@ -144,4 +164,16 @@ export class StorageItems { public static itemsArray(instance: StorageItems): StorageItem[] { return instance.items; } + + public static filesToItemsArray(files: File[]): StorageItem[] { + if (!files) { + return []; + } + + const retItems = []; + for (let i = 0; i < files.length; i++) { + retItems[i] = StorageItem.createFromFile(files[i]); + } + return retItems; + } } diff --git a/src/Browser/Avalonia.Browser/webapp/modules/storage/storageProvider.ts b/src/Browser/Avalonia.Browser/webapp/modules/storage/storageProvider.ts index 750c38b8ea..7a29992674 100644 --- a/src/Browser/Avalonia.Browser/webapp/modules/storage/storageProvider.ts +++ b/src/Browser/Avalonia.Browser/webapp/modules/storage/storageProvider.ts @@ -19,7 +19,7 @@ export class StorageProvider { }; const handle = await showDirectoryPicker(options as any); - return new StorageItem(handle); + return StorageItem.createFromHandle(handle); } public static async openFileDialog( @@ -33,7 +33,7 @@ export class StorageProvider { }; const handles = await showOpenFilePicker(options); - return new StorageItems(handles.map((handle: FileSystemFileHandle) => new StorageItem(handle))); + return new StorageItems(handles.map((handle: FileSystemFileHandle) => StorageItem.createFromHandle(handle))); } public static async saveFileDialog( @@ -48,14 +48,14 @@ export class StorageProvider { // Always prefer native save file picker, as polyfill solutions are not reliable. const handle = await (globalThis as any).showSaveFilePicker(options); - return new StorageItem(handle); + return StorageItem.createFromHandle(handle); } public static async openBookmark(key: string): Promise { const connection = await avaloniaDb.connect(); try { const handle = await connection.get(fileBookmarksStore, key); - return handle && new StorageItem(handle, key); + return handle && StorageItem.createFromHandle(handle, key); } finally { connection.close(); } From b360902d3fd461f048c973525c9abc015ae85432 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Sat, 18 Feb 2023 01:33:57 -0500 Subject: [PATCH 07/17] Typo --- src/Avalonia.Base/Input/DataObjectExtensions.cs | 6 +++--- src/Avalonia.Base/Input/IDataObject.cs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Avalonia.Base/Input/DataObjectExtensions.cs b/src/Avalonia.Base/Input/DataObjectExtensions.cs index 807c242914..6af531b0d8 100644 --- a/src/Avalonia.Base/Input/DataObjectExtensions.cs +++ b/src/Avalonia.Base/Input/DataObjectExtensions.cs @@ -11,7 +11,7 @@ namespace Avalonia.Input /// . /// /// - /// Collection of storage items - files or folders. If format isn't avaialble, returns null. + /// Collection of storage items - files or folders. If format isn't available, returns null. /// public static IEnumerable? GetFiles(this IDataObject dataObject) { @@ -23,7 +23,7 @@ namespace Avalonia.Input /// /// /// - /// Collection of file names. If format isn't avaialble, returns null. + /// Collection of file names. If format isn't available, returns null. /// [System.Obsolete("Use GetFiles, this method is supported only on desktop platforms.")] public static IEnumerable? GetFileNames(this IDataObject dataObject) @@ -40,7 +40,7 @@ namespace Avalonia.Input /// /// /// - /// A text string. If format isn't avaialble, returns null. + /// A text string. If format isn't available, returns null. /// public static string? GetText(this IDataObject dataObject) { diff --git a/src/Avalonia.Base/Input/IDataObject.cs b/src/Avalonia.Base/Input/IDataObject.cs index b6fcd8c7db..6ccd0a8499 100644 --- a/src/Avalonia.Base/Input/IDataObject.cs +++ b/src/Avalonia.Base/Input/IDataObject.cs @@ -25,7 +25,7 @@ namespace Avalonia.Input /// Tries to get the data of the given DataFormat. /// /// - /// Object data. If format isn't avaialble, returns null. + /// Object data. If format isn't available, returns null. /// object? Get(string dataFormat); } From cc1cf2003a62fc6653a392f094b978a162e9e79b Mon Sep 17 00:00:00 2001 From: robloo Date: Sat, 18 Feb 2023 20:29:22 -0500 Subject: [PATCH 08/17] Separate out SplitView types and move to SplitView directory --- .../{ => SplitView}/SplitView.cs | 62 ------------------- .../SplitView/SplitViewDisplayMode.cs | 29 +++++++++ .../SplitViewPaneClosingEventArgs.cs | 0 .../SplitView/SplitViewPanePlacement.cs | 18 ++++++ .../SplitView/SplitViewTemplateSettings.cs | 32 ++++++++++ 5 files changed, 79 insertions(+), 62 deletions(-) rename src/Avalonia.Controls/{ => SplitView}/SplitView.cs (89%) create mode 100644 src/Avalonia.Controls/SplitView/SplitViewDisplayMode.cs rename src/Avalonia.Controls/{ => SplitView}/SplitViewPaneClosingEventArgs.cs (100%) create mode 100644 src/Avalonia.Controls/SplitView/SplitViewPanePlacement.cs create mode 100644 src/Avalonia.Controls/SplitView/SplitViewTemplateSettings.cs diff --git a/src/Avalonia.Controls/SplitView.cs b/src/Avalonia.Controls/SplitView/SplitView.cs similarity index 89% rename from src/Avalonia.Controls/SplitView.cs rename to src/Avalonia.Controls/SplitView/SplitView.cs index b45e0f2adb..35faf8605d 100644 --- a/src/Avalonia.Controls/SplitView.cs +++ b/src/Avalonia.Controls/SplitView/SplitView.cs @@ -11,68 +11,6 @@ using Avalonia.Metadata; namespace Avalonia.Controls { - /// - /// Defines constants for how the SplitView Pane should display - /// - public enum SplitViewDisplayMode - { - /// - /// Pane is displayed next to content, and does not auto collapse - /// when tapped outside - /// - Inline, - /// - /// Pane is displayed next to content. When collapsed, pane is still - /// visible according to CompactPaneLength. Pane does not auto collapse - /// when tapped outside - /// - CompactInline, - /// - /// Pane is displayed above content. Pane collapses when tapped outside - /// - Overlay, - /// - /// Pane is displayed above content. When collapsed, pane is still - /// visible according to CompactPaneLength. Pane collapses when tapped outside - /// - CompactOverlay - } - - /// - /// Defines constants for where the Pane should appear - /// - public enum SplitViewPanePlacement - { - Left, - Right - } - - public class SplitViewTemplateSettings : AvaloniaObject - { - internal SplitViewTemplateSettings() { } - - public static readonly StyledProperty ClosedPaneWidthProperty = - AvaloniaProperty.Register(nameof(ClosedPaneWidth), - 0d); - - public static readonly StyledProperty PaneColumnGridLengthProperty = - AvaloniaProperty.Register( - nameof(PaneColumnGridLength)); - - public double ClosedPaneWidth - { - get => GetValue(ClosedPaneWidthProperty); - internal set => SetValue(ClosedPaneWidthProperty, value); - } - - public GridLength PaneColumnGridLength - { - get => GetValue(PaneColumnGridLengthProperty); - internal set => SetValue(PaneColumnGridLengthProperty, value); - } - } - /// /// A control with two views: A collapsible pane and an area for content /// diff --git a/src/Avalonia.Controls/SplitView/SplitViewDisplayMode.cs b/src/Avalonia.Controls/SplitView/SplitViewDisplayMode.cs new file mode 100644 index 0000000000..6333f96f86 --- /dev/null +++ b/src/Avalonia.Controls/SplitView/SplitViewDisplayMode.cs @@ -0,0 +1,29 @@ +namespace Avalonia.Controls +{ + /// + /// Defines constants for how the SplitView Pane should display + /// + public enum SplitViewDisplayMode + { + /// + /// Pane is displayed next to content, and does not auto collapse + /// when tapped outside + /// + Inline, + /// + /// Pane is displayed next to content. When collapsed, pane is still + /// visible according to CompactPaneLength. Pane does not auto collapse + /// when tapped outside + /// + CompactInline, + /// + /// Pane is displayed above content. Pane collapses when tapped outside + /// + Overlay, + /// + /// Pane is displayed above content. When collapsed, pane is still + /// visible according to CompactPaneLength. Pane collapses when tapped outside + /// + CompactOverlay + } +} diff --git a/src/Avalonia.Controls/SplitViewPaneClosingEventArgs.cs b/src/Avalonia.Controls/SplitView/SplitViewPaneClosingEventArgs.cs similarity index 100% rename from src/Avalonia.Controls/SplitViewPaneClosingEventArgs.cs rename to src/Avalonia.Controls/SplitView/SplitViewPaneClosingEventArgs.cs diff --git a/src/Avalonia.Controls/SplitView/SplitViewPanePlacement.cs b/src/Avalonia.Controls/SplitView/SplitViewPanePlacement.cs new file mode 100644 index 0000000000..62c5387192 --- /dev/null +++ b/src/Avalonia.Controls/SplitView/SplitViewPanePlacement.cs @@ -0,0 +1,18 @@ +namespace Avalonia.Controls +{ + /// + /// Defines constants for where the Pane should appear + /// + public enum SplitViewPanePlacement + { + /// + /// The pane is shown to the left of content. + /// + Left, + + /// + /// The pane is shown to the right of content. + /// + Right + } +} diff --git a/src/Avalonia.Controls/SplitView/SplitViewTemplateSettings.cs b/src/Avalonia.Controls/SplitView/SplitViewTemplateSettings.cs new file mode 100644 index 0000000000..f2cbf55986 --- /dev/null +++ b/src/Avalonia.Controls/SplitView/SplitViewTemplateSettings.cs @@ -0,0 +1,32 @@ +namespace Avalonia.Controls.Primitives +{ + /// + /// Provides calculated values for use with the 's control theme or template. + /// This class is NOT intended for general use. + /// + public class SplitViewTemplateSettings : AvaloniaObject + { + internal SplitViewTemplateSettings() { } + + public static readonly StyledProperty ClosedPaneWidthProperty = + AvaloniaProperty.Register(nameof(ClosedPaneWidth), + 0d); + + public static readonly StyledProperty PaneColumnGridLengthProperty = + AvaloniaProperty.Register( + nameof(PaneColumnGridLength)); + + public double ClosedPaneWidth + { + get => GetValue(ClosedPaneWidthProperty); + internal set => SetValue(ClosedPaneWidthProperty, value); + } + + public GridLength PaneColumnGridLength + { + get => GetValue(PaneColumnGridLengthProperty); + internal set => SetValue(PaneColumnGridLengthProperty, value); + } + } +} From 11f09232d3bf7b376f0ff7091365800f6266df7e Mon Sep 17 00:00:00 2001 From: robloo Date: Sat, 18 Feb 2023 20:59:36 -0500 Subject: [PATCH 09/17] Use OnPropertyChanged override method for all SplitView change handling --- src/Avalonia.Controls/SplitView/SplitView.cs | 135 +++++++++---------- 1 file changed, 65 insertions(+), 70 deletions(-) diff --git a/src/Avalonia.Controls/SplitView/SplitView.cs b/src/Avalonia.Controls/SplitView/SplitView.cs index 35faf8605d..da8190d764 100644 --- a/src/Avalonia.Controls/SplitView/SplitView.cs +++ b/src/Avalonia.Controls/SplitView/SplitView.cs @@ -110,12 +110,6 @@ namespace Avalonia.Controls static SplitView() { - UseLightDismissOverlayModeProperty.Changed.AddClassHandler((x, v) => x.OnUseLightDismissChanged(v)); - CompactPaneLengthProperty.Changed.AddClassHandler((x, v) => x.OnCompactPaneLengthChanged(v)); - PanePlacementProperty.Changed.AddClassHandler((x, v) => x.OnPanePlacementChanged(v)); - DisplayModeProperty.Changed.AddClassHandler((x, v) => x.OnDisplayModeChanged(v)); - - PaneProperty.Changed.AddClassHandler((x, e) => x.OnPaneChanged(e)); } /// @@ -273,7 +267,40 @@ namespace Avalonia.Controls { base.OnPropertyChanged(change); - if (change.Property == IsPaneOpenProperty) + if (change.Property == CompactPaneLengthProperty) + { + var newLen = change.GetNewValue(); + var displayMode = DisplayMode; + if (displayMode == SplitViewDisplayMode.CompactInline) + { + TemplateSettings.ClosedPaneWidth = newLen; + } + else if (displayMode == SplitViewDisplayMode.CompactOverlay) + { + TemplateSettings.ClosedPaneWidth = newLen; + TemplateSettings.PaneColumnGridLength = new GridLength(newLen, GridUnitType.Pixel); + } + } + else if (change.Property == DisplayModeProperty) + { + var oldState = GetPseudoClass(change.GetOldValue()); + var newState = GetPseudoClass(change.GetNewValue()); + + PseudoClasses.Remove($":{oldState}"); + PseudoClasses.Add($":{newState}"); + + var (closedPaneWidth, paneColumnGridLength) = change.GetNewValue() switch + { + SplitViewDisplayMode.Overlay => (0, new GridLength(0, GridUnitType.Pixel)), + SplitViewDisplayMode.CompactOverlay => (CompactPaneLength, new GridLength(CompactPaneLength, GridUnitType.Pixel)), + SplitViewDisplayMode.Inline => (0, new GridLength(0, GridUnitType.Auto)), + SplitViewDisplayMode.CompactInline => (CompactPaneLength, new GridLength(0, GridUnitType.Auto)), + _ => throw new NotImplementedException(), + }; + TemplateSettings.ClosedPaneWidth = closedPaneWidth; + TemplateSettings.PaneColumnGridLength = paneColumnGridLength; + } + else if (change.Property == IsPaneOpenProperty) { bool isPaneOpen = change.GetNewValue(); @@ -290,6 +317,30 @@ namespace Avalonia.Controls OnPaneClosed(EventArgs.Empty); } } + else if (change.Property == PaneProperty) + { + if (change.OldValue is ILogical oldChild) + { + LogicalChildren.Remove(oldChild); + } + + if (change.NewValue is ILogical newChild) + { + LogicalChildren.Add(newChild); + } + } + else if (change.Property == PanePlacementProperty) + { + var oldState = GetPseudoClass(change.GetOldValue()); + var newState = GetPseudoClass(change.GetNewValue()); + PseudoClasses.Remove($":{oldState}"); + PseudoClasses.Add($":{newState}"); + } + else if (change.Property == UseLightDismissOverlayModeProperty) + { + var mode = change.GetNewValue(); + PseudoClasses.Set(":lightdismiss", mode); + } } private void PointerPressedOutside(object? sender, PointerPressedEventArgs e) @@ -325,7 +376,7 @@ namespace Avalonia.Controls } if (closePane) { - IsPaneOpen = false; + SetCurrentValue(IsPaneOpenProperty, false); e.Handled = true; } } @@ -355,21 +406,9 @@ namespace Avalonia.Controls PaneClosed?.Invoke(this, args); } - private void OnCompactPaneLengthChanged(AvaloniaPropertyChangedEventArgs e) - { - var newLen = (double)e.NewValue!; - var displayMode = DisplayMode; - if (displayMode == SplitViewDisplayMode.CompactInline) - { - TemplateSettings.ClosedPaneWidth = newLen; - } - else if (displayMode == SplitViewDisplayMode.CompactOverlay) - { - TemplateSettings.ClosedPaneWidth = newLen; - TemplateSettings.PaneColumnGridLength = new GridLength(newLen, GridUnitType.Pixel); - } - } - + /// + /// Gets the appropriate PseudoClass for the given . + /// private static string GetPseudoClass(SplitViewDisplayMode mode) { return mode switch @@ -382,6 +421,9 @@ namespace Avalonia.Controls }; } + /// + /// Gets the appropriate PseudoClass for the given . + /// private static string GetPseudoClass(SplitViewPanePlacement placement) { return placement switch @@ -392,53 +434,6 @@ namespace Avalonia.Controls }; } - private void OnPanePlacementChanged(AvaloniaPropertyChangedEventArgs e) - { - var oldState = GetPseudoClass(e.GetOldValue()); - var newState = GetPseudoClass(e.GetNewValue()); - PseudoClasses.Remove($":{oldState}"); - PseudoClasses.Add($":{newState}"); - } - - private void OnDisplayModeChanged(AvaloniaPropertyChangedEventArgs e) - { - var oldState = GetPseudoClass(e.GetOldValue()); - var newState = GetPseudoClass(e.GetNewValue()); - - PseudoClasses.Remove($":{oldState}"); - PseudoClasses.Add($":{newState}"); - - var (closedPaneWidth, paneColumnGridLength) = e.GetNewValue() switch - { - SplitViewDisplayMode.Overlay => (0, new GridLength(0, GridUnitType.Pixel)), - SplitViewDisplayMode.CompactOverlay => (CompactPaneLength, new GridLength(CompactPaneLength, GridUnitType.Pixel)), - SplitViewDisplayMode.Inline => (0, new GridLength(0, GridUnitType.Auto)), - SplitViewDisplayMode.CompactInline => (CompactPaneLength, new GridLength(0, GridUnitType.Auto)), - _ => throw new NotImplementedException(), - }; - TemplateSettings.ClosedPaneWidth = closedPaneWidth; - TemplateSettings.PaneColumnGridLength = paneColumnGridLength; - } - - private void OnUseLightDismissChanged(AvaloniaPropertyChangedEventArgs e) - { - var mode = (bool)e.NewValue!; - PseudoClasses.Set(":lightdismiss", mode); - } - - private void OnPaneChanged(AvaloniaPropertyChangedEventArgs e) - { - if (e.OldValue is ILogical oldChild) - { - LogicalChildren.Remove(oldChild); - } - - if (e.NewValue is ILogical newChild) - { - LogicalChildren.Add(newChild); - } - } - /// /// Called when the property has to be coerced. /// From 1018a7779565d185aa2f1c12775ede2f9ba0c8c3 Mon Sep 17 00:00:00 2001 From: robloo Date: Sat, 18 Feb 2023 21:25:19 -0500 Subject: [PATCH 10/17] Switch to routed events in SplitView and support cancellation in opening/closing --- src/Avalonia.Controls/SplitView/SplitView.cs | 109 +++++++++++++++---- 1 file changed, 85 insertions(+), 24 deletions(-) diff --git a/src/Avalonia.Controls/SplitView/SplitView.cs b/src/Avalonia.Controls/SplitView/SplitView.cs index da8190d764..1099a40f08 100644 --- a/src/Avalonia.Controls/SplitView/SplitView.cs +++ b/src/Avalonia.Controls/SplitView/SplitView.cs @@ -97,6 +97,38 @@ namespace Avalonia.Controls public static readonly StyledProperty TemplateSettingsProperty = AvaloniaProperty.Register(nameof(TemplateSettings)); + /// + /// Defines the event. + /// + public static readonly RoutedEvent PaneClosedEvent = + RoutedEvent.Register( + nameof(PaneClosed), + RoutingStrategies.Bubble); + + /// + /// Defines the event. + /// + public static readonly RoutedEvent PaneClosingEvent = + RoutedEvent.Register( + nameof(PaneClosing), + RoutingStrategies.Bubble); + + /// + /// Defines the event. + /// + public static readonly RoutedEvent PaneOpenedEvent = + RoutedEvent.Register( + nameof(PaneOpened), + RoutingStrategies.Bubble); + + /// + /// Defines the event. + /// + public static readonly RoutedEvent PaneOpeningEvent = + RoutedEvent.Register( + nameof(PaneOpening), + RoutingStrategies.Bubble); + private Panel? _pane; private IDisposable? _pointerDisposable; @@ -208,24 +240,48 @@ namespace Avalonia.Controls } /// - /// Fired when the pane is closed + /// Fired when the pane is closed. /// - public event EventHandler? PaneClosed; + public event EventHandler? PaneClosed + { + add => AddHandler(PaneClosedEvent, value); + remove => RemoveHandler(PaneClosedEvent, value); + } /// - /// Fired when the pane is closing + /// Fired when the pane is closing. /// - public event EventHandler? PaneClosing; + /// + /// The event args property may be set to true to cancel the event + /// and keep the pane open. + /// + public event EventHandler? PaneClosing + { + add => AddHandler(PaneClosingEvent, value); + remove => RemoveHandler(PaneClosingEvent, value); + } /// - /// Fired when the pane is opened + /// Fired when the pane is opened. /// - public event EventHandler? PaneOpened; + public event EventHandler? PaneOpened + { + add => AddHandler(PaneOpenedEvent, value); + remove => RemoveHandler(PaneOpenedEvent, value); + } /// - /// Fired when the pane is opening + /// Fired when the pane is opening. /// - public event EventHandler? PaneOpening; + /// + /// The event args property may be set to true to cancel the event + /// and keep the pane closed. + /// + public event EventHandler? PaneOpening + { + add => AddHandler(PaneOpeningEvent, value); + remove => RemoveHandler(PaneOpeningEvent, value); + } protected override bool RegisterContentPresenter(IContentPresenter presenter) { @@ -308,13 +364,15 @@ namespace Avalonia.Controls { PseudoClasses.Add(":open"); PseudoClasses.Remove(":closed"); - OnPaneOpened(EventArgs.Empty); + + OnPaneOpened(new RoutedEventArgs(PaneOpenedEvent, this)); } else { PseudoClasses.Add(":closed"); PseudoClasses.Remove(":open"); - OnPaneClosed(EventArgs.Empty); + + OnPaneClosed(new RoutedEventArgs(PaneClosedEvent, this)); } } else if (change.Property == PaneProperty) @@ -386,24 +444,24 @@ namespace Avalonia.Controls return (DisplayMode == SplitViewDisplayMode.CompactOverlay || DisplayMode == SplitViewDisplayMode.Overlay); } - protected virtual void OnPaneOpening(EventArgs args) + protected virtual void OnPaneOpening(CancelRoutedEventArgs args) { - PaneOpening?.Invoke(this, args); + RaiseEvent(args); } - protected virtual void OnPaneOpened(EventArgs args) + protected virtual void OnPaneOpened(RoutedEventArgs args) { - PaneOpened?.Invoke(this, args); + RaiseEvent(args); } - protected virtual void OnPaneClosing(SplitViewPaneClosingEventArgs args) + protected virtual void OnPaneClosing(CancelRoutedEventArgs args) { - PaneClosing?.Invoke(this, args); + RaiseEvent(args); } - protected virtual void OnPaneClosed(EventArgs args) + protected virtual void OnPaneClosed(RoutedEventArgs args) { - PaneClosed?.Invoke(this, args); + RaiseEvent(args); } /// @@ -440,19 +498,22 @@ namespace Avalonia.Controls /// The value to coerce. protected virtual bool OnCoerceIsPaneOpen(bool value) { + CancelRoutedEventArgs eventArgs; + if (value) { - OnPaneOpening(EventArgs.Empty); + eventArgs = new CancelRoutedEventArgs(PaneOpeningEvent, this); + OnPaneOpening(eventArgs); } else { - var eventArgs = new SplitViewPaneClosingEventArgs(false); + eventArgs = new CancelRoutedEventArgs(PaneClosingEvent, this); OnPaneClosing(eventArgs); + } - if (eventArgs.Cancel) - { - return !value; - } + if (eventArgs.Cancel) + { + return !value; } return value; From 937b07f339fb1d801e071ac8d3f7bd1a464a1bd4 Mon Sep 17 00:00:00 2001 From: robloo Date: Sat, 18 Feb 2023 21:26:34 -0500 Subject: [PATCH 11/17] Remove SplitViewPaneClosingEventArgs --- .../SplitView/SplitViewPaneClosingEventArgs.cs | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 src/Avalonia.Controls/SplitView/SplitViewPaneClosingEventArgs.cs diff --git a/src/Avalonia.Controls/SplitView/SplitViewPaneClosingEventArgs.cs b/src/Avalonia.Controls/SplitView/SplitViewPaneClosingEventArgs.cs deleted file mode 100644 index 46fb2d161b..0000000000 --- a/src/Avalonia.Controls/SplitView/SplitViewPaneClosingEventArgs.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; - -namespace Avalonia.Controls -{ - public class SplitViewPaneClosingEventArgs : EventArgs - { - public bool Cancel { get; set; } - - public SplitViewPaneClosingEventArgs(bool cancel) - { - Cancel = cancel; - } - } -} From afbb04b5ffe4fe133de16f63dfcd14323656833a Mon Sep 17 00:00:00 2001 From: robloo Date: Sat, 18 Feb 2023 21:26:49 -0500 Subject: [PATCH 12/17] Fix eventArgs type in Expander --- src/Avalonia.Controls/Expander.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Avalonia.Controls/Expander.cs b/src/Avalonia.Controls/Expander.cs index 2ad6a58d38..668de5bca9 100644 --- a/src/Avalonia.Controls/Expander.cs +++ b/src/Avalonia.Controls/Expander.cs @@ -191,7 +191,7 @@ namespace Avalonia.Controls /// /// Invoked just before the event. /// - protected virtual void OnCollapsing(RoutedEventArgs eventArgs) + protected virtual void OnCollapsing(CancelRoutedEventArgs eventArgs) { RaiseEvent(eventArgs); } @@ -207,7 +207,7 @@ namespace Avalonia.Controls /// /// Invoked just before the event. /// - protected virtual void OnExpanding(RoutedEventArgs eventArgs) + protected virtual void OnExpanding(CancelRoutedEventArgs eventArgs) { RaiseEvent(eventArgs); } From 1ecdf3252064fafddf6bb82da477cb7cb1e0ea73 Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Mon, 20 Feb 2023 10:34:21 +0100 Subject: [PATCH 13/17] Remove trailing whitespace for right aligned text Fix justification for non wrapped text Fix text trimming for RTL flow direction --- src/Avalonia.Base/Media/FormattedText.cs | 2 +- .../Media/TextCollapsingCreateInfo.cs | 4 +- .../TextFormatting/InterWordJustification.cs | 13 +- .../Media/TextFormatting/ShapedTextRun.cs | 21 +- .../TextCollapsingProperties.cs | 9 +- .../TextFormatting/TextEllipsisHelper.cs | 191 +++++++++++++----- .../Media/TextFormatting/TextLayout.cs | 25 +-- .../TextLeadingPrefixCharacterEllipsis.cs | 7 +- .../Media/TextFormatting/TextLineImpl.cs | 47 +++-- .../TextTrailingCharacterEllipsis.cs | 7 +- .../TextTrailingWordEllipsis.cs | 7 +- .../Media/TextLeadingPrefixTrimming.cs | 2 +- .../Media/TextTrailingTrimming.cs | 4 +- .../TextFormatting/TextFormatterTests.cs | 2 +- .../Media/TextFormatting/TextLineTests.cs | 2 +- 15 files changed, 231 insertions(+), 112 deletions(-) diff --git a/src/Avalonia.Base/Media/FormattedText.cs b/src/Avalonia.Base/Media/FormattedText.cs index 3b63a98720..d4640390d7 100644 --- a/src/Avalonia.Base/Media/FormattedText.cs +++ b/src/Avalonia.Base/Media/FormattedText.cs @@ -877,7 +877,7 @@ namespace Avalonia.Media var lastRunProps = (GenericTextRunProperties)thatFormatRider.CurrentElement!; - TextCollapsingProperties collapsingProperties = _that._trimming.CreateCollapsingProperties(new TextCollapsingCreateInfo(maxLineLength, lastRunProps)); + TextCollapsingProperties collapsingProperties = _that._trimming.CreateCollapsingProperties(new TextCollapsingCreateInfo(maxLineLength, lastRunProps, paraProps.FlowDirection)); var collapsedLine = line.Collapse(collapsingProperties); diff --git a/src/Avalonia.Base/Media/TextCollapsingCreateInfo.cs b/src/Avalonia.Base/Media/TextCollapsingCreateInfo.cs index 40ba613717..9b7bf3f74c 100644 --- a/src/Avalonia.Base/Media/TextCollapsingCreateInfo.cs +++ b/src/Avalonia.Base/Media/TextCollapsingCreateInfo.cs @@ -6,11 +6,13 @@ namespace Avalonia.Media { public readonly double Width; public readonly TextRunProperties TextRunProperties; + public readonly FlowDirection FlowDirection; - public TextCollapsingCreateInfo(double width, TextRunProperties textRunProperties) + public TextCollapsingCreateInfo(double width, TextRunProperties textRunProperties, FlowDirection flowDirection) { Width = width; TextRunProperties = textRunProperties; + FlowDirection = flowDirection; } } } diff --git a/src/Avalonia.Base/Media/TextFormatting/InterWordJustification.cs b/src/Avalonia.Base/Media/TextFormatting/InterWordJustification.cs index 0d85f3e7c5..c1b9b77401 100644 --- a/src/Avalonia.Base/Media/TextFormatting/InterWordJustification.cs +++ b/src/Avalonia.Base/Media/TextFormatting/InterWordJustification.cs @@ -27,16 +27,6 @@ namespace Avalonia.Media.TextFormatting return; } - if (lineImpl.NewLineLength > 0) - { - return; - } - - if (lineImpl.TextLineBreak is { TextEndOfLine: not null, IsSplit: false }) - { - return; - } - var breakOportunities = new Queue(); var currentPosition = textLine.FirstTextSourceIndex; @@ -97,7 +87,8 @@ namespace Avalonia.Media.TextFormatting continue; } - var glyphIndex = glyphRun.FindGlyphIndex(characterIndex); + var offset = Math.Max(0, currentPosition - glyphRun.Metrics.FirstCluster); + var glyphIndex = glyphRun.FindGlyphIndex(characterIndex - offset); var glyphInfo = shapedBuffer.GlyphInfos[glyphIndex]; shapedBuffer.GlyphInfos[glyphIndex] = new GlyphInfo(glyphInfo.GlyphIndex, diff --git a/src/Avalonia.Base/Media/TextFormatting/ShapedTextRun.cs b/src/Avalonia.Base/Media/TextFormatting/ShapedTextRun.cs index 7f23ac98b4..568148e15c 100644 --- a/src/Avalonia.Base/Media/TextFormatting/ShapedTextRun.cs +++ b/src/Avalonia.Base/Media/TextFormatting/ShapedTextRun.cs @@ -148,33 +148,38 @@ namespace Avalonia.Media.TextFormatting internal SplitResult Split(int length) { - if (IsReversed) + var isReversed = IsReversed; + + if (isReversed) { Reverse(); - } + length = Length - length; + } #if DEBUG - if(length == 0) + if (length == 0) { throw new ArgumentOutOfRangeException(nameof(length), "length must be greater than zero."); } -#endif - +#endif var splitBuffer = ShapedBuffer.Split(length); var first = new ShapedTextRun(splitBuffer.First, Properties); - #if DEBUG +#if DEBUG if (first.Length != length) { throw new InvalidOperationException("Split length mismatch."); } - #endif - var second = new ShapedTextRun(splitBuffer.Second!, Properties); + if (isReversed) + { + return new SplitResult(second, first); + } + return new SplitResult(first, second); } diff --git a/src/Avalonia.Base/Media/TextFormatting/TextCollapsingProperties.cs b/src/Avalonia.Base/Media/TextFormatting/TextCollapsingProperties.cs index 72882df0b5..7cdf81ecc9 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextCollapsingProperties.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextCollapsingProperties.cs @@ -1,4 +1,6 @@ -namespace Avalonia.Media.TextFormatting +using System.Collections.Generic; + +namespace Avalonia.Media.TextFormatting { /// /// Properties of text collapsing. @@ -15,6 +17,11 @@ /// public abstract TextRun Symbol { get; } + /// + /// Gets the flow direction that is used for collapsing. + /// + public abstract FlowDirection FlowDirection { get; } + /// /// Collapses given text line. /// diff --git a/src/Avalonia.Base/Media/TextFormatting/TextEllipsisHelper.cs b/src/Avalonia.Base/Media/TextFormatting/TextEllipsisHelper.cs index 4c93a1d851..6422f23dcd 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextEllipsisHelper.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextEllipsisHelper.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using Avalonia.Media.TextFormatting.Unicode; namespace Avalonia.Media.TextFormatting @@ -28,97 +27,191 @@ namespace Avalonia.Media.TextFormatting var availableWidth = properties.Width - shapedSymbol.Size.Width; - while (runIndex < textRuns.Count) + if(properties.FlowDirection== FlowDirection.LeftToRight) { - var currentRun = textRuns[runIndex]; - - switch (currentRun) + while (runIndex < textRuns.Count) { - case ShapedTextRun shapedRun: - { - currentWidth += shapedRun.Size.Width; + var currentRun = textRuns[runIndex]; - if (currentWidth > availableWidth) + switch (currentRun) + { + case ShapedTextRun shapedRun: { - if (shapedRun.TryMeasureCharacters(availableWidth, out var measuredLength)) + currentWidth += shapedRun.Size.Width; + + if (currentWidth > availableWidth) { - if (isWordEllipsis && measuredLength < textLine.Length) + if (shapedRun.TryMeasureCharacters(availableWidth, out var measuredLength)) { - var currentBreakPosition = 0; - - var lineBreaker = new LineBreakEnumerator(currentRun.Text.Span); - - while (currentBreakPosition < measuredLength && lineBreaker.MoveNext(out var lineBreak)) + if (isWordEllipsis && measuredLength < textLine.Length) { - var nextBreakPosition = lineBreak.PositionMeasure; + var currentBreakPosition = 0; - if (nextBreakPosition == 0) - { - break; - } + var lineBreaker = new LineBreakEnumerator(currentRun.Text.Span); - if (nextBreakPosition >= measuredLength) + while (currentBreakPosition < measuredLength && lineBreaker.MoveNext(out var lineBreak)) { - break; + var nextBreakPosition = lineBreak.PositionMeasure; + + if (nextBreakPosition == 0) + { + break; + } + + if (nextBreakPosition >= measuredLength) + { + break; + } + + currentBreakPosition = nextBreakPosition; } - currentBreakPosition = nextBreakPosition; + measuredLength = currentBreakPosition; } - - measuredLength = currentBreakPosition; } + + collapsedLength += measuredLength; + + return CreateCollapsedRuns(textLine, collapsedLength, FlowDirection.LeftToRight, shapedSymbol); } - collapsedLength += measuredLength; + availableWidth -= shapedRun.Size.Width; - return CreateCollapsedRuns(textRuns, collapsedLength, shapedSymbol); + break; } - availableWidth -= shapedRun.Size.Width; + case DrawableTextRun drawableRun: + { + //The whole run needs to fit into available space + if (currentWidth + drawableRun.Size.Width > availableWidth) + { + return CreateCollapsedRuns(textLine, collapsedLength, FlowDirection.LeftToRight, shapedSymbol); + } - break; - } + availableWidth -= drawableRun.Size.Width; - case DrawableTextRun drawableRun: - { - //The whole run needs to fit into available space - if (currentWidth + drawableRun.Size.Width > availableWidth) - { - return CreateCollapsedRuns(textRuns, collapsedLength, shapedSymbol); + break; } + } - availableWidth -= drawableRun.Size.Width; + collapsedLength += currentRun.Length; - break; - } + runIndex++; } + } + else + { + runIndex = textRuns.Count - 1; + + while (runIndex >= 0) + { + var currentRun = textRuns[runIndex]; - collapsedLength += currentRun.Length; + switch (currentRun) + { + case ShapedTextRun shapedRun: + { + currentWidth += shapedRun.Size.Width; - runIndex++; - } + if (currentWidth > availableWidth) + { + if (shapedRun.TryMeasureCharacters(availableWidth, out var measuredLength)) + { + if (isWordEllipsis && measuredLength < textLine.Length) + { + var currentBreakPosition = 0; + + var lineBreaker = new LineBreakEnumerator(currentRun.Text.Span); + + while (currentBreakPosition < measuredLength && lineBreaker.MoveNext(out var lineBreak)) + { + var nextBreakPosition = lineBreak.PositionMeasure; + + if (nextBreakPosition == 0) + { + break; + } + + if (nextBreakPosition >= measuredLength) + { + break; + } + currentBreakPosition = nextBreakPosition; + } + + measuredLength = currentBreakPosition; + } + } + + collapsedLength += measuredLength; + + return CreateCollapsedRuns(textLine, collapsedLength, FlowDirection.RightToLeft, shapedSymbol); + } + + availableWidth -= shapedRun.Size.Width; + + break; + } + + case DrawableTextRun drawableRun: + { + //The whole run needs to fit into available space + if (currentWidth + drawableRun.Size.Width > availableWidth) + { + return CreateCollapsedRuns(textLine, collapsedLength, FlowDirection.RightToLeft, shapedSymbol); + } + + availableWidth -= drawableRun.Size.Width; + + break; + } + } + + collapsedLength += currentRun.Length; + + runIndex--; + } + } + return null; } - private static TextRun[] CreateCollapsedRuns(IReadOnlyList textRuns, int collapsedLength, - TextRun shapedSymbol) + private static TextRun[] CreateCollapsedRuns(TextLine textLine, int collapsedLength, + FlowDirection flowDirection, TextRun shapedSymbol) { + var textRuns = textLine.TextRuns; + if (collapsedLength <= 0) { return new[] { shapedSymbol }; } + if(flowDirection == FlowDirection.RightToLeft) + { + collapsedLength = textLine.Length - collapsedLength; + } + var objectPool = FormattingObjectPool.Instance; var (preSplitRuns, postSplitRuns) = TextFormatterImpl.SplitTextRuns(textRuns, collapsedLength, objectPool); try { - var collapsedRuns = new TextRun[preSplitRuns.Count + 1]; - preSplitRuns.CopyTo(collapsedRuns); - collapsedRuns[collapsedRuns.Length - 1] = shapedSymbol; - return collapsedRuns; + if (flowDirection == FlowDirection.RightToLeft) + { + var collapsedRuns = new TextRun[postSplitRuns!.Count + 1]; + postSplitRuns.CopyTo(collapsedRuns, 1); + collapsedRuns[0] = shapedSymbol; + return collapsedRuns; + } + else + { + var collapsedRuns = new TextRun[preSplitRuns!.Count + 1]; + preSplitRuns.CopyTo(collapsedRuns); + collapsedRuns[collapsedRuns.Length - 1] = shapedSymbol; + return collapsedRuns; + } } finally { diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs b/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs index 4dbc472133..a382416b8a 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs @@ -352,7 +352,7 @@ namespace Avalonia.Media.TextFormatting var lastTrailingIndex = 0; - if(_paragraphProperties.FlowDirection== FlowDirection.LeftToRight) + if (_paragraphProperties.FlowDirection == FlowDirection.LeftToRight) { lastTrailingIndex = textLine.FirstTextSourceIndex + textLine.Length; @@ -377,7 +377,7 @@ namespace Avalonia.Media.TextFormatting { lastTrailingIndex += textEndOfLine.Length; } - } + } var textPosition = characterHit.FirstCharacterIndex + characterHit.TrailingLength; @@ -553,26 +553,18 @@ namespace Avalonia.Media.TextFormatting if (_paragraphProperties.TextAlignment == TextAlignment.Justify) { - var whitespaceWidth = 0d; + var justificationWidth = MaxWidth; - for (var i = 0; i < textLines.Count; i++) + if (_paragraphProperties.TextWrapping != TextWrapping.NoWrap) { - var line = textLines[i]; - var lineWhitespaceWidth = line.Width - line.WidthIncludingTrailingWhitespace; - - if (lineWhitespaceWidth > whitespaceWidth) - { - whitespaceWidth = lineWhitespaceWidth; - } + justificationWidth = width; } - var justificationWidth = width - whitespaceWidth; - if (justificationWidth > 0) { var justificationProperties = new InterWordJustification(justificationWidth); - for (var i = 0; i < textLines.Count - 1; i++) + for (var i = 0; i < textLines.Count; i++) { var line = textLines[i]; @@ -597,12 +589,13 @@ namespace Avalonia.Media.TextFormatting /// The . private TextCollapsingProperties? GetCollapsingProperties(double width) { - if(_textTrimming == TextTrimming.None) + if (_textTrimming == TextTrimming.None) { return null; } - return _textTrimming.CreateCollapsingProperties(new TextCollapsingCreateInfo(width, _paragraphProperties.DefaultTextRunProperties)); + return _textTrimming.CreateCollapsingProperties( + new TextCollapsingCreateInfo(width, _paragraphProperties.DefaultTextRunProperties, _paragraphProperties.FlowDirection)); } public void Dispose() diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLeadingPrefixCharacterEllipsis.cs b/src/Avalonia.Base/Media/TextFormatting/TextLeadingPrefixCharacterEllipsis.cs index 2e85b1e187..a21a5d45e9 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLeadingPrefixCharacterEllipsis.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextLeadingPrefixCharacterEllipsis.cs @@ -19,11 +19,13 @@ namespace Avalonia.Media.TextFormatting /// Length of leading prefix. /// width in which collapsing is constrained to /// text run properties of ellipsis symbol + /// the flow direction of the collapes line. public TextLeadingPrefixCharacterEllipsis( string ellipsis, int prefixLength, double width, - TextRunProperties textRunProperties) + TextRunProperties textRunProperties, + FlowDirection flowDirection) { if (_prefixLength < 0) { @@ -33,6 +35,7 @@ namespace Avalonia.Media.TextFormatting _prefixLength = prefixLength; Width = width; Symbol = new TextCharacters(ellipsis, textRunProperties); + FlowDirection = flowDirection; } /// @@ -41,6 +44,8 @@ namespace Avalonia.Media.TextFormatting /// public override TextRun Symbol { get; } + public override FlowDirection FlowDirection { get; } + /// public override TextRun[]? Collapse(TextLine textLine) { diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs index 187b3154ad..b3321d4d9f 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs @@ -658,7 +658,7 @@ namespace Avalonia.Media.TextFormatting currentX += drawableTextRun.Size.Width; } - if(lastRunIndex - 1 < 0) + if (lastRunIndex - 1 < 0) { break; } @@ -685,7 +685,7 @@ namespace Avalonia.Media.TextFormatting directionalWidth -= drawableTextRun.Size.Width; } - if(firstRunIndex + 1 == _textRuns.Length) + if (firstRunIndex + 1 == _textRuns.Length) { break; } @@ -1097,7 +1097,7 @@ namespace Avalonia.Media.TextFormatting var runWidth = endX - startX; - return new TextRunBounds(new Rect(Start + startX, 0, runWidth, Height), currentPosition, characterLength, currentRun); + return new TextRunBounds(new Rect(startX, 0, runWidth, Height), currentPosition, characterLength, currentRun); } public override void Dispose() @@ -1439,13 +1439,6 @@ namespace Avalonia.Media.TextFormatting } } - if (index == lastRunIndex) - { - width = widthIncludingWhitespace + textRun.GlyphRun.Metrics.Width; - trailingWhitespaceLength = textRun.GlyphRun.Metrics.TrailingWhitespaceLength; - newLineLength += textRun.GlyphRun.Metrics.NewLineLength; - } - widthIncludingWhitespace += textRun.Size.Width; break; @@ -1455,12 +1448,6 @@ namespace Avalonia.Media.TextFormatting { widthIncludingWhitespace += drawableTextRun.Size.Width; - if (index == lastRunIndex) - { - width = widthIncludingWhitespace; - trailingWhitespaceLength = 0; - } - if (drawableTextRun.Size.Height > height) { height = drawableTextRun.Size.Height; @@ -1476,6 +1463,32 @@ namespace Avalonia.Media.TextFormatting } } + width = widthIncludingWhitespace; + + for (var i = _textRuns.Length - 1; i >= 0; i--) + { + var currentRun = _textRuns[i]; + + if(currentRun is ShapedTextRun shapedText) + { + var glyphRun = shapedText.GlyphRun; + var glyphRunMetrics = glyphRun.Metrics; + + newLineLength += glyphRunMetrics.NewLineLength; + + if (glyphRunMetrics.TrailingWhitespaceLength == 0) + { + break; + } + + trailingWhitespaceLength += glyphRunMetrics.TrailingWhitespaceLength; + + var whitespaceWidth = glyphRun.Size.Width - glyphRunMetrics.Width; + + width -= whitespaceWidth; + } + } + var start = GetParagraphOffsetX(width, widthIncludingWhitespace); if (!double.IsNaN(lineHeight) && !MathUtilities.IsZero(lineHeight)) @@ -1543,7 +1556,7 @@ namespace Avalonia.Media.TextFormatting return Math.Max(0, start); case TextAlignment.Right: - return Math.Max(0, _paragraphWidth - widthIncludingTrailingWhitespace); + return Math.Max(0, _paragraphWidth - width); default: return 0; diff --git a/src/Avalonia.Base/Media/TextFormatting/TextTrailingCharacterEllipsis.cs b/src/Avalonia.Base/Media/TextFormatting/TextTrailingCharacterEllipsis.cs index ccae99cc75..8a6607bce2 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextTrailingCharacterEllipsis.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextTrailingCharacterEllipsis.cs @@ -12,10 +12,13 @@ /// Text used as collapsing symbol. /// Width in which collapsing is constrained to. /// Text run properties of ellipsis symbol. - public TextTrailingCharacterEllipsis(string ellipsis, double width, TextRunProperties textRunProperties) + /// The flow direction of the collapsed line. + public TextTrailingCharacterEllipsis(string ellipsis, double width, + TextRunProperties textRunProperties, FlowDirection flowDirection) { Width = width; Symbol = new TextCharacters(ellipsis, textRunProperties); + FlowDirection = flowDirection; } /// @@ -24,6 +27,8 @@ /// public override TextRun Symbol { get; } + public override FlowDirection FlowDirection { get; } + /// public override TextRun[]? Collapse(TextLine textLine) { diff --git a/src/Avalonia.Base/Media/TextFormatting/TextTrailingWordEllipsis.cs b/src/Avalonia.Base/Media/TextFormatting/TextTrailingWordEllipsis.cs index c622c76a60..5252766382 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextTrailingWordEllipsis.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextTrailingWordEllipsis.cs @@ -12,14 +12,17 @@ /// Text used as collapsing symbol. /// width in which collapsing is constrained to. /// text run properties of ellipsis symbol. + /// flow direction of the collapsed line. public TextTrailingWordEllipsis( string ellipsis, double width, - TextRunProperties textRunProperties + TextRunProperties textRunProperties, + FlowDirection flowDirection ) { Width = width; Symbol = new TextCharacters(ellipsis, textRunProperties); + FlowDirection = flowDirection; } /// @@ -28,6 +31,8 @@ /// public override TextRun Symbol { get; } + public override FlowDirection FlowDirection { get; } + /// public override TextRun[]? Collapse(TextLine textLine) { diff --git a/src/Avalonia.Base/Media/TextLeadingPrefixTrimming.cs b/src/Avalonia.Base/Media/TextLeadingPrefixTrimming.cs index 7ba25eb005..19e6a70357 100644 --- a/src/Avalonia.Base/Media/TextLeadingPrefixTrimming.cs +++ b/src/Avalonia.Base/Media/TextLeadingPrefixTrimming.cs @@ -15,7 +15,7 @@ namespace Avalonia.Media public override TextCollapsingProperties CreateCollapsingProperties(TextCollapsingCreateInfo createInfo) { - return new TextLeadingPrefixCharacterEllipsis(_ellipsis, _prefixLength, createInfo.Width, createInfo.TextRunProperties); + return new TextLeadingPrefixCharacterEllipsis(_ellipsis, _prefixLength, createInfo.Width, createInfo.TextRunProperties, createInfo.FlowDirection); } public override string ToString() diff --git a/src/Avalonia.Base/Media/TextTrailingTrimming.cs b/src/Avalonia.Base/Media/TextTrailingTrimming.cs index 2edbaabbc6..8a3c5aa397 100644 --- a/src/Avalonia.Base/Media/TextTrailingTrimming.cs +++ b/src/Avalonia.Base/Media/TextTrailingTrimming.cs @@ -17,10 +17,10 @@ namespace Avalonia.Media { if (_isWordBased) { - return new TextTrailingWordEllipsis(_ellipsis, createInfo.Width, createInfo.TextRunProperties); + return new TextTrailingWordEllipsis(_ellipsis, createInfo.Width, createInfo.TextRunProperties, createInfo.FlowDirection); } - return new TextTrailingCharacterEllipsis(_ellipsis, createInfo.Width, createInfo.TextRunProperties); + return new TextTrailingCharacterEllipsis(_ellipsis, createInfo.Width, createInfo.TextRunProperties, createInfo.FlowDirection); } public override string ToString() diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs index 8a2d4ecc6b..dc8744b292 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs @@ -457,7 +457,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting if (textLine.Width > 300 || currentHeight + textLine.Height > 240) { - textLine = textLine.Collapse(new TextTrailingWordEllipsis(TextTrimming.DefaultEllipsisChar, 300, defaultProperties)); + textLine = textLine.Collapse(new TextTrailingWordEllipsis(TextTrimming.DefaultEllipsisChar, 300, defaultProperties, FlowDirection.LeftToRight)); } currentHeight += textLine.Height; diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs index 70e74cdf83..bd64d72a4d 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs @@ -407,7 +407,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting Assert.False(textLine.HasCollapsed); - TextCollapsingProperties collapsingProperties = trimming.CreateCollapsingProperties(new TextCollapsingCreateInfo(width, defaultProperties)); + TextCollapsingProperties collapsingProperties = trimming.CreateCollapsingProperties(new TextCollapsingCreateInfo(width, defaultProperties, FlowDirection.LeftToRight)); var collapsedLine = textLine.Collapse(collapsingProperties); From 3096a2d1590ec2a3172b0b79f0faa737d225f59c Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 21 Feb 2023 23:08:29 +0100 Subject: [PATCH 14/17] Add failing integration test. For the following scenario: - Open a child window of main window - Open a modal window as a child of main window - Close main window - First child window should remain in front of main window --- .../WindowTests_MacOS.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/Avalonia.IntegrationTests.Appium/WindowTests_MacOS.cs b/tests/Avalonia.IntegrationTests.Appium/WindowTests_MacOS.cs index 0839cbf183..55812d8df7 100644 --- a/tests/Avalonia.IntegrationTests.Appium/WindowTests_MacOS.cs +++ b/tests/Avalonia.IntegrationTests.Appium/WindowTests_MacOS.cs @@ -150,6 +150,18 @@ namespace Avalonia.IntegrationTests.Appium windowState = mainWindow.FindElementByAccessibilityId("MainWindowState"); Assert.Equal("Normal", windowState.Text); } + + [PlatformFact(TestPlatforms.MacOS)] + public void WindowOrder_Owned_Dialog_Stays_InFront_Of_Parent_After_Modal_Closed() + { + using (OpenWindow(new PixelSize(200, 300), ShowWindowMode.Owned, WindowStartupLocation.Manual)) + { + OpenWindow(null, ShowWindowMode.Modal, WindowStartupLocation.Manual).Dispose(); + + var secondaryWindowIndex = GetWindowOrder("SecondaryWindow"); + Assert.Equal(1, secondaryWindowIndex); + } + } [PlatformFact(TestPlatforms.MacOS)] public void Does_Not_Switch_Space_From_FullScreen_To_Main_Desktop_When_FullScreen_Window_Clicked() From e77c79b2aa4885ba3e8d4f96efd1e4ed6e48ad03 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 21 Feb 2023 23:11:10 +0100 Subject: [PATCH 15/17] Enforce window order after a delay. Sometimes, after `windowDidBecomeKey` is called and the window order is enforced via `BringToFront`, the window that became key is then moved to the front, breaking window order. Given that we're already invalidating the shadow by scheduling it on the dispatcher, use this opportunity to also enforce the window order again. --- native/Avalonia.Native/src/OSX/AvnWindow.mm | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/native/Avalonia.Native/src/OSX/AvnWindow.mm b/native/Avalonia.Native/src/OSX/AvnWindow.mm index 23abf1d53f..3a03abf211 100644 --- a/native/Avalonia.Native/src/OSX/AvnWindow.mm +++ b/native/Avalonia.Native/src/OSX/AvnWindow.mm @@ -292,12 +292,13 @@ { if (_parent == nullptr) return; - + _parent->BringToFront(); dispatch_async(dispatch_get_main_queue(), ^{ @try { - [self invalidateShadow]; + [self invalidateShadow]; + self->_parent->BringToFront(); } @finally{ } From 3de50b3baa52d6c40edca1ad6037c256c98f563c Mon Sep 17 00:00:00 2001 From: Dan Walmsley Date: Wed, 22 Feb 2023 13:09:17 +0000 Subject: [PATCH 16/17] if the window is closed, we cannot become the key window. --- native/Avalonia.Native/src/OSX/AvnWindow.mm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/native/Avalonia.Native/src/OSX/AvnWindow.mm b/native/Avalonia.Native/src/OSX/AvnWindow.mm index 23abf1d53f..505ff63d4b 100644 --- a/native/Avalonia.Native/src/OSX/AvnWindow.mm +++ b/native/Avalonia.Native/src/OSX/AvnWindow.mm @@ -238,7 +238,7 @@ -(BOOL)canBecomeKeyWindow { - if(_canBecomeKeyWindow) + if(_canBecomeKeyWindow && !_closed) { // If the window has a child window being shown as a dialog then don't allow it to become the key window. auto parent = dynamic_cast(_parent.getRaw()); From c837c1005165413ae49f0fde2f71d5f4ed52d223 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 22 Feb 2023 22:23:17 +0100 Subject: [PATCH 17/17] Don't call BringToFront on closed window. --- native/Avalonia.Native/src/OSX/AvnWindow.mm | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/native/Avalonia.Native/src/OSX/AvnWindow.mm b/native/Avalonia.Native/src/OSX/AvnWindow.mm index 80a0d3e66f..b1fb915e04 100644 --- a/native/Avalonia.Native/src/OSX/AvnWindow.mm +++ b/native/Avalonia.Native/src/OSX/AvnWindow.mm @@ -298,7 +298,8 @@ dispatch_async(dispatch_get_main_queue(), ^{ @try { [self invalidateShadow]; - self->_parent->BringToFront(); + if (self->_parent != nullptr) + self->_parent->BringToFront(); } @finally{ }