diff --git a/demo/Ursa.Demo/Pages/TagInputDemo.axaml b/demo/Ursa.Demo/Pages/TagInputDemo.axaml new file mode 100644 index 0000000..d5751e5 --- /dev/null +++ b/demo/Ursa.Demo/Pages/TagInputDemo.axaml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/demo/Ursa.Demo/Pages/TagInputDemo.axaml.cs b/demo/Ursa.Demo/Pages/TagInputDemo.axaml.cs new file mode 100644 index 0000000..f19dc0a --- /dev/null +++ b/demo/Ursa.Demo/Pages/TagInputDemo.axaml.cs @@ -0,0 +1,13 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace Ursa.Demo.Pages; + +public partial class TagInputDemo : UserControl +{ + public TagInputDemo() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/demo/Ursa.Demo/Views/MainWindow.axaml b/demo/Ursa.Demo/Views/MainWindow.axaml index d6da845..1b6c162 100644 --- a/demo/Ursa.Demo/Views/MainWindow.axaml +++ b/demo/Ursa.Demo/Views/MainWindow.axaml @@ -47,6 +47,9 @@ + + + diff --git a/src/Ursa.Themes.Semi/Controls/TagInput.axaml b/src/Ursa.Themes.Semi/Controls/TagInput.axaml new file mode 100644 index 0000000..6829870 --- /dev/null +++ b/src/Ursa.Themes.Semi/Controls/TagInput.axaml @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Ursa.Themes.Semi/Controls/_index.axaml b/src/Ursa.Themes.Semi/Controls/_index.axaml index 7c621a3..bbf2752 100644 --- a/src/Ursa.Themes.Semi/Controls/_index.axaml +++ b/src/Ursa.Themes.Semi/Controls/_index.axaml @@ -9,6 +9,7 @@ + diff --git a/src/Ursa.Themes.Semi/Themes/Dark/TagInput.axaml b/src/Ursa.Themes.Semi/Themes/Dark/TagInput.axaml new file mode 100644 index 0000000..c32a707 --- /dev/null +++ b/src/Ursa.Themes.Semi/Themes/Dark/TagInput.axaml @@ -0,0 +1,4 @@ + + + + diff --git a/src/Ursa.Themes.Semi/Themes/Dark/_index.axaml b/src/Ursa.Themes.Semi/Themes/Dark/_index.axaml index c892d42..c9f2bea 100644 --- a/src/Ursa.Themes.Semi/Themes/Dark/_index.axaml +++ b/src/Ursa.Themes.Semi/Themes/Dark/_index.axaml @@ -9,6 +9,7 @@ + diff --git a/src/Ursa.Themes.Semi/Themes/Light/TagInput.axaml b/src/Ursa.Themes.Semi/Themes/Light/TagInput.axaml new file mode 100644 index 0000000..c3b76e1 --- /dev/null +++ b/src/Ursa.Themes.Semi/Themes/Light/TagInput.axaml @@ -0,0 +1,4 @@ + + + + diff --git a/src/Ursa.Themes.Semi/Themes/Light/_index.axaml b/src/Ursa.Themes.Semi/Themes/Light/_index.axaml index c892d42..c9f2bea 100644 --- a/src/Ursa.Themes.Semi/Themes/Light/_index.axaml +++ b/src/Ursa.Themes.Semi/Themes/Light/_index.axaml @@ -9,6 +9,7 @@ + diff --git a/src/Ursa.Themes.Semi/Themes/Shared/TagInput.axaml b/src/Ursa.Themes.Semi/Themes/Shared/TagInput.axaml new file mode 100644 index 0000000..2f3d2b7 --- /dev/null +++ b/src/Ursa.Themes.Semi/Themes/Shared/TagInput.axaml @@ -0,0 +1,5 @@ + + + M17.6568 19.7782C18.2426 20.3639 19.1924 20.3639 19.7782 19.7782C20.3639 19.1924 20.3639 18.2426 19.7782 17.6568L14.1213 12L19.7782 6.34313C20.3639 5.75734 20.3639 4.8076 19.7782 4.22181C19.1924 3.63602 18.2426 3.63602 17.6568 4.22181L12 9.87866L6.34313 4.22181C5.75734 3.63602 4.8076 3.63602 4.22181 4.22181C3.63602 4.8076 3.63602 5.75734 4.22181 6.34313L9.87866 12L4.22181 17.6568C3.63602 18.2426 3.63602 19.1924 4.22181 19.7782C4.8076 20.3639 5.75734 20.3639 6.34313 19.7782L12 14.1213L17.6568 19.7782Z + 32 + diff --git a/src/Ursa.Themes.Semi/Themes/Shared/_index.axaml b/src/Ursa.Themes.Semi/Themes/Shared/_index.axaml index 5e6a238..777a0a4 100644 --- a/src/Ursa.Themes.Semi/Themes/Shared/_index.axaml +++ b/src/Ursa.Themes.Semi/Themes/Shared/_index.axaml @@ -8,5 +8,6 @@ + diff --git a/src/Ursa/AssemblyInfo.cs b/src/Ursa/AssemblyInfo.cs index 0b0375e..af243bf 100644 --- a/src/Ursa/AssemblyInfo.cs +++ b/src/Ursa/AssemblyInfo.cs @@ -1,5 +1,5 @@ using Avalonia.Metadata; +[assembly:XmlnsPrefix("https://irihi.tech/ursa", "u")] [assembly:XmlnsDefinition("https://irihi.tech/ursa", "Ursa")] [assembly:XmlnsDefinition("https://irihi.tech/ursa", "Ursa.Controls")] -[assembly:XmlnsPrefix("https://irihi.tech/ursa", "u")] \ No newline at end of file diff --git a/src/Ursa/Controls/TagInput/ClosableTag.cs b/src/Ursa/Controls/TagInput/ClosableTag.cs new file mode 100644 index 0000000..de609f2 --- /dev/null +++ b/src/Ursa/Controls/TagInput/ClosableTag.cs @@ -0,0 +1,46 @@ +using System.Windows.Input; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Metadata; +using Avalonia.Controls.Primitives; +using Avalonia.Input; + +namespace Ursa.Controls; + +[TemplatePart(PART_CloseButton, typeof(PathIcon))] +public class ClosableTag: ContentControl +{ + public const string PART_CloseButton = "PART_CloseButton"; + private PathIcon? _icon; + public static readonly StyledProperty CommandProperty = AvaloniaProperty.Register( + nameof(Command)); + + public ICommand? Command + { + get => GetValue(CommandProperty); + set => SetValue(CommandProperty, value); + } + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + if (_icon != null) + { + _icon.PointerPressed -= OnPointerPressed; + } + _icon = e.NameScope.Find(PART_CloseButton); + if (_icon != null) + { + _icon.PointerPressed += OnPointerPressed; + } + + } + + private void OnPointerPressed(object? sender, PointerPressedEventArgs args) + { + if (Command != null && Command.CanExecute(null)) + { + Command.Execute(this); + } + } +} \ No newline at end of file diff --git a/src/Ursa/Controls/TagInput/TagInput.cs b/src/Ursa/Controls/TagInput/TagInput.cs new file mode 100644 index 0000000..5ee089b --- /dev/null +++ b/src/Ursa/Controls/TagInput/TagInput.cs @@ -0,0 +1,213 @@ +using System.Collections; +using System.Collections.ObjectModel; +using Avalonia; +using Avalonia.Collections; +using Avalonia.Controls; +using Avalonia.Controls.Metadata; +using Avalonia.Controls.Presenters; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Styling; + +namespace Ursa.Controls; + +[TemplatePart(PART_ItemsControl, typeof(ItemsControl))] +public class TagInput : TemplatedControl +{ + public const string PART_ItemsControl = "PART_ItemsControl"; + + private readonly TextBox _textBox; + private ItemsControl? _itemsControl; + + + public static readonly StyledProperty> TagsProperty = + AvaloniaProperty.Register>( + nameof(Tags)); + + public IList Tags + { + get => GetValue(TagsProperty); + set => SetValue(TagsProperty, value); + } + + public static readonly DirectProperty ItemsProperty = + AvaloniaProperty.RegisterDirect( + nameof(Items), o => o.Items); + + private IList _items; + + public IList Items + { + get => _items; + private set => SetAndRaise(ItemsProperty, ref _items, value); + } + + public TagInput() + { + _textBox = new TextBox(); + _textBox.AddHandler(InputElement.KeyDownEvent, OnTextBoxKeyDown, RoutingStrategies.Tunnel); + Items = new AvaloniaList(); + Tags = new ObservableCollection(); + } + + public static readonly StyledProperty InputThemeProperty = + AvaloniaProperty.Register( + nameof(InputTheme)); + + public ControlTheme InputTheme + { + get => GetValue(InputThemeProperty); + set => SetValue(InputThemeProperty, value); + } + + public static readonly StyledProperty ItemTemplateProperty = + AvaloniaProperty.Register( + nameof(ItemTemplate)); + + public IDataTemplate? ItemTemplate + { + get => GetValue(ItemTemplateProperty); + set => SetValue(ItemTemplateProperty, value); + } + + public static readonly StyledProperty SeparatorProperty = AvaloniaProperty.Register( + nameof(Separator)); + + public string Separator + { + get => GetValue(SeparatorProperty); + set => SetValue(SeparatorProperty, value); + } + + public static readonly StyledProperty AllowDuplicatesProperty = AvaloniaProperty.Register( + nameof(AllowDuplicates), defaultValue: true); + + public bool AllowDuplicates + { + get => GetValue(AllowDuplicatesProperty); + set => SetValue(AllowDuplicatesProperty, value); + } + + public static readonly StyledProperty InnerLeftContentProperty = + AvaloniaProperty.Register( + nameof(InnerLeftContent)); + + public object? InnerLeftContent + { + get => GetValue(InnerLeftContentProperty); + set => SetValue(InnerLeftContentProperty, value); + } + + public static readonly StyledProperty InnerRightContentProperty = + AvaloniaProperty.Register( + nameof(InnerRightContent)); + + public object? InnerRightContent + { + get => GetValue(InnerRightContentProperty); + set => SetValue(InnerRightContentProperty, value); + } + + + static TagInput() + { + InputThemeProperty.Changed.AddClassHandler((o, e) => o.OnInputThemePropertyChanged(e)); + TagsProperty.Changed.AddClassHandler((o, e) => o.OnTagsPropertyChanged(e)); + } + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + _itemsControl = e.NameScope.Find(PART_ItemsControl); + Items.Add(_textBox); + } + + private void OnInputThemePropertyChanged(AvaloniaPropertyChangedEventArgs args) + { + var newTheme = args.GetNewValue(); + if (newTheme.TargetType == typeof(TextBox)) + { + _textBox.Theme = newTheme; + } + } + + private void OnTagsPropertyChanged(AvaloniaPropertyChangedEventArgs args) + { + var newTags = args.GetNewValue>(); + for (int i = 0; i < Items.Count - 1; i++) + { + Items.RemoveAt(Items.Count - 1); + } + + for (int i = 0; i < newTags.Count; i++) + { + Items.Insert(Items.Count - 1, newTags[i]); + } + } + + private void OnTextBoxKeyDown(object? sender, KeyEventArgs args) + { + if (args.Key == Key.Enter) + { + if (_textBox.Text?.Length > 0) + { + string[] values; + if (!string.IsNullOrEmpty(Separator)) + { + values = _textBox.Text.Split(new string[] { Separator }, + StringSplitOptions.RemoveEmptyEntries); + } + else + { + values = new[] { _textBox.Text }; + } + + if (!AllowDuplicates) + { + values = values.Distinct().Except(Tags).ToArray(); + } + + for (int i = 0; i < values.Length; i++) + { + int index = Items.Count - 1; + Items.Insert(index, values[i]); + Tags.Insert(index, values[i]); + } + + _textBox.Text = ""; + } + } + else if (args.Key == Key.Delete || args.Key == Key.Back) + { + if (_textBox.Text?.Length == 0) + { + if (Tags.Count == 0) + { + return; + } + int index = Items.Count - 2; + Items.RemoveAt(index); + Tags.RemoveAt(index); + } + } + } + + public void Close(object o) + { + if (o is Control t) + { + if (t.Parent is ContentPresenter presenter) + { + int? index = _itemsControl?.IndexFromContainer(presenter); + if (index is >= 0 && index < Items.Count - 1) + { + Items.RemoveAt(index.Value); + Tags.RemoveAt(index.Value); + } + } + } + } +} \ No newline at end of file diff --git a/src/Ursa/Controls/TagInput/TagInputPanel.cs b/src/Ursa/Controls/TagInput/TagInputPanel.cs new file mode 100644 index 0000000..38117e4 --- /dev/null +++ b/src/Ursa/Controls/TagInput/TagInputPanel.cs @@ -0,0 +1,111 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Utilities; + +namespace Ursa.Controls; + +/// +/// TagInputPanel is a horizontal wrap with last item filling last row. +/// +public class TagInputPanel: Panel +{ + protected override Size MeasureOverride(Size availableSize) + { + // return base.MeasureOverride(availableSize); + double currentLineX = 0; + double currentLineHeight = 0; + double totalHeight = 0; + + var children = Children; + for (int i = 0; i < children.Count-1; i++) + { + var child = children[i]; + child.Measure(availableSize); + double deltaX = availableSize.Width - currentLineX; + // Width is enough to place next child + if (MathUtilities.GreaterThan(deltaX, child.DesiredSize.Width)) + { + currentLineX+=child.DesiredSize.Width; + currentLineHeight = Math.Max(currentLineHeight, child.DesiredSize.Height); + } + // Width is not enough to place next child + // reset currentLineX and currentLineHeight + // accumulate last line height to total height. + // Notice: last line height accumulation only happens when restarting a new line, so it needs to finally add one more time outside iteration. + else + { + currentLineX = child.DesiredSize.Width; + totalHeight += currentLineHeight; + currentLineHeight = child.DesiredSize.Height; + } + } + + var last = children[children.Count - 1]; + last.Measure(availableSize); + double lastDeltaX = availableSize.Width - currentLineX; + // If width is not enough, add a new line, and recalculate total height + if (lastDeltaX < 30) + { + totalHeight+=currentLineHeight; + totalHeight += last.DesiredSize.Height; + } + else + { + currentLineHeight = Math.Max(currentLineHeight, last.DesiredSize.Height); + totalHeight += currentLineHeight; + } + + return new Size(availableSize.Width, totalHeight); + } + + protected override Size ArrangeOverride(Size finalSize) + { + double currentLineX = 0; + double currentLineHeight = 0; + double totalHeight = 0; + var children = Children; + for (int i = 0; i < children.Count - 1; i++) + { + var child = children[i]; + double deltaX = finalSize.Width - currentLineX; + // Width is enough to place next child + if (MathUtilities.GreaterThan(deltaX, child.DesiredSize.Width)) + { + child.Arrange(new Rect(currentLineX, totalHeight, child.DesiredSize.Width, Math.Max(child.DesiredSize.Height, currentLineHeight))); + currentLineX += child.DesiredSize.Width; + currentLineHeight = Math.Max(currentLineHeight, child.DesiredSize.Height); + } + // Width is not enough to place next child + // reset currentLineX and currentLineHeight + // accumulate last line height to total height. + // Notice: last line height accumulation only happens when restarting a new line, so it needs to finally add one more time outside iteration. + else + { + totalHeight += currentLineHeight; + child.Arrange(new Rect(0, totalHeight, Math.Min(child.DesiredSize.Width, finalSize.Width), child.DesiredSize.Height)); + currentLineX = child.DesiredSize.Width; + currentLineHeight = child.DesiredSize.Height; + } + } + + var last = children[children.Count - 1]; + double lastDeltaX = finalSize.Width - currentLineX; + // If width is not enough, add a new line, and recalculate total height + if (lastDeltaX < 10) + { + totalHeight += currentLineHeight; + last.Arrange(new Rect(0, totalHeight, finalSize.Width, last.DesiredSize.Height)); + totalHeight += last.DesiredSize.Height; + } + else + { + currentLineHeight = children.Count == 1 ? finalSize.Height : currentLineHeight; + last.Arrange(new Rect(currentLineX, totalHeight, lastDeltaX, + Math.Max(currentLineHeight, last.DesiredSize.Height))); + currentLineHeight = Math.Max(currentLineHeight, last.DesiredSize.Height); + totalHeight += currentLineHeight; + } + + return new Size(finalSize.Width, totalHeight); + } +} \ No newline at end of file