mirror of
https://github.com/AtomUI/AtomUI.git
synced 2026-10-22 09:23:53 +08:00
1550 lines
57 KiB
C#
1550 lines
57 KiB
C#
using System.Reflection;
|
|
using System.Text.RegularExpressions;
|
|
using AtomUI.Controls;
|
|
using AtomUI.Controls.Primitives;
|
|
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Controls.Presenters;
|
|
using Avalonia.Controls.Primitives;
|
|
using Avalonia.Controls.Templates;
|
|
using Avalonia.Data;
|
|
using Avalonia.Headless;
|
|
using Avalonia.Input;
|
|
using Avalonia.Interactivity;
|
|
using Avalonia.Layout;
|
|
using Avalonia.Threading;
|
|
using Avalonia.VisualTree;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using AvaloniaTextBox = Avalonia.Controls.TextBox;
|
|
using AvaloniaWindow = Avalonia.Controls.Window;
|
|
using AtomUIComboBox = AtomUI.Desktop.Controls.ComboBox;
|
|
using AtomUIComboBoxItem = AtomUI.Desktop.Controls.ComboBoxItem;
|
|
using AtomUITextBox = AtomUI.Desktop.Controls.TextBox;
|
|
|
|
namespace AtomUI.Desktop.Controls.Tests.ComboBox;
|
|
|
|
public class ComboBoxDisplayMemberBindingTests
|
|
{
|
|
static ComboBoxDisplayMemberBindingTests()
|
|
{
|
|
AvaloniaTestApp.EnsureInitialized();
|
|
}
|
|
|
|
[Fact]
|
|
public void Pinned_Open_Request_Opens_ComboBox_And_Its_Template_Popup()
|
|
{
|
|
var comboBox = new AtomUIComboBox
|
|
{
|
|
Width = 200,
|
|
IsMotionEnabled = false,
|
|
ItemsSource = new[] { "Alpha", "Beta" }
|
|
};
|
|
|
|
ShowInWindow(comboBox, () =>
|
|
{
|
|
comboBox.IsPopupPinnedOpen = true;
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
var popup = GetVisualDescendant<Popup>(comboBox, "PART_Popup");
|
|
comboBox.IsDropDownOpen.ShouldBeTrue();
|
|
popup.IsPopupPinnedOpen.ShouldBeTrue();
|
|
popup.IsOpen.ShouldBeTrue();
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Pinned_ComboBox_Rejects_Business_Close_Request()
|
|
{
|
|
var comboBox = new AtomUIComboBox
|
|
{
|
|
Width = 200,
|
|
IsMotionEnabled = false,
|
|
ItemsSource = new[] { "Alpha", "Beta" }
|
|
};
|
|
|
|
ShowInWindow(comboBox, () =>
|
|
{
|
|
comboBox.IsPopupPinnedOpen = true;
|
|
Dispatcher.UIThread.RunJobs();
|
|
var popup = GetVisualDescendant<Popup>(comboBox, "PART_Popup");
|
|
|
|
comboBox.IsDropDownOpen = false;
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
comboBox.IsDropDownOpen.ShouldBeTrue();
|
|
popup.IsPopupPinnedOpen.ShouldBeTrue();
|
|
popup.IsOpen.ShouldBeTrue();
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Pinned_ComboBox_Detach_Closes_And_Reattach_Reopens_It()
|
|
{
|
|
var comboBox = new AtomUIComboBox
|
|
{
|
|
Width = 200,
|
|
IsMotionEnabled = false,
|
|
ItemsSource = new[] { "Alpha", "Beta" }
|
|
};
|
|
|
|
ShowInWindow(comboBox, window =>
|
|
{
|
|
var visualLayerManager = window.Content.ShouldBeOfType<VisualLayerManager>();
|
|
var overlayPanel = visualLayerManager.Child.ShouldBeOfType<ScopeAwareOverlayLayerPanel>();
|
|
comboBox.IsPopupPinnedOpen = true;
|
|
Dispatcher.UIThread.RunJobs();
|
|
var popup = GetVisualDescendant<Popup>(comboBox, "PART_Popup");
|
|
popup.IsOpen.ShouldBeTrue();
|
|
|
|
overlayPanel.Children.Remove(comboBox);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
comboBox.IsPopupPinnedOpen.ShouldBeTrue();
|
|
comboBox.IsDropDownOpen.ShouldBeFalse();
|
|
popup.IsOpen.ShouldBeFalse();
|
|
window.GetVisualDescendants().OfType<OverlayPopupHost>().ShouldBeEmpty();
|
|
|
|
overlayPanel.Children.Add(comboBox);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
comboBox.IsDropDownOpen.ShouldBeTrue();
|
|
popup.IsOpen.ShouldBeTrue();
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void DisplayMemberBinding_DropDown_Item_Content_Is_Vertically_Centered()
|
|
{
|
|
var comboBox = new TestComboBox
|
|
{
|
|
Width = 200,
|
|
ItemsSource = new List<DataItem> { new("Avalonia") },
|
|
DisplayMemberBinding = new Binding(nameof(DataItem.Name)),
|
|
OptionFontSize = 17,
|
|
IsMotionEnabled = false
|
|
};
|
|
var item = new DataItem("Avalonia");
|
|
var container = new AtomUIComboBoxItem();
|
|
|
|
ShowInWindow(container, () =>
|
|
{
|
|
comboBox.PrepareContainerForTest(container, item, 0);
|
|
container.ApplyTemplate();
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
container.Content.ShouldBe(item);
|
|
container.ContentTemplate.ShouldNotBeNull();
|
|
container.FontSize.ShouldBe(17);
|
|
container.VerticalContentAlignment.ShouldBe(
|
|
VerticalAlignment.Center,
|
|
"DisplayMemberBinding uses Avalonia's generated item template, so the ComboBoxItem itself must center presented content.");
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Selected_Content_Presenter_Uses_SelectionBoxItemTemplate()
|
|
{
|
|
var selectedTemplate = new FuncDataTemplate<DataItem>((_, _) => new TextBlock { Text = "Selected value" });
|
|
var comboBox = new AtomUIComboBox
|
|
{
|
|
Width = 200,
|
|
ItemsSource = new List<DataItem> { new("Avalonia") },
|
|
SelectedIndex = 0,
|
|
SelectionBoxItemTemplate = selectedTemplate,
|
|
IsMotionEnabled = false
|
|
};
|
|
|
|
ShowInWindow(comboBox, () =>
|
|
{
|
|
var presenter = comboBox.GetVisualDescendants()
|
|
.OfType<ContentPresenter>()
|
|
.Single(x => x.Name == "SelectedContentPresenter");
|
|
|
|
presenter.ContentTemplate.ShouldBeSameAs(
|
|
selectedTemplate,
|
|
"ComboBox must preserve Avalonia's SelectionBoxItemTemplate semantics instead of hard-wiring selected display to ItemTemplate.");
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Template_Binds_Right_AddOn_Feedback_And_Handle_State_From_Axaml()
|
|
{
|
|
var rightAddOn = new TextBlock { Text = "extra" };
|
|
var feedback = new FormValidateFeedback();
|
|
var comboBox = new AtomUIComboBox
|
|
{
|
|
Width = 200,
|
|
ContentRightAddOn = rightAddOn,
|
|
IsMotionEnabled = false,
|
|
IsEnabled = false
|
|
};
|
|
|
|
((IFormItemFeedbackAware)comboBox).SetFeedbackControl(feedback);
|
|
feedback.ValidateStatus = FormValidateStatus.Error;
|
|
|
|
ShowInWindow(comboBox, () =>
|
|
{
|
|
var contentPresenter = GetVisualDescendant<ContentPresenter>(
|
|
comboBox,
|
|
"PART_ContentRightAddOnPresenter");
|
|
contentPresenter.Content.ShouldBeSameAs(rightAddOn);
|
|
contentPresenter.IsVisible.ShouldBeTrue();
|
|
|
|
var formFeedback = GetVisualDescendant<ContentPresenter>(comboBox, "PART_FormFeedBack");
|
|
formFeedback.Content.ShouldBeSameAs(feedback);
|
|
formFeedback.IsVisible.ShouldBeTrue();
|
|
|
|
var handle = GetVisualDescendant<ComboBoxHandle>(comboBox, "PART_ComboBoxHandle");
|
|
handle.IsMotionEnabled.ShouldBeFalse();
|
|
handle.IsEnabled.ShouldBeFalse();
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Template_Keeps_NonFilter_ItemsPresenter_As_Direct_ScrollViewer_Content()
|
|
{
|
|
var source = ReadRepoFile("src/AtomUI.Desktop.Controls/ComboBox/Themes/ComboBoxTheme.axaml");
|
|
|
|
Regex.IsMatch(
|
|
source,
|
|
"""
|
|
<atom:ScrollViewer\b[^>]*>\s*<ItemsPresenter\s+Name="PART_ItemsPresenter"
|
|
""",
|
|
RegexOptions.CultureInvariant)
|
|
.ShouldBeTrue(
|
|
"The non-filter popup path must keep PART_ItemsPresenter as the direct ScrollViewer content.");
|
|
source.ShouldNotContain("""
|
|
<Panel>
|
|
<ItemsPresenter
|
|
Name="PART_ItemsPresenter"
|
|
""");
|
|
source.ShouldContain("OverlayDismissEventPassThrough=\"True\"");
|
|
}
|
|
|
|
[Fact]
|
|
public void NonEditable_DropDown_Presents_Direct_ComboBoxItem_Containers()
|
|
{
|
|
var comboBox = new AtomUIComboBox
|
|
{
|
|
Width = 200,
|
|
IsMotionEnabled = false
|
|
};
|
|
comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Alpha" });
|
|
comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Beta" });
|
|
comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Gamma" });
|
|
|
|
ShowInWindow(comboBox, window =>
|
|
{
|
|
comboBox.IsDropDownOpen = true;
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
var popupItems = window.GetVisualDescendants()
|
|
.OfType<AtomUIComboBoxItem>()
|
|
.Where(item => item.Content is string)
|
|
.Select(item => item.Content)
|
|
.ToList();
|
|
|
|
popupItems.ShouldBe(
|
|
[
|
|
"Alpha",
|
|
"Beta",
|
|
"Gamma"
|
|
]);
|
|
|
|
var popupFrame = GetVisualDescendant<Border>(window, "PopupFrame");
|
|
popupFrame.Bounds.Height.ShouldBeGreaterThan(
|
|
96,
|
|
"The opened popup must be tall enough to show direct item rows instead of being clipped to the content padding.");
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Editable_Filter_Template_Uses_Outer_ComboBox_ItemsPresenter()
|
|
{
|
|
var source = ReadRepoFile("src/AtomUI.Desktop.Controls/ComboBox/Themes/ComboBoxTheme.axaml");
|
|
|
|
source.ShouldNotContain("ComboBoxCandidateList");
|
|
CountOccurrences(source, "Name=\"PART_ItemsPresenter\"").ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public void Editable_Template_Provides_TextBox_And_Binds_Text()
|
|
{
|
|
var comboBox = new AtomUIComboBox
|
|
{
|
|
Width = 200,
|
|
IsEditable = true,
|
|
IsMotionEnabled = false,
|
|
ItemsSource = new List<DataItem> { new("Alpha") }
|
|
};
|
|
|
|
ShowInWindow(comboBox, () =>
|
|
{
|
|
var textBox = GetVisualDescendant<AvaloniaTextBox>(comboBox, "PART_EditableTextBox");
|
|
textBox.IsVisible.ShouldBeTrue();
|
|
|
|
comboBox.Text = "Al";
|
|
Dispatcher.UIThread.RunJobs();
|
|
textBox.Text.ShouldBe("Al");
|
|
|
|
textBox.Text = "Alpha";
|
|
Dispatcher.UIThread.RunJobs();
|
|
comboBox.Text.ShouldBe("Alpha");
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Editable_Template_Uses_Lightweight_TextBox_With_TextPresenter()
|
|
{
|
|
var comboBox = new AtomUIComboBox
|
|
{
|
|
Width = 200,
|
|
IsEditable = true,
|
|
IsFilterEnabled = true,
|
|
IsMotionEnabled = false,
|
|
ItemsSource = new List<DataItem> { new("Alpha") }
|
|
};
|
|
|
|
ShowInWindow(comboBox, () =>
|
|
{
|
|
var textBox = GetVisualDescendant<AvaloniaTextBox>(comboBox, "PART_EditableTextBox");
|
|
textBox.GetVisualAncestors()
|
|
.OfType<ContentPresenter>()
|
|
.Any(presenter => presenter.Name == "PART_ContentPresenter")
|
|
.ShouldBeTrue(
|
|
"The editable TextBox must live inside AddOnDecoratedBox's content presenter, because the content frame owns ComboBox input chrome and spacing.");
|
|
textBox.ShouldNotBeAssignableTo<AtomUITextBox>(
|
|
"The ComboBox content frame owns the input chrome; the editable field must not nest a full AtomUI TextBox frame inside it.");
|
|
|
|
textBox.ApplyTemplate();
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
var textPresenter = textBox.GetVisualDescendants()
|
|
.OfType<TextPresenter>()
|
|
.SingleOrDefault(presenter => presenter.Name == "PART_TextPresenter");
|
|
textPresenter.ShouldNotBeNull(
|
|
"A focused editable ComboBox without a TextPresenter can accept Text changes but cannot render a caret.");
|
|
textPresenter.Bounds.Height.ShouldBeGreaterThan(0);
|
|
textPresenter.CaretBrush.ShouldNotBeNull();
|
|
|
|
textBox.GetVisualDescendants()
|
|
.OfType<Border>()
|
|
.Any(border => border.Name == "InnerBoxDecorator")
|
|
.ShouldBeFalse(
|
|
"The editable field should only contribute text input visuals; AddOnDecoratedBox already renders the ComboBox frame.");
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Editable_Placeholder_Hides_While_Ime_Preedit_Text_Is_Rendered()
|
|
{
|
|
var comboBox = new AtomUIComboBox
|
|
{
|
|
Width = 200,
|
|
IsEditable = true,
|
|
IsFilterEnabled = true,
|
|
IsMotionEnabled = false,
|
|
PlaceholderText = "请选择",
|
|
ItemsSource = new List<DataItem> { new("Alpha") }
|
|
};
|
|
|
|
ShowInWindow(comboBox, () =>
|
|
{
|
|
var textBox = GetVisualDescendant<AvaloniaTextBox>(comboBox, "PART_EditableTextBox");
|
|
var placeholder = GetVisualDescendant<TextBlock>(textBox, "Placeholder");
|
|
var presenter = GetVisualDescendant<TextPresenter>(textBox, "PART_TextPresenter");
|
|
|
|
placeholder.IsVisible.ShouldBeTrue();
|
|
|
|
presenter.SetCurrentValue(TextPresenter.PreeditTextProperty, "测");
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
placeholder.IsVisible.ShouldBeFalse(
|
|
"IME preedit text is rendered by the editable TextBox presenter before Text is committed, so the placeholder must not remain over it.");
|
|
|
|
presenter.SetCurrentValue(TextPresenter.PreeditTextProperty, string.Empty);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
placeholder.IsVisible.ShouldBeTrue(
|
|
"The editable ComboBox placeholder should return when IME preedit text is cleared and the text is still empty.");
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Editable_Filter_Click_Focuses_TextBox_And_Accepts_Text_Input()
|
|
{
|
|
var comboBox = new AtomUIComboBox
|
|
{
|
|
Width = 200,
|
|
IsEditable = true,
|
|
IsFilterEnabled = true,
|
|
IsMotionEnabled = false,
|
|
ItemsSource = new List<DataItem> { new("Alpha"), new("Beta") }
|
|
};
|
|
|
|
ShowInWindow(comboBox, window =>
|
|
{
|
|
var textBox = GetVisualDescendant<AvaloniaTextBox>(comboBox, "PART_EditableTextBox");
|
|
var clickPoint = textBox.TranslatePoint(
|
|
new Point(textBox.Bounds.Width / 2, textBox.Bounds.Height / 2),
|
|
window);
|
|
|
|
clickPoint.ShouldNotBeNull();
|
|
|
|
window.MouseMove(clickPoint.Value);
|
|
window.MouseDown(clickPoint.Value, MouseButton.Left);
|
|
window.MouseUp(clickPoint.Value, MouseButton.Left);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
textBox.IsFocused.ShouldBeTrue(
|
|
"Editable ComboBox must route pointer focus to PART_EditableTextBox so text input reaches the editable field.");
|
|
|
|
window.KeyTextInput("A");
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
comboBox.Text.ShouldBe("A");
|
|
comboBox.FilterValue.ShouldBe("A");
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Editable_Filter_Surface_Click_Focuses_TextBox_And_Accepts_Text_Input()
|
|
{
|
|
var comboBox = new AtomUIComboBox
|
|
{
|
|
Width = 200,
|
|
IsEditable = true,
|
|
IsFilterEnabled = true,
|
|
IsMotionEnabled = false,
|
|
ItemsSource = new List<DataItem> { new("Alpha"), new("Beta") }
|
|
};
|
|
|
|
ShowInWindow(comboBox, window =>
|
|
{
|
|
var textBox = GetVisualDescendant<AvaloniaTextBox>(comboBox, "PART_EditableTextBox");
|
|
var clickPoint = comboBox.TranslatePoint(
|
|
new Point(16, comboBox.Bounds.Height / 2),
|
|
window);
|
|
|
|
clickPoint.ShouldNotBeNull();
|
|
|
|
window.MouseMove(clickPoint.Value);
|
|
window.MouseDown(clickPoint.Value, MouseButton.Left);
|
|
window.MouseUp(clickPoint.Value, MouseButton.Left);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
textBox.IsFocused.ShouldBeTrue(
|
|
"Clicking the editable ComboBox input surface must focus PART_EditableTextBox, not only the exact TextBox visual bounds.");
|
|
|
|
window.KeyTextInput("A");
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
comboBox.Text.ShouldBe("A");
|
|
comboBox.FilterValue.ShouldBe("A");
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Editable_Filter_Handle_Click_Focuses_TextBox_And_Accepts_Text_Input()
|
|
{
|
|
var comboBox = new AtomUIComboBox
|
|
{
|
|
Width = 200,
|
|
IsEditable = true,
|
|
IsFilterEnabled = true,
|
|
IsMotionEnabled = false,
|
|
ItemsSource = new List<DataItem> { new("Alpha"), new("Beta") }
|
|
};
|
|
|
|
ShowInWindow(comboBox, window =>
|
|
{
|
|
var textBox = GetVisualDescendant<AvaloniaTextBox>(comboBox, "PART_EditableTextBox");
|
|
var handle = GetVisualDescendant<ComboBoxHandle>(comboBox, "PART_ComboBoxHandle");
|
|
var clickPoint = handle.TranslatePoint(
|
|
new Point(handle.Bounds.Width / 2, handle.Bounds.Height / 2),
|
|
window);
|
|
|
|
clickPoint.ShouldNotBeNull();
|
|
|
|
window.MouseMove(clickPoint.Value);
|
|
window.MouseDown(clickPoint.Value, MouseButton.Left);
|
|
window.MouseUp(clickPoint.Value, MouseButton.Left);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
comboBox.IsDropDownOpen.ShouldBeTrue();
|
|
textBox.IsFocused.ShouldBeTrue(
|
|
"Opening an editable ComboBox from the drop-down handle must still leave keyboard input on PART_EditableTextBox.");
|
|
|
|
window.KeyTextInput("A");
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
comboBox.Text.ShouldBe("A");
|
|
comboBox.FilterValue.ShouldBe("A");
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Editable_Filter_Handle_Click_With_Selected_Item_Keeps_TextBox_Focused()
|
|
{
|
|
var comboBox = new AtomUIComboBox
|
|
{
|
|
Width = 200,
|
|
IsEditable = true,
|
|
IsFilterEnabled = true,
|
|
IsMotionEnabled = false
|
|
};
|
|
comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Alpha" });
|
|
comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Beta" });
|
|
comboBox.SelectedIndex = 0;
|
|
|
|
ShowInWindow(comboBox, window =>
|
|
{
|
|
var textBox = GetVisualDescendant<AvaloniaTextBox>(comboBox, "PART_EditableTextBox");
|
|
var handle = GetVisualDescendant<ComboBoxHandle>(comboBox, "PART_ComboBoxHandle");
|
|
var clickPoint = handle.TranslatePoint(
|
|
new Point(handle.Bounds.Width / 2, handle.Bounds.Height / 2),
|
|
window);
|
|
|
|
clickPoint.ShouldNotBeNull();
|
|
|
|
window.MouseMove(clickPoint.Value);
|
|
window.MouseDown(clickPoint.Value, MouseButton.Left);
|
|
window.MouseUp(clickPoint.Value, MouseButton.Left);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
comboBox.IsDropDownOpen.ShouldBeTrue();
|
|
textBox.IsFocused.ShouldBeTrue(
|
|
"Avalonia ComboBox focuses the selected drop-down item when the popup opens; AtomUI editable ComboBox must restore focus to the input box.");
|
|
|
|
window.KeyTextInput("B");
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
comboBox.Text.ShouldEndWith("B");
|
|
comboBox.FilterValue?.ToString().ShouldEndWith("B");
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Editable_Filter_Popup_Passes_Overlay_Input_Through_To_Input_Surface()
|
|
{
|
|
var comboBox = new AtomUIComboBox
|
|
{
|
|
Width = 200,
|
|
IsEditable = true,
|
|
IsFilterEnabled = true,
|
|
IsMotionEnabled = false,
|
|
ItemsSource = new List<DataItem> { new("Alpha"), new("Beta") }
|
|
};
|
|
|
|
ShowInWindow(comboBox, () =>
|
|
{
|
|
var decoratedBox = GetVisualDescendant<AddOnDecoratedBox>(comboBox, AddOnDecoratedBox.AddOnDecoratedBoxPart);
|
|
var popup = GetVisualDescendant<Popup>(comboBox, "PART_Popup");
|
|
|
|
popup.OverlayInputPassThroughElement.ShouldBeSameAs(
|
|
decoratedBox,
|
|
"Editable filtering ComboBox uses an overlay popup, so the overlay must pass pointer input through to the whole input surface.");
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Editable_Filter_Open_Popup_Input_Surface_Click_Passes_Through_And_Accepts_Text()
|
|
{
|
|
var comboBox = new AtomUIComboBox
|
|
{
|
|
Width = 200,
|
|
IsEditable = true,
|
|
IsFilterEnabled = true,
|
|
IsMotionEnabled = false,
|
|
ItemsSource = new List<DataItem> { new("Alpha"), new("Beta") }
|
|
};
|
|
|
|
ShowInWindow(comboBox, window =>
|
|
{
|
|
var textBox = GetVisualDescendant<AvaloniaTextBox>(comboBox, "PART_EditableTextBox");
|
|
comboBox.IsDropDownOpen = true;
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
var clickPoint = comboBox.TranslatePoint(
|
|
new Point(16, comboBox.Bounds.Height / 2),
|
|
window);
|
|
|
|
clickPoint.ShouldNotBeNull();
|
|
|
|
window.MouseMove(clickPoint.Value);
|
|
window.MouseDown(clickPoint.Value, MouseButton.Left);
|
|
window.MouseUp(clickPoint.Value, MouseButton.Left);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
textBox.IsFocused.ShouldBeTrue(
|
|
"When the overlay popup is open, pointer input over the ComboBox input surface must pass through the overlay and focus PART_EditableTextBox.");
|
|
|
|
window.KeyTextInput("A");
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
comboBox.Text.ShouldBe("A");
|
|
comboBox.FilterValue.ShouldBe("A");
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Editable_Filter_Popup_Opened_Focuses_TextBox()
|
|
{
|
|
var comboBox = new AtomUIComboBox
|
|
{
|
|
Width = 200,
|
|
IsEditable = true,
|
|
IsFilterEnabled = true,
|
|
IsMotionEnabled = false,
|
|
ItemsSource = new List<DataItem> { new("Alpha"), new("Beta") }
|
|
};
|
|
|
|
ShowInWindow(comboBox, window =>
|
|
{
|
|
var textBox = GetVisualDescendant<AvaloniaTextBox>(comboBox, "PART_EditableTextBox");
|
|
|
|
comboBox.IsDropDownOpen = true;
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
textBox.IsFocused.ShouldBeTrue(
|
|
"Editable filtering ComboBox must follow Select's popup lifecycle and focus the text input after the popup has opened.");
|
|
|
|
window.KeyTextInput("A");
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
comboBox.Text.ShouldBe("A");
|
|
comboBox.FilterValue.ShouldBe("A");
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Editable_Filter_Popup_Opened_Does_Not_Select_All_Input_Text()
|
|
{
|
|
var comboBox = new AtomUIComboBox
|
|
{
|
|
Width = 200,
|
|
IsEditable = true,
|
|
IsFilterEnabled = true,
|
|
IsMotionEnabled = false,
|
|
ItemsSource = new List<DataItem> { new("Alpha"), new("Beta"), new("Gamma") }
|
|
};
|
|
|
|
ShowInWindow(comboBox, () =>
|
|
{
|
|
comboBox.Text = "Gamma";
|
|
comboBox.IsDropDownOpen = true;
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
var textBox = GetVisualDescendant<AvaloniaTextBox>(comboBox, "PART_EditableTextBox");
|
|
textBox.IsFocused.ShouldBeTrue();
|
|
textBox.SelectionStart.ShouldBe(
|
|
textBox.Text?.Length ?? 0,
|
|
"Opening the editable filtering popup must not preserve Avalonia ComboBox's SelectAll focus behavior.");
|
|
textBox.SelectionEnd.ShouldBe(textBox.SelectionStart);
|
|
textBox.CaretIndex.ShouldBe(textBox.SelectionStart);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Editable_Filter_Decorated_Box_Focus_Routes_Text_Input_To_TextBox()
|
|
{
|
|
var comboBox = new AtomUIComboBox
|
|
{
|
|
Width = 200,
|
|
IsEditable = true,
|
|
IsFilterEnabled = true,
|
|
IsMotionEnabled = false,
|
|
ItemsSource = new List<DataItem> { new("Alpha"), new("Beta") }
|
|
};
|
|
|
|
ShowInWindow(comboBox, window =>
|
|
{
|
|
var decoratedBox = GetVisualDescendant<AddOnDecoratedBox>(comboBox, AddOnDecoratedBox.AddOnDecoratedBoxPart);
|
|
var textBox = GetVisualDescendant<AvaloniaTextBox>(comboBox, "PART_EditableTextBox");
|
|
|
|
decoratedBox.Focus(NavigationMethod.Pointer);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
textBox.IsFocused.ShouldBeTrue(
|
|
"When the editable ComboBox input surface receives focus, keyboard input must be routed to PART_EditableTextBox.");
|
|
|
|
window.KeyTextInput("A");
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
comboBox.Text.ShouldBe("A");
|
|
comboBox.FilterValue.ShouldBe("A");
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Editable_Filter_Text_Input_Opens_DropDown()
|
|
{
|
|
var comboBox = new AtomUIComboBox
|
|
{
|
|
Width = 200,
|
|
IsEditable = true,
|
|
IsFilterEnabled = true,
|
|
IsMotionEnabled = false,
|
|
ItemsSource = new List<DataItem> { new("Alpha"), new("Beta") }
|
|
};
|
|
|
|
ShowInWindow(comboBox, window =>
|
|
{
|
|
var textBox = GetVisualDescendant<AvaloniaTextBox>(comboBox, "PART_EditableTextBox");
|
|
|
|
textBox.Focus(NavigationMethod.Pointer);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
comboBox.IsDropDownOpen.ShouldBeFalse();
|
|
|
|
window.KeyTextInput("A");
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
comboBox.Text.ShouldBe("A");
|
|
comboBox.FilterValue.ShouldBe("A");
|
|
comboBox.IsDropDownOpen.ShouldBeTrue(
|
|
"Editable filtering ComboBox should open suggestions when the focused input receives text.");
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Editable_Filter_Backspace_Opens_DropDown_With_Filtered_Candidates()
|
|
{
|
|
var comboBox = new AtomUIComboBox
|
|
{
|
|
Width = 200,
|
|
IsEditable = true,
|
|
IsFilterEnabled = true,
|
|
IsMotionEnabled = false,
|
|
ItemsSource = new List<DataItem> { new("Alpha"), new("Beta"), new("Gamma") }
|
|
};
|
|
|
|
ShowInWindow(comboBox, window =>
|
|
{
|
|
comboBox.Text = "Gamma";
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
var textBox = GetVisualDescendant<AvaloniaTextBox>(comboBox, "PART_EditableTextBox");
|
|
textBox.Focus(NavigationMethod.Pointer);
|
|
textBox.CaretIndex = textBox.Text?.Length ?? 0;
|
|
textBox.SelectionStart = textBox.CaretIndex;
|
|
textBox.SelectionEnd = textBox.CaretIndex;
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
comboBox.IsDropDownOpen.ShouldBeFalse();
|
|
|
|
PressKey(window, Key.Back, PhysicalKey.Backspace);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
comboBox.Text.ShouldBe("Gamm");
|
|
comboBox.FilterValue.ShouldBe("Gamm");
|
|
comboBox.IsDropDownOpen.ShouldBeTrue(
|
|
"Backspace is a user text edit in editable filtering mode, so the candidate popup should reopen with the new filter text.");
|
|
|
|
var popupFrame = GetVisualDescendant<Border>(window, "PopupFrame");
|
|
var visibleRows = popupFrame.GetVisualDescendants()
|
|
.OfType<AtomUIComboBoxItem>()
|
|
.Where(item => item.IsVisible && item.Content is DataItem)
|
|
.Select(item => ((DataItem)item.Content!).Name)
|
|
.ToList();
|
|
visibleRows.ShouldBe(["Gamma"]);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Editable_Filter_Backspace_To_Empty_Closes_DropDown()
|
|
{
|
|
var comboBox = new AtomUIComboBox
|
|
{
|
|
Width = 200,
|
|
IsEditable = true,
|
|
IsFilterEnabled = true,
|
|
IsMotionEnabled = false,
|
|
ItemsSource = new List<DataItem> { new("Alpha"), new("Beta") }
|
|
};
|
|
|
|
ShowInWindow(comboBox, window =>
|
|
{
|
|
comboBox.Text = "A";
|
|
comboBox.IsDropDownOpen = true;
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
var textBox = GetVisualDescendant<AvaloniaTextBox>(comboBox, "PART_EditableTextBox");
|
|
textBox.Focus(NavigationMethod.Pointer);
|
|
textBox.CaretIndex = textBox.Text?.Length ?? 0;
|
|
textBox.SelectionStart = textBox.CaretIndex;
|
|
textBox.SelectionEnd = textBox.CaretIndex;
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
PressKey(window, Key.Back, PhysicalKey.Backspace);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
comboBox.Text.ShouldBeEmpty();
|
|
comboBox.FilterValue.ShouldBeNull();
|
|
comboBox.IsDropDownOpen.ShouldBeFalse(
|
|
"Deleting the editable filtering text to empty should close the popup instead of showing an unfiltered list implicitly.");
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Editable_Filter_Programmatic_Text_Does_Not_Open_DropDown()
|
|
{
|
|
var comboBox = new AtomUIComboBox
|
|
{
|
|
Width = 200,
|
|
IsEditable = true,
|
|
IsFilterEnabled = true,
|
|
IsMotionEnabled = false,
|
|
ItemsSource = new List<DataItem> { new("Alpha"), new("Beta") }
|
|
};
|
|
|
|
ShowInWindow(comboBox, () =>
|
|
{
|
|
comboBox.Text = "Al";
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
comboBox.FilterValue.ShouldBe("Al");
|
|
comboBox.IsDropDownOpen.ShouldBeFalse(
|
|
"Only focused editable input changes should open the candidate popup; programmatic Text updates should not.");
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Editable_Filter_Uses_Filtered_Visible_Items_Without_Replacing_Items()
|
|
{
|
|
var items = new List<DataItem>
|
|
{
|
|
new("Alpha"),
|
|
new("Alpine"),
|
|
new("Beta")
|
|
};
|
|
var comboBox = new AtomUIComboBox
|
|
{
|
|
Width = 200,
|
|
IsEditable = true,
|
|
IsFilterEnabled = true,
|
|
IsMotionEnabled = false,
|
|
ItemsSource = items,
|
|
FilterValueSelector = item => item is DataItem dataItem ? dataItem.Name : item?.ToString()
|
|
};
|
|
|
|
ShowInWindow(comboBox, window =>
|
|
{
|
|
comboBox.Text = "Al";
|
|
comboBox.IsDropDownOpen = true;
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
comboBox.Items.Count.ShouldBe(3);
|
|
var popupFrame = GetVisualDescendant<Border>(window, "PopupFrame");
|
|
var visibleRows = popupFrame.GetVisualDescendants()
|
|
.OfType<AtomUIComboBoxItem>()
|
|
.Where(item => item.IsVisible && item.Content is DataItem)
|
|
.Select(item => ((DataItem)item.Content!).Name)
|
|
.ToList();
|
|
|
|
visibleRows.ShouldBe(["Alpha", "Alpine"]);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Editable_Filter_Uses_DisplayMemberBinding_When_FilterValueSelector_Is_Not_Set()
|
|
{
|
|
var comboBox = new AtomUIComboBox
|
|
{
|
|
Width = 200,
|
|
IsEditable = true,
|
|
IsFilterEnabled = true,
|
|
IsMotionEnabled = false,
|
|
DisplayMemberBinding = new Binding(nameof(DataItem.Name)),
|
|
ItemsSource = new List<DataItem>
|
|
{
|
|
new DataItem("Alpha"),
|
|
new DataItem("Alpine"),
|
|
new DataItem("Beta")
|
|
}
|
|
};
|
|
|
|
ShowInWindow(comboBox, window =>
|
|
{
|
|
comboBox.Text = "pine";
|
|
comboBox.IsDropDownOpen = true;
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
var popupFrame = GetVisualDescendant<Border>(window, "PopupFrame");
|
|
var visibleRows = popupFrame.GetVisualDescendants()
|
|
.OfType<AtomUIComboBoxItem>()
|
|
.Where(item => item.IsVisible && item.Content is DataItem)
|
|
.Select(item => ((DataItem)item.Content!).Name)
|
|
.ToList();
|
|
|
|
visibleRows.ShouldBe(["Alpine"]);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Editable_Filter_With_ItemTemplate_Does_Not_Set_Candidate_DisplayMemberBinding()
|
|
{
|
|
var comboBox = new AtomUIComboBox
|
|
{
|
|
Width = 200,
|
|
IsEditable = true,
|
|
IsFilterEnabled = true,
|
|
IsMotionEnabled = false,
|
|
ItemsSource = new List<DataItem> { new("Alpha"), new("Alpine") },
|
|
ItemTemplate = new FuncDataTemplate<DataItem>((item, _) => new TextBlock { Text = item?.Name })
|
|
};
|
|
|
|
ShowInWindow(comboBox, window =>
|
|
{
|
|
comboBox.Text = "Al";
|
|
comboBox.IsDropDownOpen = true;
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
var popupFrame = GetVisualDescendant<Border>(window, "PopupFrame");
|
|
var visibleRows = popupFrame.GetVisualDescendants()
|
|
.OfType<AtomUIComboBoxItem>()
|
|
.Where(item => item.IsVisible && item.Content is DataItem)
|
|
.Select(item => ((DataItem)item.Content!).Name)
|
|
.ToList();
|
|
|
|
visibleRows.ShouldBe(["Alpha", "Alpine"]);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Editable_Filter_DropDown_Presents_Candidate_Item_Rows()
|
|
{
|
|
var comboBox = new AtomUIComboBox
|
|
{
|
|
Width = 200,
|
|
IsEditable = true,
|
|
IsFilterEnabled = true,
|
|
IsMotionEnabled = false
|
|
};
|
|
comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Alpha" });
|
|
comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Alpine" });
|
|
comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Beta" });
|
|
|
|
ShowInWindow(comboBox, window =>
|
|
{
|
|
comboBox.Text = "Al";
|
|
comboBox.IsDropDownOpen = true;
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
comboBox.FilterValue.ShouldBe("Al");
|
|
((AtomUIComboBoxItem)comboBox.Items[2]!).IsVisible.ShouldBeFalse();
|
|
var popupFrame = GetVisualDescendant<Border>(window, "PopupFrame");
|
|
var candidateRows = popupFrame.GetVisualDescendants()
|
|
.OfType<AtomUIComboBoxItem>()
|
|
.Where(item => item.IsVisible && item.Content is string)
|
|
.Select(item => item.Content)
|
|
.ToList();
|
|
|
|
candidateRows.ShouldBe(
|
|
[
|
|
"Alpha",
|
|
"Alpine"
|
|
]);
|
|
|
|
popupFrame.Bounds.Height.ShouldBeGreaterThan(
|
|
48,
|
|
"The editable filter popup must render candidate rows instead of collapsing to an empty strip.");
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Editable_Filter_No_Match_Shows_Empty_Indicator()
|
|
{
|
|
var comboBox = new AtomUIComboBox
|
|
{
|
|
Width = 200,
|
|
IsEditable = true,
|
|
IsFilterEnabled = true,
|
|
IsMotionEnabled = false
|
|
};
|
|
comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Alpha" });
|
|
comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Beta" });
|
|
|
|
ShowInWindow(comboBox, window =>
|
|
{
|
|
comboBox.Text = "NoMatch";
|
|
comboBox.IsDropDownOpen = true;
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
var popupFrame = GetVisualDescendant<Border>(window, "PopupFrame");
|
|
popupFrame.GetVisualDescendants()
|
|
.OfType<AtomUIComboBoxItem>()
|
|
.Where(item => item.IsVisible && item.Content is string)
|
|
.ShouldBeEmpty();
|
|
|
|
var emptyIndicator = GetVisualDescendant<Control>(popupFrame, "PART_EmptyIndicator");
|
|
emptyIndicator.IsVisible.ShouldBeTrue(
|
|
"When editable filtering removes every candidate, the popup should show the standard empty state instead of a blank panel.");
|
|
emptyIndicator.GetVisualDescendants()
|
|
.OfType<Empty>()
|
|
.SingleOrDefault()
|
|
.ShouldNotBeNull();
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Editable_Filter_Candidate_Selection_Maps_Back_To_Source_ComboBoxItem()
|
|
{
|
|
var firstItem = new AtomUIComboBoxItem { Content = "Alpha" };
|
|
var secondItem = new AtomUIComboBoxItem { Content = "Alpine" };
|
|
var thirdItem = new AtomUIComboBoxItem { Content = "Beta" };
|
|
var comboBox = new AtomUIComboBox
|
|
{
|
|
Width = 200,
|
|
IsEditable = true,
|
|
IsFilterEnabled = true,
|
|
IsMotionEnabled = false
|
|
};
|
|
comboBox.Items.Add(firstItem);
|
|
comboBox.Items.Add(secondItem);
|
|
comboBox.Items.Add(thirdItem);
|
|
|
|
ShowInWindow(comboBox, window =>
|
|
{
|
|
comboBox.Text = "Alpine";
|
|
comboBox.IsDropDownOpen = true;
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
var popupFrame = GetVisualDescendant<Border>(window, "PopupFrame");
|
|
var candidateRow = popupFrame.GetVisualDescendants()
|
|
.OfType<AtomUIComboBoxItem>()
|
|
.Single(item => item.IsVisible && item.Content == secondItem.Content);
|
|
comboBox.UpdateSelectionFromEvent(candidateRow, new RoutedEventArgs());
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
comboBox.SelectedItem.ShouldBeSameAs(secondItem);
|
|
comboBox.SelectedIndex.ShouldBe(1);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Editable_Filter_Clicking_Candidate_Closes_DropDown()
|
|
{
|
|
var comboBox = new AtomUIComboBox
|
|
{
|
|
Width = 200,
|
|
IsEditable = true,
|
|
IsFilterEnabled = true,
|
|
IsMotionEnabled = false
|
|
};
|
|
comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Alpha" });
|
|
comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Alpine" });
|
|
comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Beta" });
|
|
comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Gamma" });
|
|
comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Delta" });
|
|
|
|
ShowInWindow(comboBox, window =>
|
|
{
|
|
comboBox.IsDropDownOpen = true;
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
var popupFrame = GetVisualDescendant<Border>(window, "PopupFrame");
|
|
var candidateRow = popupFrame.GetVisualDescendants()
|
|
.OfType<AtomUIComboBoxItem>()
|
|
.Single(item => item.IsVisible && item.Content?.ToString() == "Gamma");
|
|
|
|
ClickControl(window, candidateRow);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
comboBox.SelectedIndex.ShouldBe(3);
|
|
comboBox.Text.ShouldBe("Gamma");
|
|
comboBox.IsDropDownOpen.ShouldBeFalse(
|
|
"Selecting a candidate from the editable filtering popup must commit the option and close the popup.");
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Editable_Filter_Clicking_Candidate_Does_Not_Transiently_Select_All_Text()
|
|
{
|
|
var comboBox = new AtomUIComboBox
|
|
{
|
|
Width = 200,
|
|
IsEditable = true,
|
|
IsFilterEnabled = true,
|
|
IsMotionEnabled = false
|
|
};
|
|
comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Alpha" });
|
|
comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Alpine" });
|
|
comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Beta" });
|
|
comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Gamma" });
|
|
|
|
ShowInWindow(comboBox, window =>
|
|
{
|
|
comboBox.Text = "G";
|
|
comboBox.IsDropDownOpen = true;
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
var textBox = GetVisualDescendant<AvaloniaTextBox>(comboBox, "PART_EditableTextBox");
|
|
var textLength = textBox.Text?.Length ?? 0;
|
|
textBox.SelectionStart = textLength;
|
|
textBox.SelectionEnd = textLength;
|
|
textBox.CaretIndex = textLength;
|
|
|
|
var observedFullSelection = false;
|
|
void TrackSelection()
|
|
{
|
|
var currentTextLength = textBox.Text?.Length ?? 0;
|
|
if (currentTextLength > 0 &&
|
|
textBox.SelectionStart == 0 &&
|
|
textBox.SelectionEnd == currentTextLength)
|
|
{
|
|
observedFullSelection = true;
|
|
}
|
|
}
|
|
|
|
using var selectionStartSubscription = textBox.GetObservable(AvaloniaTextBox.SelectionStartProperty)
|
|
.Subscribe(_ => TrackSelection());
|
|
using var selectionEndSubscription = textBox.GetObservable(AvaloniaTextBox.SelectionEndProperty)
|
|
.Subscribe(_ => TrackSelection());
|
|
|
|
var popupFrame = GetVisualDescendant<Border>(window, "PopupFrame");
|
|
var candidateRow = popupFrame.GetVisualDescendants()
|
|
.OfType<AtomUIComboBoxItem>()
|
|
.Single(item => item.IsVisible &&
|
|
item.Content?.ToString() == "Gamma");
|
|
|
|
ClickControl(window, candidateRow);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
comboBox.Text.ShouldBe("Gamma");
|
|
comboBox.IsDropDownOpen.ShouldBeFalse();
|
|
observedFullSelection.ShouldBeFalse(
|
|
"Committing a popup candidate must not run Avalonia ComboBox's editable SelectAll focus behavior and then collapse it afterward.");
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Editable_Filter_Clicking_Current_Filtered_Candidate_Closes_DropDown()
|
|
{
|
|
var comboBox = new AtomUIComboBox
|
|
{
|
|
Width = 200,
|
|
IsEditable = true,
|
|
IsFilterEnabled = true,
|
|
IsMotionEnabled = false
|
|
};
|
|
comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Alpha" });
|
|
comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Alpine" });
|
|
comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Beta" });
|
|
comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Gamma" });
|
|
|
|
ShowInWindow(comboBox, window =>
|
|
{
|
|
comboBox.Text = "Gamma";
|
|
comboBox.IsDropDownOpen = true;
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
comboBox.SelectedIndex.ShouldBe(3);
|
|
|
|
var popupFrame = GetVisualDescendant<Border>(window, "PopupFrame");
|
|
var candidateRow = popupFrame.GetVisualDescendants()
|
|
.OfType<AtomUIComboBoxItem>()
|
|
.Single(item => item.IsVisible && item.Content?.ToString() == "Gamma");
|
|
|
|
ClickControl(window, candidateRow);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
comboBox.SelectedIndex.ShouldBe(3);
|
|
comboBox.Text.ShouldBe("Gamma");
|
|
comboBox.IsDropDownOpen.ShouldBeFalse(
|
|
"Clicking the currently selected filtered candidate must still close the popup even when SelectedItem does not change.");
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Editable_Filter_Down_Key_Activates_Candidate_Without_Changing_Selection()
|
|
{
|
|
var comboBox = new AtomUIComboBox
|
|
{
|
|
Width = 200,
|
|
IsEditable = true,
|
|
IsFilterEnabled = true,
|
|
IsMotionEnabled = false
|
|
};
|
|
comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Alpha" });
|
|
comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Alpine" });
|
|
comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Beta" });
|
|
|
|
ShowInWindow(comboBox, window =>
|
|
{
|
|
comboBox.Text = "Al";
|
|
comboBox.IsDropDownOpen = true;
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
var textBox = GetVisualDescendant<AvaloniaTextBox>(comboBox, "PART_EditableTextBox");
|
|
textBox.Focus(NavigationMethod.Pointer);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
PressKey(window, Key.Down, PhysicalKey.ArrowDown);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
var visibleRows = GetVisibleStringComboBoxItems(window);
|
|
visibleRows.Select(item => item.Content).ShouldBe(["Alpha", "Alpine"]);
|
|
IsCandidateSelected(visibleRows[0]).ShouldBeTrue(
|
|
"Down should move the keyboard candidate highlight to the first visible option.");
|
|
IsCandidateSelected(visibleRows[1]).ShouldBeFalse();
|
|
comboBox.SelectedIndex.ShouldBe(-1,
|
|
"Moving the keyboard candidate must not commit the real ComboBox selection.");
|
|
|
|
PressKey(window, Key.Down, PhysicalKey.ArrowDown);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
IsCandidateSelected(visibleRows[0]).ShouldBeFalse();
|
|
IsCandidateSelected(visibleRows[1]).ShouldBeTrue(
|
|
"Repeated Down should move the candidate highlight without committing selection.");
|
|
comboBox.SelectedIndex.ShouldBe(-1);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Pointer_And_Keyboard_Navigation_Share_One_Active_Candidate()
|
|
{
|
|
var comboBox = new AtomUIComboBox
|
|
{
|
|
Width = 200,
|
|
IsEditable = true,
|
|
IsFilterEnabled = true,
|
|
IsMotionEnabled = false
|
|
};
|
|
comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Alpha" });
|
|
comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Alpine" });
|
|
|
|
ShowInWindow(comboBox, window =>
|
|
{
|
|
comboBox.Text = "Al";
|
|
comboBox.IsDropDownOpen = true;
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
var visibleRows = GetVisibleStringComboBoxItems(window);
|
|
MovePointerTo(window, visibleRows[0]);
|
|
|
|
IsCandidateSelected(visibleRows[0]).ShouldBeTrue();
|
|
IsCandidateSelected(visibleRows[1]).ShouldBeFalse();
|
|
|
|
PressKey(window, Key.Down, PhysicalKey.ArrowDown);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
visibleRows[0].IsPointerOver.ShouldBeTrue(
|
|
"keyboard navigation must replace the candidate even while the pointer remains over the old row.");
|
|
IsCandidateSelected(visibleRows[0]).ShouldBeFalse();
|
|
IsCandidateSelected(visibleRows[1]).ShouldBeTrue();
|
|
comboBox.SelectedIndex.ShouldBe(-1);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Pointer_Move_Then_Enter_Commits_The_Pointer_Candidate()
|
|
{
|
|
var comboBox = new AtomUIComboBox
|
|
{
|
|
Width = 200,
|
|
IsEditable = true,
|
|
IsFilterEnabled = true,
|
|
IsMotionEnabled = false
|
|
};
|
|
comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Alpha" });
|
|
comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Alpine" });
|
|
|
|
ShowInWindow(comboBox, window =>
|
|
{
|
|
comboBox.Text = "Al";
|
|
comboBox.IsDropDownOpen = true;
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
var visibleRows = GetVisibleStringComboBoxItems(window);
|
|
PressKey(window, Key.Down, PhysicalKey.ArrowDown);
|
|
MovePointerTo(window, visibleRows[1]);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
IsCandidateSelected(visibleRows[0]).ShouldBeFalse();
|
|
IsCandidateSelected(visibleRows[1]).ShouldBeTrue();
|
|
|
|
PressKey(window, Key.Enter, PhysicalKey.Enter);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
comboBox.SelectedIndex.ShouldBe(1);
|
|
comboBox.Text.ShouldBe("Alpine");
|
|
comboBox.IsDropDownOpen.ShouldBeFalse();
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Editable_Filter_Enter_Key_Commits_Active_Candidate_And_Closes_DropDown()
|
|
{
|
|
var comboBox = new AtomUIComboBox
|
|
{
|
|
Width = 200,
|
|
IsEditable = true,
|
|
IsFilterEnabled = true,
|
|
IsMotionEnabled = false
|
|
};
|
|
comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Alpha" });
|
|
comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Alpine" });
|
|
comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Beta" });
|
|
|
|
ShowInWindow(comboBox, window =>
|
|
{
|
|
comboBox.Text = "Al";
|
|
comboBox.IsDropDownOpen = true;
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
var textBox = GetVisualDescendant<AvaloniaTextBox>(comboBox, "PART_EditableTextBox");
|
|
textBox.Focus(NavigationMethod.Pointer);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
PressKey(window, Key.Down, PhysicalKey.ArrowDown);
|
|
PressKey(window, Key.Enter, PhysicalKey.Enter);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
comboBox.SelectedIndex.ShouldBe(0);
|
|
comboBox.Text.ShouldBe("Alpha");
|
|
comboBox.IsDropDownOpen.ShouldBeFalse(
|
|
"Enter should commit the active keyboard candidate and close the popup.");
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Editable_Filter_Up_Key_Activates_Last_Visible_Candidate()
|
|
{
|
|
var comboBox = new AtomUIComboBox
|
|
{
|
|
Width = 200,
|
|
IsEditable = true,
|
|
IsFilterEnabled = true,
|
|
IsMotionEnabled = false
|
|
};
|
|
comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Alpha" });
|
|
comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Alpine" });
|
|
comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Beta" });
|
|
|
|
ShowInWindow(comboBox, window =>
|
|
{
|
|
comboBox.Text = "Al";
|
|
comboBox.IsDropDownOpen = true;
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
var textBox = GetVisualDescendant<AvaloniaTextBox>(comboBox, "PART_EditableTextBox");
|
|
textBox.Focus(NavigationMethod.Pointer);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
PressKey(window, Key.Up, PhysicalKey.ArrowUp);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
var visibleRows = GetVisibleStringComboBoxItems(window);
|
|
visibleRows.Select(item => item.Content).ShouldBe(["Alpha", "Alpine"]);
|
|
IsCandidateSelected(visibleRows[0]).ShouldBeFalse();
|
|
IsCandidateSelected(visibleRows[1]).ShouldBeTrue(
|
|
"Up should move the keyboard candidate highlight to the last visible option when no candidate is active.");
|
|
comboBox.SelectedIndex.ShouldBe(-1);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Editable_Filter_Escape_Key_Clears_Candidate_And_Closes_DropDown()
|
|
{
|
|
var comboBox = new AtomUIComboBox
|
|
{
|
|
Width = 200,
|
|
IsEditable = true,
|
|
IsFilterEnabled = true,
|
|
IsMotionEnabled = false
|
|
};
|
|
comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Alpha" });
|
|
comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Alpine" });
|
|
comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Beta" });
|
|
|
|
ShowInWindow(comboBox, window =>
|
|
{
|
|
comboBox.Text = "Al";
|
|
comboBox.IsDropDownOpen = true;
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
var textBox = GetVisualDescendant<AvaloniaTextBox>(comboBox, "PART_EditableTextBox");
|
|
textBox.Focus(NavigationMethod.Pointer);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
PressKey(window, Key.Down, PhysicalKey.ArrowDown);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
var visibleRows = GetVisibleStringComboBoxItems(window);
|
|
IsCandidateSelected(visibleRows[0]).ShouldBeTrue();
|
|
|
|
PressKey(window, Key.Escape, PhysicalKey.Escape);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
comboBox.IsDropDownOpen.ShouldBeFalse("Escape should close the open candidate popup.");
|
|
comboBox.SelectedIndex.ShouldBe(-1, "Escape should not commit the active keyboard candidate.");
|
|
visibleRows.ForEach(row => IsCandidateSelected(row).ShouldBeFalse());
|
|
});
|
|
}
|
|
|
|
private static T GetVisualDescendant<T>(Control control, string name)
|
|
where T : Control
|
|
{
|
|
var descendant = control.GetVisualDescendants()
|
|
.OfType<T>()
|
|
.SingleOrDefault(x => x.Name == name);
|
|
descendant.ShouldNotBeNull();
|
|
return descendant;
|
|
}
|
|
|
|
private static void ClickControl(AvaloniaWindow window, Control control)
|
|
{
|
|
var clickPoint = control.TranslatePoint(
|
|
new Point(control.Bounds.Width / 2, control.Bounds.Height / 2),
|
|
window);
|
|
clickPoint.ShouldNotBeNull();
|
|
|
|
window.MouseMove(clickPoint.Value);
|
|
window.MouseDown(clickPoint.Value, MouseButton.Left);
|
|
window.MouseUp(clickPoint.Value, MouseButton.Left);
|
|
}
|
|
|
|
private static void MovePointerTo(AvaloniaWindow window, Control control)
|
|
{
|
|
var point = control.TranslatePoint(
|
|
new Point(control.Bounds.Width / 2, control.Bounds.Height / 2),
|
|
window);
|
|
point.ShouldNotBeNull();
|
|
|
|
window.MouseMove(point.Value);
|
|
Dispatcher.UIThread.RunJobs();
|
|
}
|
|
|
|
private static List<AtomUIComboBoxItem> GetVisibleStringComboBoxItems(Control root)
|
|
{
|
|
return root.GetVisualDescendants()
|
|
.OfType<AtomUIComboBoxItem>()
|
|
.Where(item => item.IsVisible && item.Content is string)
|
|
.ToList();
|
|
}
|
|
|
|
private static bool IsCandidateSelected(AtomUIComboBoxItem item)
|
|
{
|
|
var property = typeof(AtomUIComboBoxItem).GetProperty(
|
|
"IsCandidateSelected",
|
|
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
|
property.ShouldNotBeNull("ComboBoxItem should expose an internal keyboard candidate state.");
|
|
return (bool)property.GetValue(item)!;
|
|
}
|
|
|
|
private static void PressKey(AvaloniaWindow window, Key key, PhysicalKey physicalKey)
|
|
{
|
|
window.KeyPress(key, RawInputModifiers.None, physicalKey, null);
|
|
}
|
|
|
|
private static void ShowInWindow(Control content, Action assertion)
|
|
{
|
|
ShowInWindow(content, _ => assertion());
|
|
}
|
|
|
|
private static void ShowInWindow(Control content, Action<AvaloniaWindow> assertion)
|
|
{
|
|
var window = new AvaloniaWindow
|
|
{
|
|
Width = 360,
|
|
Height = 220,
|
|
Content = CreatePopupOverlayHost(content)
|
|
};
|
|
|
|
try
|
|
{
|
|
window.Show();
|
|
Dispatcher.UIThread.RunJobs();
|
|
content.ApplyTemplate();
|
|
content.Measure(Size.Infinity);
|
|
content.Arrange(new Rect(content.DesiredSize));
|
|
Dispatcher.UIThread.RunJobs();
|
|
assertion(window);
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
}
|
|
|
|
private static VisualLayerManager CreatePopupOverlayHost(Control content)
|
|
{
|
|
var overlayPanel = new ScopeAwareOverlayLayerPanel
|
|
{
|
|
Width = 360,
|
|
Height = 220
|
|
};
|
|
overlayPanel.Children.Add(content);
|
|
|
|
var visualLayerManager = new VisualLayerManager
|
|
{
|
|
Child = overlayPanel
|
|
};
|
|
EnablePopupOverlayLayer(visualLayerManager);
|
|
return visualLayerManager;
|
|
}
|
|
|
|
private static void EnablePopupOverlayLayer(VisualLayerManager visualLayerManager)
|
|
{
|
|
var property = typeof(VisualLayerManager).GetProperty(
|
|
"EnablePopupOverlayLayer",
|
|
BindingFlags.Instance | BindingFlags.NonPublic);
|
|
|
|
property.ShouldNotBeNull();
|
|
property.SetValue(visualLayerManager, true);
|
|
}
|
|
|
|
private static string ReadRepoFile(string relativePath)
|
|
{
|
|
var path = GetRepoFile(relativePath);
|
|
File.Exists(path).ShouldBeTrue($"Expected repository file to exist: {relativePath}");
|
|
return File.ReadAllText(path);
|
|
}
|
|
|
|
private static string GetRepoFile(string relativePath)
|
|
{
|
|
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
|
while (directory is not null)
|
|
{
|
|
var candidate = Path.Combine(directory.FullName, relativePath);
|
|
if (File.Exists(candidate))
|
|
{
|
|
return candidate;
|
|
}
|
|
|
|
directory = directory.Parent;
|
|
}
|
|
|
|
return Path.Combine(AppContext.BaseDirectory, relativePath);
|
|
}
|
|
|
|
private static int CountOccurrences(string source, string value)
|
|
{
|
|
var count = 0;
|
|
var startIndex = 0;
|
|
while (true)
|
|
{
|
|
var matchIndex = source.IndexOf(value, startIndex, StringComparison.Ordinal);
|
|
if (matchIndex < 0)
|
|
{
|
|
return count;
|
|
}
|
|
|
|
count++;
|
|
startIndex = matchIndex + value.Length;
|
|
}
|
|
}
|
|
|
|
private sealed record DataItem(string Name);
|
|
|
|
private sealed class TestComboBox : AtomUIComboBox
|
|
{
|
|
public void PrepareContainerForTest(Control container, object? item, int index)
|
|
{
|
|
PrepareContainerForItemOverride(container, item, index);
|
|
}
|
|
}
|
|
}
|