diff options
author | Eric Maupin <me@ermau.com> | 2018-09-05 17:59:08 +0300 |
---|---|---|
committer | GitHub <noreply@github.com> | 2018-09-05 17:59:08 +0300 |
commit | 27a74c5e58217b9e47eb26b89260c00742243a0e (patch) | |
tree | 5c166793b53dddcdfed1ec602bb272b089dfa532 | |
parent | f4e834c60b3b3bc9780e4064b72cd9ccf9a48a74 (diff) | |
parent | c9b474b066ee6b9e8e74a8fbcb5b625a113b627c (diff) |
Merge pull request #379 from xamarin/ermau-autocomplete
Basic autocomplete support
10 files changed, 451 insertions, 42 deletions
diff --git a/Xamarin.PropertyEditing.Tests/MockEditorProvider.cs b/Xamarin.PropertyEditing.Tests/MockEditorProvider.cs index 2225bca..dee6c71 100644 --- a/Xamarin.PropertyEditing.Tests/MockEditorProvider.cs +++ b/Xamarin.PropertyEditing.Tests/MockEditorProvider.cs @@ -13,8 +13,9 @@ namespace Xamarin.PropertyEditing.Tests { public static readonly TargetPlatform MockPlatform = new TargetPlatform (new MockEditorProvider ()); - public MockEditorProvider () + public MockEditorProvider (IResourceProvider resources = null) { + this.resources = resources; } public MockEditorProvider (IObjectEditor editor) @@ -70,9 +71,9 @@ namespace Xamarin.PropertyEditing.Tests { switch (item) { case MockWpfControl msc: - return new MockObjectEditor (msc); + return new MockObjectEditor (msc) { Resources = this.resources }; case MockControl mc: - return new MockNameableEditor (mc); + return new MockNameableEditor (mc) { Resources = this.resources }; case MockBinding mb: return new MockBindingEditor (mb); default: @@ -99,6 +100,7 @@ namespace Xamarin.PropertyEditing.Tests return Task.FromResult<IReadOnlyDictionary<Type, ITypeInfo>> (new Dictionary<Type, ITypeInfo> ()); } + private readonly IResourceProvider resources; private readonly Dictionary<object, IObjectEditor> editorCache = new Dictionary<object, IObjectEditor> (); } }
\ No newline at end of file diff --git a/Xamarin.PropertyEditing.Tests/MockObjectEditor.cs b/Xamarin.PropertyEditing.Tests/MockObjectEditor.cs index 80f11c0..3adb37e 100644 --- a/Xamarin.PropertyEditing.Tests/MockObjectEditor.cs +++ b/Xamarin.PropertyEditing.Tests/MockObjectEditor.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.ComponentModel; using System.Linq; using System.Reflection; +using System.Threading; using System.Threading.Tasks; using Xamarin.PropertyEditing.Reflection; using Xamarin.PropertyEditing.Tests.MockControls; @@ -29,7 +30,7 @@ namespace Xamarin.PropertyEditing.Tests } internal class MockObjectEditor - : IObjectEditor, IObjectEventEditor + : IObjectEditor, IObjectEventEditor, ICompleteValues { public MockObjectEditor () { @@ -111,6 +112,12 @@ namespace Xamarin.PropertyEditing.Tests set; } + public IResourceProvider Resources + { + get; + set; + } + public void ChangeAllProperties () { PropertyChanged?.Invoke (this, new EditorPropertyChangedEventArgs (null)); @@ -332,6 +339,24 @@ namespace Xamarin.PropertyEditing.Tests return Task.FromResult<ITypeInfo> (new TypeInfo (asm, type.Namespace, type.Name)); } + public bool CanAutocomplete (string input) + { + return (input != null && input.Trim ().StartsWith ("@")); + } + + public async Task<IReadOnlyList<string>> GetCompletionsAsync (IPropertyInfo property, string input, CancellationToken cancellationToken) + { + if (Resources == null) + return Array.Empty<string> (); + + input = input.Trim ().TrimStart('@'); + var resources = await Resources.GetResourcesAsync (Target, property, cancellationToken); + return resources.Where (r => + r.Name.IndexOf (input, StringComparison.OrdinalIgnoreCase) != -1 + && r.Name.Length > input.Length) // Skip exact matches + .Select (r => "@" + r.Name).ToList (); + } + internal readonly IDictionary<IPropertyInfo, object> values = new Dictionary<IPropertyInfo, object> (); internal readonly IDictionary<IEventInfo, string> events = new Dictionary<IEventInfo, string> (); internal readonly IReadOnlyDictionary<IPropertyInfo, IReadOnlyList<ITypeInfo>> assignableTypes; diff --git a/Xamarin.PropertyEditing.Tests/PropertyViewModelTests.cs b/Xamarin.PropertyEditing.Tests/PropertyViewModelTests.cs index 00d7e3b..c207b4f 100644 --- a/Xamarin.PropertyEditing.Tests/PropertyViewModelTests.cs +++ b/Xamarin.PropertyEditing.Tests/PropertyViewModelTests.cs @@ -1085,6 +1085,96 @@ namespace Xamarin.PropertyEditing.Tests Assert.That (requested, Is.True, "CreateResourceRequested did not fire"); } + [TestCase (true, true, true)] + [TestCase (false, true, false)] + [TestCase (true, false, false)] + public void AutocompleteEnabled (bool customExpressions, bool hasInterface, bool expected) + { + var target = new object (); + var property = GetPropertyMock (); + var editor = new Mock<IObjectEditor> (); + editor.SetupGet (e => e.Target).Returns (target); + SetupPropertySetAndGet (editor, property.Object); + + if (hasInterface) { + string[] results = { "Foo", "Bar", "Baz" }; + var complete = editor.As<ICompleteValues> (); + complete.Setup (c => c.GetCompletionsAsync (property.Object, It.IsAny<string> (), It.IsAny<CancellationToken> ())) + .ReturnsAsync (results); + } + + var mockProvider = new MockEditorProvider (editor.Object); + var resources = new MockResourceProvider (); + var platform = new TargetPlatform (mockProvider, resources) { + SupportsCustomExpressions = customExpressions + }; + + var vm = GetViewModel (platform, property.Object, new[] { editor.Object }); + Assert.That (vm.SupportsAutocomplete, Is.EqualTo (expected)); + } + + [Test] + public void AutocompleteResults () + { + var target = new object (); + var property = GetPropertyMock (); + var editor = new Mock<IObjectEditor> (); + editor.SetupGet (e => e.Target).Returns (target); + SetupPropertySetAndGet (editor, property.Object); + + string[] results = new[] { "Foo", "Bar", "Baz" }; + var complete = editor.As<ICompleteValues> (); + complete.Setup (c => c.GetCompletionsAsync (property.Object, It.IsAny<string> (), It.IsAny<CancellationToken> ())).ReturnsAsync (results); + + var mockProvider = new MockEditorProvider (editor.Object); + var resources = new MockResourceProvider(); + var platform = new TargetPlatform (mockProvider, resources) { + SupportsCustomExpressions = true + }; + + var vm = GetViewModel (platform, property.Object, new[] { editor.Object }); + Assume.That (vm.SupportsAutocomplete, Is.True); + vm.PreviewCustomExpression = "preview"; + + CollectionAssert.AreEqual (vm.AutocompleteItems, results); + complete.Verify (c => c.GetCompletionsAsync (property.Object, "preview", It.IsAny<CancellationToken> ())); + } + + [Test] + public void AutocompleteCancels () + { + var target = new object (); + var property = GetPropertyMock (); + var editor = new Mock<IObjectEditor> (); + editor.SetupGet (e => e.Target).Returns (target); + SetupPropertySetAndGet (editor, property.Object); + + string[] results = new[] { "Foo", "Bar", "Baz" }; + var tcs = new TaskCompletionSource<IReadOnlyList<string>> (); + + var complete = editor.As<ICompleteValues> (); + complete.Setup (c => c.GetCompletionsAsync (property.Object, It.IsAny<string> (), It.IsAny<CancellationToken> ())) + .Returns<IPropertyInfo,string,CancellationToken> ((a,b,c) => { + c.Register (() => { + tcs.TrySetCanceled (); + }); + return tcs.Task; + }); + + var mockProvider = new MockEditorProvider (editor.Object); + var resources = new MockResourceProvider (); + var platform = new TargetPlatform (mockProvider, resources) { + SupportsCustomExpressions = true + }; + + var vm = GetViewModel (platform, property.Object, new[] { editor.Object }); + Assume.That (vm.SupportsAutocomplete, Is.True); + vm.PreviewCustomExpression = "preview"; + + vm.PreviewCustomExpression = "attempt2"; + Assert.That (tcs.Task.IsCanceled, Is.True); + } + protected TViewModel GetViewModel (IPropertyInfo property, IObjectEditor editor) { return GetViewModel (property, new[] { editor }); diff --git a/Xamarin.PropertyEditing.Windows.Standalone/MainWindow.xaml.cs b/Xamarin.PropertyEditing.Windows.Standalone/MainWindow.xaml.cs index 28f69b7..d543269 100644 --- a/Xamarin.PropertyEditing.Windows.Standalone/MainWindow.xaml.cs +++ b/Xamarin.PropertyEditing.Windows.Standalone/MainWindow.xaml.cs @@ -15,7 +15,8 @@ namespace Xamarin.PropertyEditing.Windows.Standalone public MainWindow () { InitializeComponent (); - this.panel.TargetPlatform = new TargetPlatform (new MockEditorProvider(), new MockResourceProvider(), new MockBindingProvider()) { + var resources = new MockResourceProvider(); + this.panel.TargetPlatform = new TargetPlatform (new MockEditorProvider (resources), resources, new MockBindingProvider()) { SupportsCustomExpressions = true, SupportsMaterialDesign = true, SupportsBrushOpacity = false, @@ -24,7 +25,6 @@ namespace Xamarin.PropertyEditing.Windows.Standalone } }; - this.panel.ResourceProvider = new MockResourceProvider (); #if USE_VS_ICONS this.panel.Resources.MergedDictionaries.Add (new ResourceDictionary { Source = new Uri ("pack://application:,,,/ProppyIcons.xaml", UriKind.RelativeOrAbsolute) diff --git a/Xamarin.PropertyEditing.Windows/EntryPopup.cs b/Xamarin.PropertyEditing.Windows/EntryPopup.cs index fbc3a05..08db76b 100644 --- a/Xamarin.PropertyEditing.Windows/EntryPopup.cs +++ b/Xamarin.PropertyEditing.Windows/EntryPopup.cs @@ -1,6 +1,5 @@ using System; using System.Windows; -using System.Windows.Controls; using System.Windows.Controls.Primitives; using System.Windows.Input; @@ -9,6 +8,20 @@ namespace Xamarin.PropertyEditing.Windows internal class EntryPopup : Popup { + static EntryPopup () + { + // Autocomplete is a popup in a popup and when you go to click the other popup, this one might close + // so we need to hack around this otherwise uncontrollable behavior (StaysOpen has no effect). + var existing = IsOpenProperty.GetMetadata (typeof(Popup)); + PropertyChangedCallback callback = (o,e) => { + if ((bool) e.NewValue || ((EntryPopup) o).CanClose ()) { + existing.PropertyChangedCallback (o, e); + } + }; + + IsOpenProperty.OverrideMetadata(typeof(EntryPopup), new FrameworkPropertyMetadata (existing.DefaultValue, callback, existing.CoerceValueCallback)); + } + public static readonly DependencyProperty ContentTemplateProperty = DependencyProperty.Register ( nameof(ContentTemplate), typeof(DataTemplate), typeof(EntryPopup), new PropertyMetadata ((s,e) => ((EntryPopup)s).UpdateContentTemplate())); @@ -18,6 +31,15 @@ namespace Xamarin.PropertyEditing.Windows set { SetValue (ContentTemplateProperty, value); } } + public static readonly DependencyProperty ValueProperty = DependencyProperty.Register ( + "Value", typeof(string), typeof(EntryPopup), new PropertyMetadata (default(string))); + + public string Value + { + get { return (string) GetValue (ValueProperty); } + set { SetValue (ValueProperty, value); } + } + public override void OnApplyTemplate () { base.OnApplyTemplate (); @@ -33,15 +55,20 @@ namespace Xamarin.PropertyEditing.Windows protected override void OnClosed (EventArgs e) { if (!this.closingFromEscape) { - this.textBox.GetBindingExpression (TextBox.TextProperty)?.UpdateSource(); + GetBindingExpression (ValueProperty)?.UpdateSource (); } else this.closingFromEscape = false; base.OnClosed (e); } - protected override void OnPreviewKeyDown (KeyEventArgs e) + protected override void OnKeyDown (KeyEventArgs e) { + base.OnKeyDown (e); + + if (e.Handled) + return; + if (e.Key == Key.Escape) { this.closingFromEscape = true; IsOpen = false; @@ -50,22 +77,25 @@ namespace Xamarin.PropertyEditing.Windows IsOpen = false; e.Handled = true; } - - base.OnPreviewKeyDown (e); } - private TextBox textBox; + private TextBoxEx textBox; private bool closingFromEscape; + private bool CanClose () + { + return this.textBox.CanCloseParent (); + } + private void UpdateContentTemplate() { Child = ContentTemplate?.LoadContent() as UIElement; if (Child == null) return; - this.textBox = ((FrameworkElement)Child)?.FindName ("entry") as TextBox; + this.textBox = ((FrameworkElement)Child)?.FindName ("entry") as TextBoxEx; if (this.textBox == null) - throw new InvalidOperationException ("Need an entry TextBox for EntryPopup"); + throw new InvalidOperationException ("Need an entry TextBoxEx for EntryPopup"); } } }
\ No newline at end of file diff --git a/Xamarin.PropertyEditing.Windows/TextBoxEx.cs b/Xamarin.PropertyEditing.Windows/TextBoxEx.cs index 295ea32..74863fe 100644 --- a/Xamarin.PropertyEditing.Windows/TextBoxEx.cs +++ b/Xamarin.PropertyEditing.Windows/TextBoxEx.cs @@ -1,19 +1,19 @@ using System; +using System.Collections; using System.ComponentModel; using System.Windows; using System.Windows.Controls; +using System.Windows.Controls.Primitives; using System.Windows.Input; namespace Xamarin.PropertyEditing.Windows { [TemplatePart (Name ="PART_Clear", Type = typeof (Button))] + [TemplatePart (Name = "PART_CompletePopup", Type = typeof (Popup))] + [TemplatePart (Name = "PART_CompleteList", Type = typeof (ListBox))] internal class TextBoxEx : TextBox { - public TextBoxEx() - { - PreviewKeyDown += OnPreviewKeyDown; - } public static readonly DependencyProperty HintProperty = DependencyProperty.Register ( "Hint", typeof(object), typeof(TextBoxEx), new PropertyMetadata (default(object))); @@ -42,6 +42,15 @@ namespace Xamarin.PropertyEditing.Windows set { SetValue (FocusSelectsAllProperty, value); } } + public static readonly DependencyProperty EnableClearProperty = DependencyProperty.Register ( + "EnableClear", typeof(bool), typeof(TextBoxEx), new PropertyMetadata (true)); + + public bool EnableClear + { + get { return (bool) GetValue (EnableClearProperty); } + set { SetValue (EnableClearProperty, value); } + } + public static readonly DependencyProperty ShowClearButtonProperty = DependencyProperty.Register ( "ShowClearButton", typeof(bool), typeof(TextBoxEx), new PropertyMetadata (default(bool))); @@ -60,6 +69,15 @@ namespace Xamarin.PropertyEditing.Windows set { SetValue (ClearButtonStyleProperty, value); } } + public static readonly DependencyProperty EnableSubmitProperty = DependencyProperty.Register ( + "EnableSubmit", typeof(bool), typeof(TextBoxEx), new PropertyMetadata (true)); + + public bool EnableSubmit + { + get { return (bool) GetValue (EnableSubmitProperty); } + set { SetValue (EnableSubmitProperty, value); } + } + public static readonly DependencyProperty SubmitButtonStyleProperty = DependencyProperty.Register ( "SubmitButtonStyle", typeof(Style), typeof(TextBoxEx), new PropertyMetadata (default(Style))); @@ -69,6 +87,24 @@ namespace Xamarin.PropertyEditing.Windows set { SetValue (SubmitButtonStyleProperty, value); } } + public static readonly DependencyProperty ItemsSourceProperty = DependencyProperty.Register ( + "ItemsSource", typeof (IEnumerable), typeof (TextBoxEx), new PropertyMetadata (default (IEnumerable))); + + public IEnumerable ItemsSource + { + get { return (IEnumerable)GetValue (ItemsSourceProperty); } + set { SetValue (ItemsSourceProperty, value); } + } + + public static readonly DependencyProperty ItemTemplateProperty = DependencyProperty.Register ( + "ItemTemplate", typeof (DataTemplate), typeof (TextBoxEx), new PropertyMetadata (default (DataTemplate))); + + public DataTemplate ItemTemplate + { + get { return (DataTemplate)GetValue (ItemTemplateProperty); } + set { SetValue (ItemTemplateProperty, value); } + } + public override void OnApplyTemplate () { base.OnApplyTemplate (); @@ -81,6 +117,14 @@ namespace Xamarin.PropertyEditing.Windows return; } + this.popup = (Popup)GetTemplateChild ("PART_CompletePopup"); + this.list = (ListBox)GetTemplateChild ("PART_CompleteList"); + if (this.popup == null || this.list == null) + throw new InvalidOperationException ("PART_CompletePopup and PART_CompleteList must be present"); + + this.list.ItemContainerGenerator.ItemsChanged += OnItemsChanged; + this.list.PreviewMouseLeftButtonDown += OnListMouseDown; + clear.Click += (sender, e) => { Clear(); }; @@ -114,8 +158,14 @@ namespace Xamarin.PropertyEditing.Windows protected override void OnLostKeyboardFocus (KeyboardFocusChangedEventArgs e) { + if (this.defocusFromList) + return; + base.OnLostKeyboardFocus (e); - OnSubmit(); + this.popup.IsOpen = false; + + if (EnableSubmit) + OnSubmit (); } protected virtual void OnSubmit() @@ -124,6 +174,64 @@ namespace Xamarin.PropertyEditing.Windows expression?.UpdateSource (); } + protected override void OnPreviewKeyDown (KeyEventArgs e) + { + e.Handled = true; + if (e.Key == Key.Down) { + if (this.list.SelectedIndex == -1 || this.list.SelectedIndex + 1 == this.list.Items.Count) + this.list.SelectedIndex = 0; + else + this.list.SelectedIndex++; + + this.list.ScrollIntoView (this.list.SelectedItem); + } else if (e.Key == Key.Up) { + if (this.list.SelectedIndex == -1 || this.list.SelectedIndex == 0) + this.list.SelectedIndex = this.list.Items.Count - 1; + else + this.list.SelectedIndex--; + + this.list.ScrollIntoView (this.list.SelectedItem); + } else if (e.Key == Key.Enter || e.Key == Key.Tab) { + if (this.list.SelectedValue != null) { + SelectCompleteItem (this.list.SelectedItem); + } else if (!this.popup.IsOpen) { + if (EnableSubmit) + OnSubmit (); + else + e.Handled = false; + } + } else if (e.Key == Key.Escape) { + if (this.popup.IsOpen) + this.popup.IsOpen = false; + else if (EnableClear) + Clear (); + else + e.Handled = false; + } else { + e.Handled = false; + } + + base.OnPreviewKeyDown (e); + } + + protected internal bool CanCloseParent () + { + bool can = !this.defocusFromList; + this.defocusFromList = false; + return can; + } + + private bool defocusFromList; + private Popup popup; + private ListBox list; + + private void SelectCompleteItem (object item) + { + Text = item.ToString (); + CaretIndex = Text.Length; + this.popup.IsOpen = false; + } + private void FocusSelect() { if (!FocusSelectsAll) @@ -133,13 +241,27 @@ namespace Xamarin.PropertyEditing.Windows SelectAll (); } - private void OnPreviewKeyDown (object sender, System.Windows.Input.KeyEventArgs e) + private void OnItemsChanged (object sender, ItemsChangedEventArgs e) { - if (e.Key == Key.Enter) { - OnSubmit(); - } else if (e.Key == Key.Escape) { - Clear(); - } + if (!HasEffectiveKeyboardFocus) + return; + + this.popup.IsOpen = (this.list.Items.Count > 0); + if (this.list.SelectedIndex == -1) + this.list.SelectedIndex = 0; + } + + private void OnListMouseDown (object sender, MouseButtonEventArgs e) + { + Point pos = e.GetPosition (this.list); + var element = this.list.InputHitTest (pos) as FrameworkElement; + var item = element?.FindParentOrSelf<ListBoxItem> (); + if (item == null) + return; + + SelectCompleteItem (item.DataContext); + this.defocusFromList = true; + e.Handled = true; } } } diff --git a/Xamarin.PropertyEditing.Windows/Themes/Resources.xaml b/Xamarin.PropertyEditing.Windows/Themes/Resources.xaml index 0772b60..75b5b92 100644 --- a/Xamarin.PropertyEditing.Windows/Themes/Resources.xaml +++ b/Xamarin.PropertyEditing.Windows/Themes/Resources.xaml @@ -497,7 +497,8 @@ <Style x:Key="CustomExpressionPopup" TargetType="local:EntryPopup"> <Setter Property="Placement" Value="Bottom" /> <Setter Property="Width" Value="400" /> - <Setter Property="StaysOpen" Value="False" /> + <Setter Property="StaysOpen" Value="True" /> + <Setter Property="Value" Value="{Binding CustomExpression,Mode=TwoWay,UpdateSourceTrigger=Explicit}" /> <Setter Property="ContentTemplate"> <Setter.Value> <DataTemplate> @@ -509,7 +510,7 @@ </Grid.RowDefinitions> <TextBlock Name="label" Grid.Row="0" Margin="5,5,0,5" FontWeight="Bold" Text="{x:Static prop:Resources.CustomExpression}" /> - <local:TextBoxEx x:Name="entry" Grid.Row="1" Margin="5,0,5,5" AutomationProperties.LabeledBy="{Binding Mode=OneTime,ElementName=label}" FocusSelectsAll="True" Text="{Binding CustomExpression,Mode=TwoWay,UpdateSourceTrigger=Explicit}" /> + <local:TextBoxEx x:Name="entry" Grid.Row="1" Margin="5,0,5,5" AutomationProperties.LabeledBy="{Binding Mode=OneTime,ElementName=label}" FocusSelectsAll="True" EnableClear="False" EnableSubmit="False" Text="{Binding PreviewCustomExpression,Mode=OneWayToSource,UpdateSourceTrigger=PropertyChanged}" ItemsSource="{Binding AutocompleteItems}" /> </Grid> </Border> </DataTemplate> @@ -1631,8 +1632,6 @@ <Style TargetType="ListBoxItem"> <Setter Property="SnapsToDevicePixels" Value="True"/> <Setter Property="Padding" Value="4,1"/> - <Setter Property="HorizontalContentAlignment" Value="{Binding HorizontalContentAlignment, RelativeSource={RelativeSource AncestorType={x:Type ItemsControl}}}"/> - <Setter Property="VerticalContentAlignment" Value="{Binding VerticalContentAlignment, RelativeSource={RelativeSource AncestorType={x:Type ItemsControl}}}"/> <Setter Property="Foreground" Value="{DynamicResource ListItemForegroundBrush}" /> <Setter Property="Background" Value="Transparent"/> <Setter Property="BorderBrush" Value="Transparent"/> @@ -1942,21 +1941,26 @@ <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="{x:Type local:TextBoxEx}"> - <Border x:Name="border" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" Background="{TemplateBinding Background}" SnapsToDevicePixels="True"> - <Grid> - <Grid.ColumnDefinitions> - <ColumnDefinition Width="*" /> - <ColumnDefinition Width="Auto" /> - </Grid.ColumnDefinitions> + <Grid> + <Border x:Name="border" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" Background="{TemplateBinding Background}" SnapsToDevicePixels="True"> + <Grid> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="Auto" /> + </Grid.ColumnDefinitions> - <ContentPresenter Grid.Column="0" Name="hintContent" Margin="2,0,2,0" Visibility="Collapsed" ContentSource="Hint" /> - <ScrollViewer x:Name="PART_ContentHost" Grid.Column="0" Focusable="false" HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Hidden"/> + <ContentPresenter Grid.Column="0" Name="hintContent" Margin="2,0,2,0" Visibility="Collapsed" ContentSource="Hint" /> + <ScrollViewer x:Name="PART_ContentHost" Grid.Column="0" Focusable="false" HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Hidden"/> - <Button Name="PART_Clear" Grid.Column="1" Style="{TemplateBinding ClearButtonStyle}" Visibility="{TemplateBinding ShowClearButton,Converter={StaticResource BoolToVisibilityConverter}}" /> - <Button Name="PART_Submit" Grid.Column="1" Style="{TemplateBinding SubmitButtonStyle}" Visibility="Collapsed" /> - </Grid> - </Border> - <ControlTemplate.Triggers> + <Button Name="PART_Clear" Grid.Column="1" Style="{TemplateBinding ClearButtonStyle}" Visibility="{TemplateBinding ShowClearButton,Converter={StaticResource BoolToVisibilityConverter}}" /> + <Button Name="PART_Submit" Grid.Column="1" Style="{TemplateBinding SubmitButtonStyle}" Visibility="Collapsed" /> + </Grid> + </Border> + <Popup Name="PART_CompletePopup" Placement="Bottom" StaysOpen="True" Focusable="False" Width="{Binding ElementName=border,Path=ActualWidth}"> + <ListBox Name="PART_CompleteList" Focusable="False" MaxHeight="200" ItemsSource="{TemplateBinding ItemsSource}" ItemTemplate="{TemplateBinding ItemTemplate}" /> + </Popup> + </Grid> + <ControlTemplate.Triggers> <Trigger Property="IsEnabled" Value="false"> <Setter Property="Opacity" TargetName="border" Value="0.56"/> </Trigger> diff --git a/Xamarin.PropertyEditing/ICompleteValues.cs b/Xamarin.PropertyEditing/ICompleteValues.cs new file mode 100644 index 0000000..c9e7a88 --- /dev/null +++ b/Xamarin.PropertyEditing/ICompleteValues.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Xamarin.PropertyEditing +{ + /// <summary> + /// Light-up interface to handle auto-completion of values. + /// </summary> + /// <remarks> + /// To be implemented on <see cref="IObjectEditor"/> implementations. Requires <see cref="TargetPlatform.SupportsCustomExpressions"/>. + /// </remarks> + public interface ICompleteValues + { + /// <summary> + /// Gets whether or not the string can be looked at for auto-completion. + /// </summary> + /// <returns> + /// <c>true</c> if <paramref name="input"/> contains auto-completable markers, <c>false</c> otherwise. + /// </returns> + /// <remarks> + /// <para> + /// This is not an indicator of whether a value to autocomplete the <paramref name="input"/> exists, but + /// instead to indicate whether it is a special string that has auto-completion implications. Examples + /// of this are '@' in Android, or '{' in XAML languages. + /// </para> + /// <para> + /// Additionally this is not the only gating mechanic for auto-completion. UI elements that would always + /// auto-complete may bypass this check altogether. Instead this is for inputs that may not auto-complete, + /// but could be enabled to given the correct input. + /// </para> + /// </remarks> + bool CanAutocomplete (string input); + + /// <returns> + /// A list of strings to be used to set the value of the <paramref name="property"/> if selected via <see cref="ValueInfo.CustomExpression"/>. + /// </returns> + /// <remarks> + /// Returned strings are not post-sorted nor count-trimmed. Across multiple editors, only the common elements will be present + /// in order of the first editor. The general assumption is that order would likely be the same across editors. + /// </remarks> + Task<IReadOnlyList<string>> GetCompletionsAsync (IPropertyInfo property, string input, CancellationToken cancellationToken); + } +} diff --git a/Xamarin.PropertyEditing/ViewModels/PropertyViewModel.cs b/Xamarin.PropertyEditing/ViewModels/PropertyViewModel.cs index 4e353c8..605b8c5 100644 --- a/Xamarin.PropertyEditing/ViewModels/PropertyViewModel.cs +++ b/Xamarin.PropertyEditing/ViewModels/PropertyViewModel.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Collections.Specialized; using System.ComponentModel; using System.Linq; +using System.Threading; using System.Threading.Tasks; using System.Windows.Input; @@ -77,6 +78,32 @@ namespace Xamarin.PropertyEditing.ViewModels } } + public bool SupportsAutocomplete + { + get { return this.supportsAutocomplete; } + private set + { + if (this.supportsAutocomplete == value) + return; + + this.supportsAutocomplete = value; + OnPropertyChanged(); + + if (!value) { + this.autocomplete = null; + this.autocompleteCancel?.Cancel(); + this.autocompleteCancel = null; + } + } + } + + public IReadOnlyList<string> AutocompleteItems => this.autocomplete; + + public string PreviewCustomExpression + { + set { UpdateAutocomplete (value); } + } + public string CustomExpression { get { return this.value?.CustomExpression; } @@ -228,6 +255,23 @@ namespace Xamarin.PropertyEditing.ViewModels } } + protected override void OnEditorsChanged (object sender, NotifyCollectionChangedEventArgs e) + { + base.OnEditorsChanged (sender, e); + + if (e.Action == NotifyCollectionChangedAction.Add && SupportsAutocomplete) + return; + + if (TargetPlatform.SupportsCustomExpressions) { + foreach (IObjectEditor editor in Editors) { + if (editor is ICompleteValues) { + SupportsAutocomplete = true; + break; + } + } + } + } + private readonly ICoerce<TValue> coerce; private readonly IValidator<TValue> validator; private readonly ICanNavigateToSource valueNavigator; @@ -235,6 +279,10 @@ namespace Xamarin.PropertyEditing.ViewModels private bool isNullable; private ValueInfo<TValue> value; + private bool supportsAutocomplete; + private ObservableCollectionEx<string> autocomplete; + private CancellationTokenSource autocompleteCancel; + private void SignalValueChange () { OnPropertyChanged (nameof (Value)); @@ -359,6 +407,49 @@ namespace Xamarin.PropertyEditing.ViewModels }); } + private async void UpdateAutocomplete (string value) + { + if (!SupportsAutocomplete) + return; + + if (this.autocomplete == null) { + this.autocomplete = new ObservableCollectionEx<string> (); + OnPropertyChanged (nameof(AutocompleteItems)); + } else { + this.autocompleteCancel.Cancel(); + } + + this.autocompleteCancel = new CancellationTokenSource (); + CancellationToken cancel = this.autocompleteCancel.Token; + + try { + HashSet<string> common = null; + List<Task<IReadOnlyList<string>>> tasks = new List<Task<IReadOnlyList<string>>> (); + + foreach (IObjectEditor editor in Editors) { + if (!(editor is ICompleteValues complete)) + continue; + + tasks.Add (complete.GetCompletionsAsync (Property, value, cancel)); + } + + IReadOnlyList<string> list = null; + do { + Task<IReadOnlyList<string>> results = await Task.WhenAny (tasks); + tasks.Remove (results); + + if (list == null) { + list = results.Result; + common = new HashSet<string> (list); + } else + common.IntersectWith (results.Result); + } while (tasks.Count > 0 && !cancel.IsCancellationRequested); + + this.autocomplete.Reset (list.Where (common.Contains)); + } catch (OperationCanceledException) { + } + } + private static TValue DefaultValue; } diff --git a/Xamarin.PropertyEditing/Xamarin.PropertyEditing.csproj b/Xamarin.PropertyEditing/Xamarin.PropertyEditing.csproj index 45486a8..22785d8 100644 --- a/Xamarin.PropertyEditing/Xamarin.PropertyEditing.csproj +++ b/Xamarin.PropertyEditing/Xamarin.PropertyEditing.csproj @@ -81,6 +81,7 @@ <Compile Include="IClampedPropertyInfo.cs" /> <Compile Include="IColorSpaced.cs" /> <Compile Include="ICoerce.cs" /> + <Compile Include="ICompleteValues.cs" /> <Compile Include="IEditorProvider.cs" /> <Compile Include="IObjectEventEditor.cs" /> <Compile Include="IEventInfo.cs" /> |