diff --git a/demo/Ursa.Demo/Pages/TreeComboBoxDemo.axaml b/demo/Ursa.Demo/Pages/TreeComboBoxDemo.axaml
new file mode 100644
index 0000000..2624e7d
--- /dev/null
+++ b/demo/Ursa.Demo/Pages/TreeComboBoxDemo.axaml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demo/Ursa.Demo/Pages/TreeComboBoxDemo.axaml.cs b/demo/Ursa.Demo/Pages/TreeComboBoxDemo.axaml.cs
new file mode 100644
index 0000000..6338466
--- /dev/null
+++ b/demo/Ursa.Demo/Pages/TreeComboBoxDemo.axaml.cs
@@ -0,0 +1,13 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace Ursa.Demo.Pages;
+
+public partial class TreeComboBoxDemo : UserControl
+{
+ public TreeComboBoxDemo()
+ {
+ InitializeComponent();
+ }
+}
\ No newline at end of file
diff --git a/demo/Ursa.Demo/ViewModels/MainViewViewModel.cs b/demo/Ursa.Demo/ViewModels/MainViewViewModel.cs
index 15af99e..a86e947 100644
--- a/demo/Ursa.Demo/ViewModels/MainViewViewModel.cs
+++ b/demo/Ursa.Demo/ViewModels/MainViewViewModel.cs
@@ -55,6 +55,7 @@ public class MainViewViewModel : ViewModelBase
MenuKeys.MenuKeySkeleton => new SkeletonDemoViewModel(),
MenuKeys.MenuKeyTagInput => new TagInputDemoViewModel(),
MenuKeys.MenuKeyTimeline => new TimelineDemoViewModel(),
+ MenuKeys.MenuKeyTreeComboBox => new TreeComboBoxDemoViewModel(),
MenuKeys.MenuKeyTwoTonePathIcon => new TwoTonePathIconDemoViewModel(),
MenuKeys.MenuKeyThemeToggler => new ThemeTogglerDemoViewModel(),
MenuKeys.MenuKeyToolBar => new ToolBarDemoViewModel(),
diff --git a/demo/Ursa.Demo/ViewModels/MenuViewModel.cs b/demo/Ursa.Demo/ViewModels/MenuViewModel.cs
index 5a345ef..006bcd6 100644
--- a/demo/Ursa.Demo/ViewModels/MenuViewModel.cs
+++ b/demo/Ursa.Demo/ViewModels/MenuViewModel.cs
@@ -43,6 +43,7 @@ public class MenuViewModel: ViewModelBase
new() { MenuHeader = "TagInput", Key = MenuKeys.MenuKeyTagInput },
new() { MenuHeader = "Theme Toggler", Key = MenuKeys.MenuKeyThemeToggler },
new() { MenuHeader = "Timeline", Key = MenuKeys.MenuKeyTimeline },
+ new() { MenuHeader = "TreeComboBox", Key = MenuKeys.MenuKeyTreeComboBox },
new() { MenuHeader = "TwoTonePathIcon", Key = MenuKeys.MenuKeyTwoTonePathIcon},
new() { MenuHeader = "ToolBar", Key = MenuKeys.MenuKeyToolBar },
new() { MenuHeader = "Verification Code", Key = MenuKeys.MenuKeyVerificationCode, Status = "New" },
@@ -85,6 +86,7 @@ public static class MenuKeys
public const string MenuKeyTimeline = "Timeline";
public const string MenuKeyTwoTonePathIcon = "TwoTonePathIcon";
public const string MenuKeyThemeToggler = "ThemeToggler";
+ public const string MenuKeyTreeComboBox = "TreeComboBox";
public const string MenuKeyToolBar = "ToolBar";
public const string MenuKeyVerificationCode = "VerificationCode";
diff --git a/demo/Ursa.Demo/ViewModels/TreeComboBoxDemoViewModel.cs b/demo/Ursa.Demo/ViewModels/TreeComboBoxDemoViewModel.cs
new file mode 100644
index 0000000..33a9252
--- /dev/null
+++ b/demo/Ursa.Demo/ViewModels/TreeComboBoxDemoViewModel.cs
@@ -0,0 +1,8 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace Ursa.Demo.ViewModels;
+
+public class TreeComboBoxDemoViewModel: ObservableObject
+{
+
+}
\ No newline at end of file
diff --git a/src/Ursa.Themes.Semi/Controls/TreeComboBox.axaml b/src/Ursa.Themes.Semi/Controls/TreeComboBox.axaml
new file mode 100644
index 0000000..db57123
--- /dev/null
+++ b/src/Ursa.Themes.Semi/Controls/TreeComboBox.axaml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Ursa.Themes.Semi/Controls/_index.axaml b/src/Ursa.Themes.Semi/Controls/_index.axaml
index 9a138a9..4b325cd 100644
--- a/src/Ursa.Themes.Semi/Controls/_index.axaml
+++ b/src/Ursa.Themes.Semi/Controls/_index.axaml
@@ -32,6 +32,7 @@
+
diff --git a/src/Ursa/Controls/ComboBox/TreeComboBox.cs b/src/Ursa/Controls/ComboBox/TreeComboBox.cs
new file mode 100644
index 0000000..060dfee
--- /dev/null
+++ b/src/Ursa/Controls/ComboBox/TreeComboBox.cs
@@ -0,0 +1,128 @@
+using System.Data;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Metadata;
+using Avalonia.Controls.Primitives;
+using Avalonia.Controls.Templates;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.OpenGL.Controls;
+using Irihi.Avalonia.Shared.Common;
+
+
+namespace Ursa.Controls;
+
+[TemplatePart(PartNames.PART_Popup, typeof(Popup))]
+public class TreeComboBox: SelectingItemsControl
+{
+ private static readonly FuncTemplate DefaultPanel =
+ new FuncTemplate(() => new VirtualizingStackPanel());
+
+ public static readonly StyledProperty MaxDropDownHeightProperty =
+ ComboBox.MaxDropDownHeightProperty.AddOwner();
+
+ public double MaxDropDownHeight
+ {
+ get => GetValue(MaxDropDownHeightProperty);
+ set => SetValue(MaxDropDownHeightProperty, value);
+ }
+
+ public static readonly StyledProperty WatermarkProperty =
+ TextBox.WatermarkProperty.AddOwner();
+
+ public string? Watermark
+ {
+ get => GetValue(WatermarkProperty);
+ set => SetValue(WatermarkProperty, value);
+ }
+
+ public static readonly StyledProperty IsDropDownOpenProperty =
+ ComboBox.IsDropDownOpenProperty.AddOwner();
+
+ public bool IsDropDownOpen
+ {
+ get => GetValue(IsDropDownOpenProperty);
+ set => SetValue(IsDropDownOpenProperty, value);
+ }
+
+ public static readonly StyledProperty HorizontalContentAlignmentProperty =
+ ContentControl.HorizontalContentAlignmentProperty.AddOwner();
+
+ public HorizontalAlignment HorizontalContentAlignment
+ {
+ get => GetValue(HorizontalContentAlignmentProperty);
+ set => SetValue(HorizontalContentAlignmentProperty, value);
+ }
+
+ public static readonly StyledProperty VerticalContentAlignmentProperty =
+ ContentControl.VerticalContentAlignmentProperty.AddOwner();
+
+ public VerticalAlignment VerticalContentAlignment
+ {
+ get => GetValue(VerticalContentAlignmentProperty);
+ set => SetValue(VerticalContentAlignmentProperty, value);
+ }
+
+ public static readonly StyledProperty SelectedItemTemplateProperty =
+ AvaloniaProperty.Register(nameof(SelectedItemTemplate));
+
+ public IDataTemplate? SelectedItemTemplate
+ {
+ get => GetValue(SelectedItemTemplateProperty);
+ set => SetValue(SelectedItemTemplateProperty, value);
+ }
+
+ public static readonly DirectProperty SelectionBoxItemProperty = AvaloniaProperty.RegisterDirect(
+ nameof(SelectionBoxItem), o => o.SelectionBoxItem);
+ private object? _selectionBoxItem;
+ public object? SelectionBoxItem
+ {
+ get => _selectionBoxItem;
+ protected set => SetAndRaise(SelectionBoxItemProperty, ref _selectionBoxItem, value);
+ }
+
+ static TreeComboBox()
+ {
+ ItemsPanelProperty.OverrideDefaultValue(DefaultPanel);
+ FocusableProperty.OverrideDefaultValue(true);
+ }
+
+ protected override bool NeedsContainerOverride(object? item, int index, out object? recycleKey)
+ {
+ return NeedsContainer(item, out recycleKey);
+ }
+
+ internal bool NeedsContainerInternal(object? item, int index, out object? recycleKey)
+ {
+ return NeedsContainerOverride(item, index, out recycleKey);
+ }
+
+ protected override Control CreateContainerForItemOverride(object? item, int index, object? recycleKey)
+ {
+ return new TreeComboBoxItem();
+ }
+
+ internal Control CreateContainerForItemInternal(object? item, int index, object? recycleKey)
+ {
+ return CreateContainerForItemOverride(item, index, recycleKey);
+ }
+
+ protected override void ContainerForItemPreparedOverride(Control container, object? item, int index)
+ {
+ base.ContainerForItemPreparedOverride(container, item, index);
+ }
+
+ internal void ContainerForItemPreparedInternal(Control container, object? item, int index)
+ {
+ ContainerForItemPreparedOverride(container, item, index);
+ }
+
+ protected override void OnPointerReleased(PointerReleasedEventArgs e)
+ {
+ base.OnPointerReleased(e);
+ if (e.InitialPressMouseButton == MouseButton.Left)
+ {
+ IsDropDownOpen = !IsDropDownOpen;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Ursa/Controls/ComboBox/TreeComboBoxItem.cs b/src/Ursa/Controls/ComboBox/TreeComboBoxItem.cs
new file mode 100644
index 0000000..3908c00
--- /dev/null
+++ b/src/Ursa/Controls/ComboBox/TreeComboBoxItem.cs
@@ -0,0 +1,110 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Metadata;
+using Avalonia.Controls.Primitives;
+using Avalonia.Input;
+using Avalonia.LogicalTree;
+using Avalonia.Media;
+using Avalonia.Media.TextFormatting;
+using Irihi.Avalonia.Shared.Common;
+using Irihi.Avalonia.Shared.Helpers;
+
+namespace Ursa.Controls;
+
+[TemplatePart(PartNames.PART_Header, typeof(Control))]
+public class TreeComboBoxItem: HeaderedItemsControl, ISelectable
+{
+ private Control? _header;
+ private TreeComboBox? _treeComboBox;
+
+ public static readonly StyledProperty IsSelectedProperty = TreeViewItem.IsSelectedProperty.AddOwner();
+
+ public bool IsSelected
+ {
+ get => GetValue(IsSelectedProperty);
+ set => SetValue(IsSelectedProperty, value);
+ }
+
+ public static readonly StyledProperty IsExpandedProperty = TreeViewItem.IsExpandedProperty.AddOwner();
+
+ public bool IsExpanded
+ {
+ get => GetValue(IsExpandedProperty);
+ set => SetValue(IsExpandedProperty, value);
+ }
+
+
+
+ public static readonly DirectProperty LevelProperty = AvaloniaProperty.RegisterDirect(
+ nameof(Level), o => o.Level, (o, v) => o.Level = v);
+ private int _level;
+ public int Level
+ {
+ get => _level;
+ protected set => SetAndRaise(LevelProperty, ref _level, value);
+ }
+
+ protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
+ {
+ base.OnApplyTemplate(e);
+ DoubleTappedEvent.RemoveHandler(OnDoubleTapped, _header);
+ _header = e.NameScope.Find(PartNames.PART_Header);
+ DoubleTappedEvent.AddHandler(OnDoubleTapped, _header);
+ }
+
+ protected override void OnAttachedToLogicalTree(LogicalTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToLogicalTree(e);
+ _treeComboBox = this.FindLogicalAncestorOfType();
+ Level = CalculateDistanceFromLogicalParent(this);
+ if (this.ItemTemplate is null && this._treeComboBox?.ItemTemplate is not null)
+ {
+ SetCurrentValue(ItemTemplateProperty, this._treeComboBox.ItemTemplate);
+ }
+
+
+
+ }
+
+ private void OnDoubleTapped(object sender, TappedEventArgs e)
+ {
+ if (this.ItemCount <= 0) return;
+ this.SetCurrentValue(IsExpandedProperty, !IsExpanded);
+ e.Handled = true;
+ }
+
+ protected override bool NeedsContainerOverride(object? item, int index, out object? recycleKey)
+ {
+ return EnsureParent().NeedsContainerInternal(item, index, out recycleKey);
+ }
+
+ protected override Control CreateContainerForItemOverride(object? item, int index, object? recycleKey)
+ {
+ return EnsureParent().CreateContainerForItemInternal(item, index, recycleKey);
+ }
+
+ protected override void ContainerForItemPreparedOverride(Control container, object? item, int index)
+ {
+ EnsureParent().ContainerForItemPreparedInternal(container, item, index);
+ }
+
+ // TODO replace with helper method from shared library.
+ private static int CalculateDistanceFromLogicalParent(ILogical? logical, int @default = -1) where T: ILogical
+ {
+ int distance = 0;
+ ILogical? parent = logical;
+ while (parent is not null)
+ {
+ if (parent is T) return distance;
+ parent = parent.LogicalParent;
+ distance++;
+ }
+ return @default;
+ }
+
+ private TreeComboBox EnsureParent()
+ {
+ return this._treeComboBox ??
+ throw new InvalidOperationException("TreeComboBoxItem must be a part of TreeComboBox");
+ }
+}
\ No newline at end of file
diff --git a/src/Ursa/Ursa.csproj b/src/Ursa/Ursa.csproj
index 8c86bf6..853e121 100644
--- a/src/Ursa/Ursa.csproj
+++ b/src/Ursa/Ursa.csproj
@@ -17,7 +17,7 @@
-
+