diff --git a/src/GitHub.App/Models/SuggestionItem.cs b/src/GitHub.App/Models/SuggestionItem.cs new file mode 100644 index 0000000000..713e0357e9 --- /dev/null +++ b/src/GitHub.App/Models/SuggestionItem.cs @@ -0,0 +1,51 @@ +using System; +using GitHub.Extensions; +using GitHub.Helpers; + +namespace GitHub.Models +{ + /// + /// Represents a single auto completion suggestion (mentions, emojis, issues) in a generic format that can be + /// easily cached. + /// + public class SuggestionItem + { + public SuggestionItem(string name, string description) + { + Guard.ArgumentNotEmptyString(name, "name"); + Guard.ArgumentNotEmptyString(description, "description"); + + Name = name; + Description = description; + } + + public SuggestionItem(string name, string description, string imageUrl) + { + Guard.ArgumentNotEmptyString(name, "name"); + + Name = name; + Description = description; + ImageUrl = imageUrl; + } + + /// + /// The name to display for this entry + /// + public string Name { get; set; } + + /// + /// Additional details about the entry + /// + public string Description { get; set; } + + /// + /// An image url for this entry + /// + public string ImageUrl { get; set; } + + /// + /// The date this suggestion was last modified according to the API. + /// + public DateTimeOffset? LastModifiedDate { get; set; } + } +} diff --git a/src/GitHub.App/SampleData/CommentViewModelDesigner.cs b/src/GitHub.App/SampleData/CommentViewModelDesigner.cs index b060088de9..132fe03568 100644 --- a/src/GitHub.App/SampleData/CommentViewModelDesigner.cs +++ b/src/GitHub.App/SampleData/CommentViewModelDesigner.cs @@ -3,6 +3,7 @@ using System.Reactive; using System.Threading.Tasks; using GitHub.Models; +using GitHub.Services; using GitHub.ViewModels; using ReactiveUI; @@ -37,6 +38,7 @@ public CommentViewModelDesigner() public ReactiveCommand CommitEdit { get; } public ReactiveCommand OpenOnGitHub { get; } = ReactiveCommand.Create(() => { }); public ReactiveCommand Delete { get; } + public IAutoCompleteAdvisor AutoCompleteAdvisor { get; } public Task InitializeAsync(ICommentThreadViewModel thread, ActorModel currentUser, CommentModel comment, CommentEditState state) { diff --git a/src/GitHub.App/SampleData/PullRequestCreationViewModelDesigner.cs b/src/GitHub.App/SampleData/PullRequestCreationViewModelDesigner.cs index 32922438d8..77edc7ab89 100644 --- a/src/GitHub.App/SampleData/PullRequestCreationViewModelDesigner.cs +++ b/src/GitHub.App/SampleData/PullRequestCreationViewModelDesigner.cs @@ -4,6 +4,7 @@ using System.Reactive; using System.Threading.Tasks; using GitHub.Models; +using GitHub.Services; using GitHub.Validation; using GitHub.ViewModels.GitHubPane; using ReactiveUI; @@ -53,6 +54,7 @@ public PullRequestCreationViewModelDesigner() public string PRTitle { get; set; } public ReactivePropertyValidator TitleValidator { get; } + public IAutoCompleteAdvisor AutoCompleteAdvisor { get; } public ReactivePropertyValidator BranchValidator { get; } diff --git a/src/GitHub.App/SampleData/PullRequestReviewAuthoringViewModelDesigner.cs b/src/GitHub.App/SampleData/PullRequestReviewAuthoringViewModelDesigner.cs index cfc8eb23e4..9d0c118cbb 100644 --- a/src/GitHub.App/SampleData/PullRequestReviewAuthoringViewModelDesigner.cs +++ b/src/GitHub.App/SampleData/PullRequestReviewAuthoringViewModelDesigner.cs @@ -3,6 +3,7 @@ using System.Reactive; using System.Threading.Tasks; using GitHub.Models; +using GitHub.Services; using GitHub.ViewModels.GitHubPane; using ReactiveUI; @@ -53,6 +54,7 @@ public PullRequestReviewAuthoringViewModelDesigner() public ReactiveCommand Comment { get; } public ReactiveCommand RequestChanges { get; } public ReactiveCommand Cancel { get; } + public IAutoCompleteAdvisor AutoCompleteAdvisor { get; } public Task InitializeAsync( LocalRepositoryModel localRepository, diff --git a/src/GitHub.App/Services/AutoCompleteAdvisor.cs b/src/GitHub.App/Services/AutoCompleteAdvisor.cs new file mode 100644 index 0000000000..569a87b526 --- /dev/null +++ b/src/GitHub.App/Services/AutoCompleteAdvisor.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel.Composition; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; +using System.Reactive.Linq; +using GitHub.Extensions; +using GitHub.Logging; +using GitHub.Models; +using Serilog; + +namespace GitHub.Services +{ + [Export(typeof(IAutoCompleteAdvisor))] + [PartCreationPolicy(CreationPolicy.Shared)] + public class AutoCompleteAdvisor : IAutoCompleteAdvisor + { + const int SuggestionCount = 5; // The number of suggestions we'll provide. github.com does 5. + + static readonly ILogger log = LogManager.ForContext(); + readonly Lazy> prefixSourceMap; + + [ImportingConstructor] + public AutoCompleteAdvisor([ImportMany(typeof(IAutoCompleteSource))]IEnumerable autocompleteSources) + { + prefixSourceMap = new Lazy>( + () => autocompleteSources.ToDictionary(s => s.Prefix, s => s)); + } + + public IObservable GetAutoCompletionSuggestions(string text, int caretPosition) + { + Guard.ArgumentNotNull("text", text); + + if (caretPosition < 0 || caretPosition > text.Length) + { + string error = String.Format(CultureInfo.InvariantCulture, + "The CaretPosition '{0}', is not in the range of '0' and the text length '{1}' for the text '{2}'", + caretPosition, + text.Length, + text); + + // We need to be alerted when this happens because it should never happen. + // But it apparently did happen in production. + Debug.Fail(error); + log.Error(error); + return Observable.Empty(); + } + var tokenAndSource = PrefixSourceMap + .Select(kvp => new {Source = kvp.Value, Token = ParseAutoCompletionToken(text, caretPosition, kvp.Key)}) + .FirstOrDefault(s => s.Token != null); + + if (tokenAndSource == null) + { + return Observable.Return(AutoCompleteResult.Empty); + } + + return tokenAndSource.Source.GetSuggestions() + .Select(suggestion => new + { + suggestion, + rank = suggestion.GetSortRank(tokenAndSource.Token.SearchSearchPrefix) + }) + .Where(suggestion => suggestion.rank > -1) + .ToList() + .Select(suggestions => suggestions + .OrderByDescending(s => s.rank) + .ThenBy(s => s.suggestion.Name) + .Take(SuggestionCount) + .Select(s => s.suggestion) + .ToList()) + .Select(suggestions => new AutoCompleteResult(tokenAndSource.Token.Offset, + new ReadOnlyCollection(suggestions))) + .Catch(e => + { + log.Error(e, "Error Getting AutoCompleteResult"); + return Observable.Return(AutoCompleteResult.Empty); + }); + } + + [SuppressMessage("Microsoft.Usage", "CA2233:OperationsShouldNotOverflow", MessageId = "caretPosition-1" + , Justification = "We ensure the argument is greater than -1 so it can't overflow")] + public static AutoCompletionToken ParseAutoCompletionToken(string text, int caretPosition, string triggerPrefix) + { + Guard.ArgumentNotNull("text", text); + Guard.ArgumentInRange(caretPosition, 0, text.Length, "caretPosition"); + if (caretPosition == 0 || text.Length == 0) return null; + + // :th : 1 + //:th : 0 + //Hi :th : 3 + int beginningOfWord = text.LastIndexOfAny(new[] { ' ', '\n' }, caretPosition - 1) + 1; + string word = text.Substring(beginningOfWord, caretPosition - beginningOfWord); + if (!word.StartsWith(triggerPrefix, StringComparison.Ordinal)) return null; + + return new AutoCompletionToken(word.Substring(1), beginningOfWord); + } + + Dictionary PrefixSourceMap { get { return prefixSourceMap.Value; } } + } + + public class AutoCompletionToken + { + public AutoCompletionToken(string searchPrefix, int offset) + { + Guard.ArgumentNotNull(searchPrefix, "searchPrefix"); + Guard.ArgumentNonNegative(offset, "offset"); + + SearchSearchPrefix = searchPrefix; + Offset = offset; + } + + /// + /// Used to filter the list of auto complete suggestions to what the user has typed in. + /// + public string SearchSearchPrefix { get; private set; } + public int Offset { get; private set; } + } +} diff --git a/src/GitHub.App/Services/IAutoCompleteSource.cs b/src/GitHub.App/Services/IAutoCompleteSource.cs new file mode 100644 index 0000000000..09b77c4cc3 --- /dev/null +++ b/src/GitHub.App/Services/IAutoCompleteSource.cs @@ -0,0 +1,13 @@ +using System; +using GitHub.Models; + +namespace GitHub.Services +{ + public interface IAutoCompleteSource + { + IObservable GetSuggestions(); + + // The prefix used to trigger auto completion. + string Prefix { get; } + } +} diff --git a/src/GitHub.App/Services/IssuesAutoCompleteSource.cs b/src/GitHub.App/Services/IssuesAutoCompleteSource.cs new file mode 100644 index 0000000000..d2c64e671c --- /dev/null +++ b/src/GitHub.App/Services/IssuesAutoCompleteSource.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Linq; +using System.Reactive.Linq; +using GitHub.Api; +using GitHub.Extensions; +using GitHub.Models; +using GitHub.Primitives; +using Octokit.GraphQL; +using Octokit.GraphQL.Model; +using static Octokit.GraphQL.Variable; + +namespace GitHub.Services +{ + [Export(typeof(IAutoCompleteSource))] + [PartCreationPolicy(CreationPolicy.Shared)] + public class IssuesAutoCompleteSource : IAutoCompleteSource + { + readonly ITeamExplorerContext teamExplorerContext; + readonly IGraphQLClientFactory graphqlFactory; + ICompiledQuery> query; + + [ImportingConstructor] + public IssuesAutoCompleteSource(ITeamExplorerContext teamExplorerContext, IGraphQLClientFactory graphqlFactory) + { + Guard.ArgumentNotNull(teamExplorerContext, nameof(teamExplorerContext)); + Guard.ArgumentNotNull(graphqlFactory, nameof(graphqlFactory)); + + this.teamExplorerContext = teamExplorerContext; + this.graphqlFactory = graphqlFactory; + } + + public IObservable GetSuggestions() + { + var localRepositoryModel = teamExplorerContext.ActiveRepository; + + var hostAddress = HostAddress.Create(localRepositoryModel.CloneUrl.Host); + var owner = localRepositoryModel.Owner; + var name = localRepositoryModel.Name; + + string filter; + string after; + + if (query == null) + { + query = new Query().Search(query: Var(nameof(filter)), SearchType.Issue, 100, after: Var(nameof(after))) + .Select(item => new Page + { + Items = item.Nodes.Select(searchResultItem => + searchResultItem.Switch(selector => selector + .Issue(i => new SuggestionItem("#" + i.Number, i.Title) { LastModifiedDate = i.LastEditedAt }) + .PullRequest(p => new SuggestionItem("#" + p.Number, p.Title) { LastModifiedDate = p.LastEditedAt })) + ).ToList(), + EndCursor = item.PageInfo.EndCursor, + HasNextPage = item.PageInfo.HasNextPage, + TotalCount = item.IssueCount + }) + .Compile(); + } + + filter = $"repo:{owner}/{name}"; + + return Observable.FromAsync(async () => + { + var results = new List(); + + var variables = new Dictionary + { + {nameof(filter), filter }, + }; + + var connection = await graphqlFactory.CreateConnection(hostAddress); + var searchResults = await connection.Run(query, variables); + + results.AddRange(searchResults.Items); + + while (searchResults.HasNextPage) + { + variables[nameof(after)] = searchResults.EndCursor; + searchResults = await connection.Run(query, variables); + + results.AddRange(searchResults.Items); + } + + return results.Select(item => new IssueAutoCompleteSuggestion(item, Prefix)); + + }).SelectMany(observable => observable); + } + + class SearchResult + { + public SuggestionItem SuggestionItem { get; set; } + } + + public string Prefix + { + get { return "#"; } + } + + class IssueAutoCompleteSuggestion : AutoCompleteSuggestion + { + // Just needs to be some value before GitHub stored its first issue. + static readonly DateTimeOffset lowerBound = new DateTimeOffset(2000, 1, 1, 12, 0, 0, TimeSpan.FromSeconds(0)); + + readonly SuggestionItem suggestion; + public IssueAutoCompleteSuggestion(SuggestionItem suggestion, string prefix) + : base(suggestion.Name, suggestion.Description, prefix) + { + this.suggestion = suggestion; + } + + public override int GetSortRank(string text) + { + // We need to override the sort rank behavior because when we display issues, we include the prefix + // unlike mentions. So we need to account for that in how we do filtering. + if (text.Length == 0) + { + return (int) ((suggestion.LastModifiedDate ?? lowerBound) - lowerBound).TotalSeconds; + } + // Name is always "#" followed by issue number. + return Name.StartsWith("#" + text, StringComparison.OrdinalIgnoreCase) + ? 1 + : DescriptionWords.Any(word => word.StartsWith(text, StringComparison.OrdinalIgnoreCase)) + ? 0 + : -1; + } + + // This is what gets "completed" when you tab. + public override string ToString() + { + return Name; + } + } + } +} diff --git a/src/GitHub.App/Services/MentionsAutoCompleteSource.cs b/src/GitHub.App/Services/MentionsAutoCompleteSource.cs new file mode 100644 index 0000000000..68fc002292 --- /dev/null +++ b/src/GitHub.App/Services/MentionsAutoCompleteSource.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Linq; +using System.Reactive.Linq; +using System.Windows.Media.Imaging; +using GitHub.Api; +using GitHub.Extensions; +using GitHub.Models; +using GitHub.Primitives; +using Octokit.GraphQL; +using static Octokit.GraphQL.Variable; + +namespace GitHub.Services +{ + /// + /// Supplies @mentions auto complete suggestions. + /// + [Export(typeof(IAutoCompleteSource))] + [PartCreationPolicy(CreationPolicy.Shared)] + public class MentionsAutoCompleteSource : IAutoCompleteSource + { + const string DefaultAvatar = "pack://application:,,,/GitHub.App;component/Images/default_user_avatar.png"; + + readonly ITeamExplorerContext teamExplorerContext; + readonly IGraphQLClientFactory graphqlFactory; + readonly IAvatarProvider avatarProvider; + ICompiledQuery> query; + + [ImportingConstructor] + public MentionsAutoCompleteSource( + ITeamExplorerContext teamExplorerContext, + IGraphQLClientFactory graphqlFactory, + IAvatarProvider avatarProvider) + { + Guard.ArgumentNotNull(teamExplorerContext, nameof(teamExplorerContext)); + Guard.ArgumentNotNull(graphqlFactory, nameof(graphqlFactory)); + Guard.ArgumentNotNull(avatarProvider, nameof(avatarProvider)); + + this.teamExplorerContext = teamExplorerContext; + this.graphqlFactory = graphqlFactory; + this.avatarProvider = avatarProvider; + } + + public IObservable GetSuggestions() + { + var localRepositoryModel = teamExplorerContext.ActiveRepository; + + var hostAddress = HostAddress.Create(localRepositoryModel.CloneUrl.Host); + var owner = localRepositoryModel.Owner; + var name = localRepositoryModel.Name; + + if (query == null) + { + query = new Query().Repository(owner: Var(nameof(owner)), name: Var(nameof(name))) + .Select(repository => + repository.MentionableUsers(null, null, null, null) + .AllPages() + .Select(sourceItem => + new SuggestionItem(sourceItem.Login, + sourceItem.Name ?? "(unknown)", + sourceItem.AvatarUrl(null))) + .ToList()) + .Compile(); + } + + var variables = new Dictionary + { + {nameof(owner), owner }, + {nameof(name), name }, + }; + + return Observable.FromAsync(async () => + { + var connection = await graphqlFactory.CreateConnection(hostAddress); + var suggestions = await connection.Run(query, variables); + return suggestions.Select(suggestion => new AutoCompleteSuggestion(suggestion.Name, + suggestion.Description, + ResolveImage(suggestion), + Prefix)); + }).SelectMany(enumerable => enumerable); + } + + IObservable ResolveImage(SuggestionItem uri) + { + if (uri.ImageUrl != null) + { + return avatarProvider.GetAvatar(uri.ImageUrl); + } + + return Observable.Return(AvatarProvider.CreateBitmapImage(DefaultAvatar)); + } + + public string Prefix => "@"; + } +} diff --git a/src/GitHub.App/ViewModels/CommentViewModel.cs b/src/GitHub.App/ViewModels/CommentViewModel.cs index b8a640742b..0a750f08a4 100644 --- a/src/GitHub.App/ViewModels/CommentViewModel.cs +++ b/src/GitHub.App/ViewModels/CommentViewModel.cs @@ -41,11 +41,14 @@ public class CommentViewModel : ViewModelBase, ICommentViewModel /// Initializes a new instance of the class. /// /// The comment service. + /// The auto complete advisor. [ImportingConstructor] - public CommentViewModel(ICommentService commentService) + public CommentViewModel(ICommentService commentService, IAutoCompleteAdvisor autoCompleteAdvisor) { Guard.ArgumentNotNull(commentService, nameof(commentService)); + Guard.ArgumentNotNull(autoCompleteAdvisor, nameof(autoCompleteAdvisor)); + AutoCompleteAdvisor = autoCompleteAdvisor; this.commentService = commentService; var canDeleteObservable = this.WhenAnyValue( @@ -190,6 +193,9 @@ public ICommentThreadViewModel Thread /// public ReactiveCommand Delete { get; } + /// + public IAutoCompleteAdvisor AutoCompleteAdvisor { get; } + /// public Task InitializeAsync( ICommentThreadViewModel thread, diff --git a/src/GitHub.App/ViewModels/Documents/IssueishCommentViewModel.cs b/src/GitHub.App/ViewModels/Documents/IssueishCommentViewModel.cs index 184406eb1a..44db887114 100644 --- a/src/GitHub.App/ViewModels/Documents/IssueishCommentViewModel.cs +++ b/src/GitHub.App/ViewModels/Documents/IssueishCommentViewModel.cs @@ -23,9 +23,10 @@ public sealed class IssueishCommentViewModel : CommentViewModel, IIssueishCommen /// Initializes a new instance of the class. /// /// The comment service. + /// [ImportingConstructor] - public IssueishCommentViewModel(ICommentService commentService) - : base(commentService) + public IssueishCommentViewModel(ICommentService commentService, IAutoCompleteAdvisor autoCompleteAdvisor) + : base(commentService, autoCompleteAdvisor) { CloseOrReopen = ReactiveCommand.CreateFromTask( DoCloseOrReopen, diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestCreationViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestCreationViewModel.cs index 89f8e37c50..bd25c85c52 100644 --- a/src/GitHub.App/ViewModels/GitHubPane/PullRequestCreationViewModel.cs +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestCreationViewModel.cs @@ -18,6 +18,7 @@ using GitHub.Models.Drafts; using GitHub.Primitives; using GitHub.Services; +using GitHub.UI; using GitHub.Validation; using Octokit; using ReactiveUI; @@ -51,8 +52,9 @@ public PullRequestCreationViewModel( IPullRequestService service, INotificationService notifications, IMessageDraftStore draftStore, - IGitService gitService) - : this(modelServiceFactory, service, notifications, draftStore, gitService, DefaultScheduler.Instance) + IGitService gitService, + IAutoCompleteAdvisor autoCompleteAdvisor) + : this(modelServiceFactory, service, notifications, draftStore, gitService, autoCompleteAdvisor, DefaultScheduler.Instance) { } @@ -62,6 +64,7 @@ public PullRequestCreationViewModel( INotificationService notifications, IMessageDraftStore draftStore, IGitService gitService, + IAutoCompleteAdvisor autoCompleteAdvisor, IScheduler timerScheduler) { Guard.ArgumentNotNull(modelServiceFactory, nameof(modelServiceFactory)); @@ -69,12 +72,14 @@ public PullRequestCreationViewModel( Guard.ArgumentNotNull(notifications, nameof(notifications)); Guard.ArgumentNotNull(draftStore, nameof(draftStore)); Guard.ArgumentNotNull(gitService, nameof(gitService)); + Guard.ArgumentNotNull(autoCompleteAdvisor, nameof(autoCompleteAdvisor)); Guard.ArgumentNotNull(timerScheduler, nameof(timerScheduler)); this.service = service; this.modelServiceFactory = modelServiceFactory; this.draftStore = draftStore; this.gitService = gitService; + this.AutoCompleteAdvisor = autoCompleteAdvisor; this.timerScheduler = timerScheduler; this.WhenAnyValue(x => x.Branches) @@ -336,6 +341,7 @@ protected string GetDraftKey() public RemoteRepositoryModel GitHubRepository { get { return githubRepository?.Value; } } bool IsExecuting { get { return isExecuting.Value; } } + public IAutoCompleteAdvisor AutoCompleteAdvisor { get; } bool initialized; bool Initialized diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewAuthoringViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewAuthoringViewModel.cs index c263af4585..82ff54359c 100644 --- a/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewAuthoringViewModel.cs +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewAuthoringViewModel.cs @@ -45,8 +45,9 @@ public PullRequestReviewAuthoringViewModel( IPullRequestEditorService editorService, IPullRequestSessionManager sessionManager, IMessageDraftStore draftStore, - IPullRequestFilesViewModel files) - : this(pullRequestService, editorService, sessionManager,draftStore, files, DefaultScheduler.Instance) + IPullRequestFilesViewModel files, + IAutoCompleteAdvisor autoCompleteAdvisor) + : this(pullRequestService, editorService, sessionManager,draftStore, files, autoCompleteAdvisor, DefaultScheduler.Instance) { } @@ -56,12 +57,14 @@ public PullRequestReviewAuthoringViewModel( IPullRequestSessionManager sessionManager, IMessageDraftStore draftStore, IPullRequestFilesViewModel files, + IAutoCompleteAdvisor autoCompleteAdvisor, IScheduler timerScheduler) { Guard.ArgumentNotNull(editorService, nameof(editorService)); Guard.ArgumentNotNull(sessionManager, nameof(sessionManager)); Guard.ArgumentNotNull(draftStore, nameof(draftStore)); Guard.ArgumentNotNull(files, nameof(files)); + Guard.ArgumentNotNull(autoCompleteAdvisor, nameof(autoCompleteAdvisor)); Guard.ArgumentNotNull(timerScheduler, nameof(timerScheduler)); this.pullRequestService = pullRequestService; @@ -77,6 +80,7 @@ public PullRequestReviewAuthoringViewModel( .ToProperty(this, x => x.CanApproveRequestChanges); Files = files; + AutoCompleteAdvisor = autoCompleteAdvisor; var hasBodyOrComments = this.WhenAnyValue( x => x.Body, @@ -118,6 +122,9 @@ public PullRequestDetailModel PullRequestModel /// public IPullRequestFilesViewModel Files { get; } + /// + public IAutoCompleteAdvisor AutoCompleteAdvisor { get; } + /// public string Body { diff --git a/src/GitHub.App/ViewModels/PullRequestReviewCommentViewModel.cs b/src/GitHub.App/ViewModels/PullRequestReviewCommentViewModel.cs index 1d47330c88..4a2c1a8e97 100644 --- a/src/GitHub.App/ViewModels/PullRequestReviewCommentViewModel.cs +++ b/src/GitHub.App/ViewModels/PullRequestReviewCommentViewModel.cs @@ -25,10 +25,12 @@ public class PullRequestReviewCommentViewModel : CommentViewModel, IPullRequestR /// /// Initializes a new instance of the class. /// - /// The comment service + /// The comment service. + /// The auto complete advisor. [ImportingConstructor] - public PullRequestReviewCommentViewModel(ICommentService commentService) - : base(commentService) + public PullRequestReviewCommentViewModel(ICommentService commentService, + IAutoCompleteAdvisor autoCompleteAdvisor) + : base(commentService, autoCompleteAdvisor) { canStartReview = this.WhenAnyValue( x => x.IsPending, diff --git a/src/GitHub.Exports.Reactive/Models/AutoCompleteResult.cs b/src/GitHub.Exports.Reactive/Models/AutoCompleteResult.cs new file mode 100644 index 0000000000..c9461025f9 --- /dev/null +++ b/src/GitHub.Exports.Reactive/Models/AutoCompleteResult.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; + +namespace GitHub.Models +{ + public class AutoCompleteResult + { + public static AutoCompleteResult Empty = new AutoCompleteResult(0, new AutoCompleteSuggestion[] {}); + + public AutoCompleteResult(int offset, IReadOnlyList suggestions) + { + Offset = offset; + Suggestions = suggestions; + } + + public int Offset { get; private set; } + public IReadOnlyList Suggestions { get; private set; } + } +} diff --git a/src/GitHub.Exports.Reactive/Models/AutoCompleteSuggestion.cs b/src/GitHub.Exports.Reactive/Models/AutoCompleteSuggestion.cs new file mode 100644 index 0000000000..e00e67274e --- /dev/null +++ b/src/GitHub.Exports.Reactive/Models/AutoCompleteSuggestion.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reactive.Linq; +using System.Windows.Media.Imaging; +using GitHub.Extensions; +using GitHub.Helpers; +using ReactiveUI; + +namespace GitHub.Models +{ + public class AutoCompleteSuggestion + { + readonly string prefix; + readonly string suffix; + readonly string[] descriptionWords; + + public AutoCompleteSuggestion(string name, string description, string prefix) + : this(name, description, Observable.Return(null), prefix) + { + } + + public AutoCompleteSuggestion(string name, string description, IObservable image, string prefix) + : this(name, description, image, prefix, null) + { + } + + public AutoCompleteSuggestion(string name, IObservable image, string prefix, string suffix) + : this(name, null, image, prefix, suffix) + { + } + + public AutoCompleteSuggestion(string name, string description, IObservable image, string prefix, string suffix) + { + Guard.ArgumentNotEmptyString(name, "name"); + Guard.ArgumentNotEmptyString(prefix, "prefix"); // Suggestions have to have a triggering prefix. + Guard.ArgumentNotNull(image, "image"); + + Name = name; + Description = description; + if (image != null) + { + image = image.ObserveOn(RxApp.MainThreadScheduler); + } + Image = image; + + this.prefix = prefix; + this.suffix = suffix; + + // This is pretty naive, but since the Description is currently limited to a user's FullName, + // This is fine. When we add #issue completion, we may need to fancy this up a bit. + descriptionWords = (description ?? String.Empty) + .Split(new[] {' '}, StringSplitOptions.RemoveEmptyEntries); + } + + /// + /// The name to display in the autocomplete list box. This should not have the "@" or ":" characters around it. + /// + public string Name { get; private set; } + + public string Description { get; private set; } + + public IObservable Image { get; private set; } + + protected IReadOnlyCollection DescriptionWords { get { return descriptionWords; } } + + // What gets autocompleted. + public override string ToString() + { + return prefix + Name + suffix; + } + + /// + /// Used to determine if the suggestion matches the text and if so, how it should be sorted. The larger the + /// rank, the higher it sorts. + /// + /// + /// For mentions we sort suggestions in the following order: + /// + /// 1. Login starts with text + /// 2. Component of Name starts with text (split name by spaces, then match each word) + /// + /// Non matches return -1. The secondary sort is by Login ascending. + /// + /// The suggestion text to match + /// -1 for non-match and the sort order described in the remarks for matches + public virtual int GetSortRank(string text) + { + Guard.ArgumentNotNull(text, "text"); + + return Name.StartsWith(text, StringComparison.OrdinalIgnoreCase) + ? 1 + : descriptionWords.Any(word => word.StartsWith(text, StringComparison.OrdinalIgnoreCase)) + ? 0 + : -1; + } + } +} diff --git a/src/GitHub.Exports.Reactive/Services/IAutoCompleteAdvisor.cs b/src/GitHub.Exports.Reactive/Services/IAutoCompleteAdvisor.cs new file mode 100644 index 0000000000..b071be1bff --- /dev/null +++ b/src/GitHub.Exports.Reactive/Services/IAutoCompleteAdvisor.cs @@ -0,0 +1,10 @@ +using System; +using GitHub.Models; + +namespace GitHub.Services +{ + public interface IAutoCompleteAdvisor + { + IObservable GetAutoCompletionSuggestions(string text, int caretPosition); + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestCreationViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestCreationViewModel.cs index 444c5bd191..fb201f3ab7 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestCreationViewModel.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestCreationViewModel.cs @@ -4,6 +4,7 @@ using ReactiveUI; using System.Threading.Tasks; using System.Reactive; +using GitHub.Services; namespace GitHub.ViewModels.GitHubPane { @@ -16,6 +17,7 @@ public interface IPullRequestCreationViewModel : IPanePageViewModel ReactiveCommand Cancel { get; } string PRTitle { get; set; } ReactivePropertyValidator TitleValidator { get; } + IAutoCompleteAdvisor AutoCompleteAdvisor { get; } Task InitializeAsync(LocalRepositoryModel repository, IConnection connection); } diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestReviewAuthoringViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestReviewAuthoringViewModel.cs index e0be2242d9..6d6137050a 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestReviewAuthoringViewModel.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestReviewAuthoringViewModel.cs @@ -3,6 +3,7 @@ using System.Reactive; using System.Threading.Tasks; using GitHub.Models; +using GitHub.Services; using ReactiveUI; namespace GitHub.ViewModels.GitHubPane @@ -87,6 +88,11 @@ public interface IPullRequestReviewAuthoringViewModel : IPanePageViewModel, IDis /// ReactiveCommand Cancel { get; } + /// + /// Provides an AutoCompleteAdvisor. + /// + IAutoCompleteAdvisor AutoCompleteAdvisor { get; } + /// /// Initializes the view model for creating a new review. /// diff --git a/src/GitHub.Exports.Reactive/ViewModels/ICommentViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/ICommentViewModel.cs index ce35d8a3ff..be6d97e13b 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/ICommentViewModel.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/ICommentViewModel.cs @@ -1,5 +1,6 @@ using System; using System.Reactive; +using GitHub.Services; using ReactiveUI; namespace GitHub.ViewModels @@ -119,5 +120,10 @@ public interface ICommentViewModel : IViewModel /// Deletes a comment. /// ReactiveCommand Delete { get; } + + /// + /// Provides an AutoCompleteAdvisor. + /// + IAutoCompleteAdvisor AutoCompleteAdvisor { get; } } } \ No newline at end of file diff --git a/src/GitHub.Extensions/ReflectionExtensions.cs b/src/GitHub.Extensions/ReflectionExtensions.cs index e69c65f1d5..df10dba2f4 100644 --- a/src/GitHub.Extensions/ReflectionExtensions.cs +++ b/src/GitHub.Extensions/ReflectionExtensions.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.Linq; using System.Reflection; +using System.Runtime.Serialization; namespace GitHub.Extensions { @@ -53,5 +54,20 @@ public static string GetCustomAttributeValue(this Assembly assembly, string p var value = propertyInfo.GetValue(attribute, null); return value.ToString(); } + + public static T CreateUninitialized() + { + // WARNING: THIS METHOD IS PURE EVIL! + // Only use this in cases where T is sealed and has an internal ctor and + // you're SURE the API you're passing it into won't do anything interesting with it. + // Even then, consider refactoring. + return (T)FormatterServices.GetUninitializedObject(typeof(T)); + } + + public static void Invoke(object obj, string methodName, params object[] parameters) + { + var method = obj.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic); + method.Invoke(obj, parameters); + } } } diff --git a/src/GitHub.UI/Assets/Controls.xaml b/src/GitHub.UI/Assets/Controls.xaml index dccf59c145..0f7c550f0f 100644 --- a/src/GitHub.UI/Assets/Controls.xaml +++ b/src/GitHub.UI/Assets/Controls.xaml @@ -6,6 +6,7 @@ mc:Ignorable="d"> + diff --git a/src/GitHub.UI/Assets/Controls/AutoCompleteBox.xaml b/src/GitHub.UI/Assets/Controls/AutoCompleteBox.xaml new file mode 100644 index 0000000000..6d10b00ade --- /dev/null +++ b/src/GitHub.UI/Assets/Controls/AutoCompleteBox.xaml @@ -0,0 +1,252 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteBox.cs b/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteBox.cs new file mode 100644 index 0000000000..013c3e40e9 --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteBox.cs @@ -0,0 +1,1616 @@ +// (c) Copyright Microsoft Corporation. +// (c) Copyright GitHub, Inc. +// This source is subject to the Microsoft Public License (Ms-PL). +// Please see http://go.microsoft.com/fwlink/?LinkID=131993] for details. +// All other rights reserved. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; +using System.Reactive; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using System.Windows; +using System.Windows.Automation.Peers; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Markup; +using System.Windows.Media; +using GitHub.Extensions; +using GitHub.Helpers; +using GitHub.Models; +using GitHub.Services; +using GitHub.UI.Controls; +using GitHub.UI.Controls.AutoCompleteBox; +using GitHub.UI.Helpers; +using ReactiveUI; +using Control = System.Windows.Controls.Control; +using KeyEventArgs = System.Windows.Input.KeyEventArgs; + +namespace GitHub.UI +{ + /// + /// Represents a control that provides a text box for user input and a + /// drop-down that contains possible matches based on the input in the text + /// box. + /// + [SuppressMessage("Microsoft.Design", "CA1001:TypesThatOwnDisposableFieldsShouldBeDisposable", + Justification = "It's a control. It'll be disposed when the app shuts down.")] + [SuppressMessage("Microsoft.Maintainability", "CA1506:AvoidExcessiveClassCoupling", + Justification = "Large implementation keeps the components contained.")] + [ContentProperty("ItemsSource")] + public class AutoCompleteBox : Control, IUpdateVisualState, IPopupTarget + { + private const string elementSelector = "Selector"; + private const string elementPopup = "Popup"; + private const string elementTextBoxStyle = "TextBoxStyle"; + private const string elementItemContainerStyle = "ItemContainerStyle"; + + private readonly IDictionary eventSubscriptions = new Dictionary(); + private List suggestions; // local cached copy of the items data. + + /// + // Gets or sets the observable collection that contains references to + // all of the items in the generated view of data that is provided to + /// the selection-style control adapter. + /// + private ObservableCollection view; + + /// + /// Gets or sets a value to ignore a number of pending change handlers. + /// The value is decremented after each use. This is used to reset the + /// value of properties without performing any of the actions in their + /// change handlers. + /// + /// The int is important as a value because the TextBox + /// TextChanged event does not immediately fire, and this will allow for + /// nested property changes to be ignored. + private int ignoreTextPropertyChange; + private bool ignorePropertyChange; // indicates whether to ignore calling pending change handlers. + private bool userCalledPopulate; // indicates whether the user initiated the current populate call. + private bool popupHasOpened; // A value indicating whether the popup has been opened at least once. + // Helper that provides all of the standard interaction functionality. Making it internal for subclass access. + internal InteractionHelper Interaction { get; set; } + // BindingEvaluator that provides updated string values from a single binding. + /// A weak event listener for the collection changed event. + private WeakEventListener collectionChangedWeakEventListener; + bool supportsShortcutOriginalValue; // Used to save whether the text input allows shortcuts or not. + readonly Subject populatingSubject = new Subject(); + readonly IDpiManager dpiManager; + + /// + /// Initializes a new instance of the + /// class. + /// + public AutoCompleteBox() : this(DpiManager.Instance) + { + } + + public AutoCompleteBox(IDpiManager dpiManager) + { + Guard.ArgumentNotNull(dpiManager, "dpiManager"); + + CompletionOffset = 0; + IsEnabledChanged += ControlIsEnabledChanged; + Interaction = new InteractionHelper(this); + + // Creating the view here ensures that View is always != null + ClearView(); + + Populating = populatingSubject; + + Populating + .SelectMany(_ => + { + var advisor = Advisor ?? EmptyAutoCompleteAdvisor.Instance; + return advisor.GetAutoCompletionSuggestions(Text, TextBox.CaretIndex); + }) + .ObserveOn(RxApp.MainThreadScheduler) + .Subscribe(result => + { + CompletionOffset = result.Offset; + ItemsSource = result.Suggestions; + PopulateComplete(); + }); + this.dpiManager = dpiManager; + } + + public IObservable Populating { get; private set; } + + public int CompletionOffset + { + get { return (int)GetValue(CompletionOffsetProperty); } + set { SetValue(CompletionOffsetProperty, value); } + } + + // Using a DependencyProperty as the backing store for CompletionOffset. This enables animation, styling, binding, etc... + public static readonly DependencyProperty CompletionOffsetProperty = + DependencyProperty.Register( + "CompletionOffset", + typeof(int), + typeof(AutoCompleteBox), + new PropertyMetadata(0)); + + public Point PopupPosition + { + get + { + var position = TextBox.GetPositionFromCharIndex(CompletionOffset); + var dpi = dpiManager.CurrentDpi; + double verticalOffset = 5.0 - TextBox.Margin.Bottom; + position.Offset(0, verticalOffset); // Vertically pad it. Yeah, Point is mutable. WTF? + return dpi.Scale(position); + } + } + + /// + /// Gets or sets the minimum delay, in milliseconds, after text is typed + /// in the text box before the + /// control + /// populates the list of possible matches in the drop-down. + /// + /// The minimum delay, in milliseconds, after text is typed in + /// the text box, but before the + /// populates + /// the list of possible matches in the drop-down. The default is 0. + /// The set value is less than 0. + public int MinimumPopulateDelay + { + get { return (int)GetValue(MinimumPopulateDelayProperty); } + set { SetValue(MinimumPopulateDelayProperty, value); } + } + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DependencyProperty MinimumPopulateDelayProperty = + DependencyProperty.Register( + "MinimumPopulateDelay", + typeof(int), + typeof(AutoCompleteBox), + new PropertyMetadata(OnMinimumPopulateDelayPropertyChanged)); + + /// + /// MinimumPopulateDelayProperty property changed handler. Any current + /// dispatcher timer will be stopped. The timer will not be restarted + /// until the next TextUpdate call by the user. + /// + /// AutoCompleteTextBox that changed its + /// MinimumPopulateDelay. + /// Event arguments. + [SuppressMessage("Microsoft.Usage", "CA2208:InstantiateArgumentExceptionsCorrectly", + Justification = "The exception is most likely to be called through the CLR property setter.")] + private static void OnMinimumPopulateDelayPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var source = d as AutoCompleteBox; + + if (source == null) return; + + if (source.ignorePropertyChange) + { + source.ignorePropertyChange = false; + return; + } + + int newValue = (int)e.NewValue; + if (newValue < 0) + { + source.ignorePropertyChange = true; + d.SetValue(e.Property, e.OldValue); + + throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, + "Invalid value '{0}' for MinimumPopulateDelay", newValue), "e"); + } + + // Resubscribe to TextBox changes with new delay. The easiest way is to just set the TextBox to itself. + var textBox = source.TextBox; + source.TextBox = null; + source.TextBox = textBox; + } + + /// + /// Gets or sets the used + /// to display each item in the drop-down portion of the control. + /// + /// The used to + /// display each item in the drop-down. The default is null. + /// + /// You use the ItemTemplate property to specify the visualization + /// of the data objects in the drop-down portion of the AutoCompleteBox + /// control. If your AutoCompleteBox is bound to a collection and you + /// do not provide specific display instructions by using a + /// DataTemplate, the resulting UI of each item is a string + /// representation of each object in the underlying collection. + /// + public DataTemplate ItemTemplate + { + get { return GetValue(ItemTemplateProperty) as DataTemplate; } + set { SetValue(ItemTemplateProperty, value); } + } + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DependencyProperty ItemTemplateProperty = + DependencyProperty.Register( + "ItemTemplate", + typeof(DataTemplate), + typeof(AutoCompleteBox), + new PropertyMetadata(null)); + + /// + /// Gets or sets the that is + /// applied to the selection adapter contained in the drop-down portion + /// of the + /// control. + /// + /// The applied to the + /// selection adapter contained in the drop-down portion of the + /// control. + /// The default is null. + /// + /// The default selection adapter contained in the drop-down is a + /// ListBox control. + /// + public Style ItemContainerStyle + { + get { return GetValue(ItemContainerStyleProperty) as Style; } + set { SetValue(ItemContainerStyleProperty, value); } + } + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DependencyProperty ItemContainerStyleProperty = + DependencyProperty.Register( + elementItemContainerStyle, + typeof(Style), + typeof(AutoCompleteBox), + new PropertyMetadata(null, null)); + + /// + /// Gets or sets the applied to + /// the text box portion of the + /// control. + /// + /// The applied to the text + /// box portion of the + /// control. + /// The default is null. + public Style TextBoxStyle + { + get { return GetValue(TextBoxStyleProperty) as Style; } + set { SetValue(TextBoxStyleProperty, value); } + } + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DependencyProperty TextBoxStyleProperty = + DependencyProperty.Register( + elementTextBoxStyle, + typeof(Style), + typeof(AutoCompleteBox), + new PropertyMetadata(null)); + + /// + /// Gets or sets the maximum height of the drop-down portion of the + /// control. + /// + /// The maximum height of the drop-down portion of the + /// control. + /// The default is . + /// The specified value is less than 0. + public double MaxDropDownHeight + { + get { return (double)GetValue(MaxDropDownHeightProperty); } + set { SetValue(MaxDropDownHeightProperty, value); } + } + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DependencyProperty MaxDropDownHeightProperty = + DependencyProperty.Register( + "MaxDropDownHeight", + typeof(double), + typeof(AutoCompleteBox), + new PropertyMetadata(double.PositiveInfinity, OnMaxDropDownHeightPropertyChanged)); + + /// + /// MaxDropDownHeightProperty property changed handler. + /// + /// AutoCompleteTextBox that changed its MaxDropDownHeight. + /// Event arguments. + [SuppressMessage("Microsoft.Usage", "CA2208:InstantiateArgumentExceptionsCorrectly" + , Justification = "The exception will be called through a CLR setter in most cases.")] + private static void OnMaxDropDownHeightPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var source = d as AutoCompleteBox; + if (source == null) return; + if (source.ignorePropertyChange) + { + source.ignorePropertyChange = false; + return; + } + + double newValue = (double)e.NewValue; + + // Revert to the old value if invalid (negative) + if (newValue < 0) + { + source.ignorePropertyChange = true; + source.SetValue(e.Property, e.OldValue); + + throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, + "Invalid value '{0}' for MaxDropDownHeight", e.NewValue), "e"); + } + + source.OnMaxDropDownHeightChanged(newValue); + } + + /// + /// Gets or sets a value indicating whether the drop-down portion of + /// the control is open. + /// + /// + /// True if the drop-down is open; otherwise, false. The default is + /// false. + /// + public bool IsDropDownOpen + { + get { return (bool)GetValue(IsDropDownOpenProperty); } + set + { + HandleShortcutSupport(value); + SetValue(IsDropDownOpenProperty, value); + } + } + + void HandleShortcutSupport(bool value) + { + if (TextBox == null) + { + return; + } + + var shortcutContainer = TextBox.Control as IShortcutContainer; + if (shortcutContainer != null) + { + shortcutContainer.SupportsKeyboardShortcuts = !value && supportsShortcutOriginalValue; + } + } + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DependencyProperty IsDropDownOpenProperty = + DependencyProperty.Register( + "IsDropDownOpen", + typeof(bool), + typeof(AutoCompleteBox), + new PropertyMetadata(false, OnIsDropDownOpenPropertyChanged)); + + /// + /// IsDropDownOpenProperty property changed handler. + /// + /// AutoCompleteTextBox that changed its IsDropDownOpen. + /// Event arguments. + private static void OnIsDropDownOpenPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var source = d as AutoCompleteBox; + + if (source == null) return; + + // Ignore the change if requested + if (source.ignorePropertyChange) + { + source.ignorePropertyChange = false; + return; + } + + bool oldValue = (bool)e.OldValue; + bool newValue = (bool)e.NewValue; + + if (!newValue) + { + source.ClosingDropDown(oldValue); + } + + source.UpdateVisualState(true); + } + + /// + /// Gets or sets a collection that is used to generate the items for the + /// drop-down portion of the + /// control. + /// + /// The collection that is used to generate the items of the + /// drop-down portion of the + /// control. + public IEnumerable ItemsSource + { + get { return GetValue(ItemsSourceProperty) as IEnumerable; } + set { SetValue(ItemsSourceProperty, value); } + } + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DependencyProperty ItemsSourceProperty = + DependencyProperty.Register( + "ItemsSource", + typeof(IEnumerable), + typeof(AutoCompleteBox), + new PropertyMetadata(OnItemsSourcePropertyChanged)); + + /// + /// ItemsSourceProperty property changed handler. + /// + /// AutoCompleteBox that changed its ItemsSource. + /// Event arguments. + private static void OnItemsSourcePropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var autoComplete = d as AutoCompleteBox; + if (autoComplete == null) return; + autoComplete.OnItemsSourceChanged((IEnumerable)e.OldValue, (IEnumerable)e.NewValue); + } + + /// + /// Gets or sets the selected item in the drop-down. + /// + /// The selected item in the drop-down. + /// + /// If the IsTextCompletionEnabled property is true and text typed by + /// the user matches an item in the ItemsSource collection, which is + /// then displayed in the text box, the SelectedItem property will be + /// a null reference. + /// + public object SelectedItem + { + get { return GetValue(SelectedItemProperty); } + set { SetValue(SelectedItemProperty, value); } + } + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier the + /// + /// dependency property. + public static readonly DependencyProperty SelectedItemProperty = + DependencyProperty.Register( + "SelectedItem", + typeof(object), + typeof(AutoCompleteBox), + new PropertyMetadata()); + + private void CancelSuggestion() + { + Debug.Assert(TextBox != null, "TextBox is somehow null"); + Debug.Assert(Text != null, "Text is somehow null"); + + DismissDropDown(); + + Debug.Assert(0 == TextBox.SelectionLength, "SelectionLength is what I think it is"); + } + + private void ExpandSuggestion(string value) + { + Debug.Assert(value != null, "The string passed into ExpandSuggestion should not be null"); + Debug.Assert(TextBox != null, "TextBox is somehow null"); + Debug.Assert(Text != null, "Text is somehow null"); + + var newText = TextBox.GetExpandedText(value, CompletionOffset); + UpdateTextValue(newText); + + // New caret index should be one space after the inserted text. + int newCaretIndex = CompletionOffset + value.Length + 1; + TextBox.CaretIndex = newCaretIndex; + Debug.Assert(newCaretIndex == TextBox.SelectionStart, + String.Format(CultureInfo.InvariantCulture, + "SelectionStart '{0}' should be the same as newCaretIndex '{1}'", + TextBox.SelectionStart, newCaretIndex)); + Debug.Assert(0 == TextBox.SelectionLength, + String.Format(CultureInfo.InvariantCulture, + "SelectionLength should be 0 but is '{0}' is what I think it is", + TextBox.SelectionStart)); + } + + /// + /// Gets or sets the text in the text box portion of the + /// control. + /// + /// The text in the text box portion of the + /// control. + public string Text + { + get { return GetValue(TextProperty) as string; } + set { SetValue(TextProperty, value); } + } + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DependencyProperty TextProperty = + DependencyProperty.Register( + "Text", + typeof(string), + typeof(AutoCompleteBox), + new PropertyMetadata(string.Empty, OnTextPropertyChanged)); + + /// + /// TextProperty property changed handler. + /// + /// AutoCompleteBox that changed its Text. + /// Event arguments. + private static void OnTextPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var source = d as AutoCompleteBox; + if (source == null) return; + + source.OnTextPropertyChanged((string) e.NewValue); + } + + /// + /// Gets or sets the drop down popup control. + /// + private PopupHelper DropDownPopup { get; set; } + + /// + /// The TextBox template part. + /// + private IAutoCompleteTextInput textInput; + + /// + /// The SelectionAdapter. + /// + private ISelectionAdapter adapter; + + /// + /// Gets or sets the Text template part. + /// + [SuppressMessage("Microsoft.Design", "CA1062:Validate arguments of public methods", MessageId = "0")] + public IAutoCompleteTextInput TextBox + { + get { return textInput; } + set { UpdateTextBox(value); } + } + + void UpdateTextBox(IAutoCompleteTextInput value) + { + // Detach existing handlers + if (textInput != null) + { + UnsubscribeToEvent("SelectionChanged"); + UnsubscribeToEvent("OnTextBoxTextChanged"); + } + + textInput = value; + + // Attach handlers + if (textInput != null) + { + var shortcutContainer = textInput.Control as IShortcutContainer; + if (shortcutContainer != null) + { + supportsShortcutOriginalValue = shortcutContainer.SupportsKeyboardShortcuts; + } + + SubscribeToEvent("OnTextBoxTextChanged", + ObserveTextBoxChanges().Subscribe(shouldPopulate => + { + if (shouldPopulate) + { + PopulateDropDown(); + } + else + { + DismissDropDown(); + } + })); + + if (Text != null) + { + UpdateTextValue(Text); + } + } + } + + IObservable ObserveTextBoxChanges() + { + var distinctTextChanges = textInput + .TextChanged + .Select(_ => textInput.Text ?? "") + .DistinctUntilChanged(); + + if (MinimumPopulateDelay >= 0) + { + distinctTextChanges = distinctTextChanges + .Throttle(TimeSpan.FromMilliseconds(MinimumPopulateDelay), RxApp.MainThreadScheduler); + } + + return distinctTextChanges + .Select(text => { + bool userChangedTextBox = ignoreTextPropertyChange == 0; + if (ignoreTextPropertyChange > 0) ignoreTextPropertyChange--; + + return new { Text = text, ShouldPopulate = text.Length > 0 && userChangedTextBox }; + }) + .Do(textInfo => + { + userCalledPopulate = textInfo.ShouldPopulate; + UpdateAutoCompleteTextValue(textInfo.Text); + }) + .Select(textInfo => textInfo.ShouldPopulate); + } + + /// + /// Gets or sets the selection adapter used to populate the drop-down + /// with a list of selectable items. + /// + /// The selection adapter used to populate the drop-down with a + /// list of selectable items. + /// + /// You can use this property when you create an automation peer to sw + /// use with AutoCompleteBox or deriving from AutoCompleteBox to + /// create a custom control. + /// + [SuppressMessage("Microsoft.Design", "CA1062:Validate arguments of public methods", MessageId = "0")] + public ISelectionAdapter SelectionAdapter + { + get { return adapter; } + set + { + if (adapter != null) + { + adapter.SelectionChanged -= OnAdapterSelectionChanged; + adapter.Commit -= OnAdapterSelectionComplete; + adapter.Cancel -= OnAdapterSelectionCanceled; + adapter.ItemsSource = null; + } + + adapter = value; + + if (adapter != null) + { + adapter.SelectionChanged += OnAdapterSelectionChanged; + adapter.Commit += OnAdapterSelectionComplete; + adapter.Cancel += OnAdapterSelectionCanceled; + adapter.ItemsSource = view; + } + } + } + + /// + /// Provides suggestions based on what's been typed. + /// + public IAutoCompleteAdvisor Advisor + { + get; + set; + } + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DependencyProperty AdvisorProperty = + DependencyProperty.Register( + "Advisor", + typeof(IAutoCompleteAdvisor), + typeof(AutoCompleteBox), + new PropertyMetadata(null, OnAdvisorPropertyChanged)); + + /// + /// AdvisorProperty property changed handler. + /// + /// AutoCompleteBox that changed its Advisor. + /// Event arguments. + private static void OnAdvisorPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var source = d as AutoCompleteBox; + if (source == null) return; + + source.Advisor = (IAutoCompleteAdvisor)e.NewValue; + } + + /// + /// Builds the visual tree for the + /// control + /// when a new template is applied. + /// + public override void OnApplyTemplate() + { + if (TextBox != null) + { + UnsubscribeToEvent("PreviewKeyDown"); + } + + if (DropDownPopup != null) + { + DropDownPopup.Closed -= OnDropDownClosed; + DropDownPopup.FocusChanged -= OnDropDownFocusChanged; + DropDownPopup.UpdateVisualStates -= OnDropDownPopupUpdateVisualStates; + DropDownPopup.BeforeOnApplyTemplate(); + DropDownPopup = null; + } + + base.OnApplyTemplate(); + + // Set the template parts. Individual part setters remove and add + // any event handlers. + var popup = GetTemplateChild(elementPopup) as Popup; + if (popup != null) + { + DropDownPopup = new PopupHelper(this, popup) + { + MaxDropDownHeight = MaxDropDownHeight + }; + DropDownPopup.AfterOnApplyTemplate(); + DropDownPopup.Closed += OnDropDownClosed; + DropDownPopup.FocusChanged += OnDropDownFocusChanged; + DropDownPopup.UpdateVisualStates += OnDropDownPopupUpdateVisualStates; + } + SelectionAdapter = GetSelectionAdapterPart(); + // TODO: eliminate duplication between these two elements... + TextBox = InputElement; + + if (TextBox != null) + { + SubscribeToEvent("PreviewKeyDown", TextBox.PreviewKeyDown.Subscribe(OnTextBoxPreviewKeyDown)); + } + + Interaction.OnApplyTemplateBase(); + + // If the drop down property indicates that the popup is open, + // flip its value to invoke the changed handler. + if (IsDropDownOpen && DropDownPopup != null && !DropDownPopup.IsOpen) + { + OpeningDropDown(); + } + } + + /// + /// Allows the popup wrapper to fire visual state change events. + /// + /// The source object. + /// The event data. + private void OnDropDownPopupUpdateVisualStates(object sender, EventArgs e) + { + UpdateVisualState(true); + } + + /// + /// Allows the popup wrapper to fire the FocusChanged event. + /// + /// The source object. + /// The event data. + private void OnDropDownFocusChanged(object sender, EventArgs e) + { + FocusChanged(HasFocus()); + } + + /// + /// Begin closing the drop-down. + /// + /// The original value. + private void ClosingDropDown(bool oldValue) + { + bool delayedClosingVisual = false; + if (DropDownPopup != null) + { + delayedClosingVisual = DropDownPopup.UsesClosingVisualState; + } + + if (view == null || view.Count == 0) + { + delayedClosingVisual = false; + } + + // Immediately close the drop down window: + // When a popup closed visual state is present, the code path is + // slightly different and the actual call to CloseDropDown will + // be called only after the visual state's transition is done + RaiseExpandCollapseAutomationEvent(oldValue, false); + if (!delayedClosingVisual) + { + CloseDropDown(); + } + + UpdateVisualState(true); + } + + private void OpeningDropDown() + { + OpenDropDown(); + + UpdateVisualState(true); + } + + /// + /// Raise an expand/collapse event through the automation peer. + /// + /// The old value. + /// The new value. + private void RaiseExpandCollapseAutomationEvent(bool oldValue, bool newValue) + { + var peer = UIElementAutomationPeer.FromElement(this) as AutoCompleteBoxAutomationPeer; + if (peer != null) + { + peer.RaiseExpandCollapseAutomationEvent(oldValue, newValue); + } + } + + /// + /// Handles the PreviewKeyDown event on the TextBox for WPF. + /// + /// The event data. + private void OnTextBoxPreviewKeyDown(EventPattern e) + { + OnKeyDown(e.EventArgs); + } + + /// + /// Connects to the DropDownPopup Closed event. + /// + /// The source object. + /// The event data. + private void OnDropDownClosed(object sender, EventArgs e) + { + // Force the drop down dependency property to be false. + if (IsDropDownOpen) + { + IsDropDownOpen = false; + } + } + + /// + /// Creates an + /// + /// + /// A + /// + /// for the + /// object. + protected override AutomationPeer OnCreateAutomationPeer() + { + return new AutoCompleteBoxAutomationPeer(this); + } + + /// + /// Handles the FocusChanged event. + /// + /// A value indicating whether the control + /// currently has the focus. + private void FocusChanged(bool hasFocus) + { + // The OnGotFocus & OnLostFocus are asynchronously and cannot + // reliably tell you that have the focus. All they do is let you + // know that the focus changed sometime in the past. To determine + // if you currently have the focus you need to do consult the + // FocusManager (see HasFocus()). + + if (!hasFocus) + { + IsDropDownOpen = false; + userCalledPopulate = false; + } + } + + /// + /// Determines whether the text box or drop-down portion of the + /// control has + /// focus. + /// + /// true to indicate the + /// has focus; + /// otherwise, false. + protected bool HasFocus() + { + var focused = + // For WPF, check if the element that has focus is within the control, as + // FocusManager.GetFocusedElement(this) will return null in such a case. + IsKeyboardFocusWithin ? Keyboard.FocusedElement as DependencyObject : FocusManager.GetFocusedElement(this) as DependencyObject; + + while (focused != null) + { + if (ReferenceEquals(focused, this)) + { + return true; + } + + // This helps deal with popups that may not be in the same + // visual tree + var parent = VisualTreeHelper.GetParent(focused); + if (parent == null) + { + // Try the logical parent. + var element = focused as FrameworkElement; + if (element != null) + { + parent = element.Parent; + } + } + focused = parent; + } + return false; + } + + /// + /// Provides handling for the + /// event. + /// + /// A + /// that contains the event data. + protected override void OnGotFocus(RoutedEventArgs e) + { + base.OnGotFocus(e); + FocusChanged(HasFocus()); + } + + /// + /// Handles change of keyboard focus, which is treated differently than control focus + /// + /// + protected override void OnIsKeyboardFocusWithinChanged(DependencyPropertyChangedEventArgs e) + { + base.OnIsKeyboardFocusWithinChanged(e); + FocusChanged((bool)e.NewValue); + } + + /// + /// Provides handling for the + /// event. + /// + /// A + /// that contains the event data. + protected override void OnLostFocus(RoutedEventArgs e) + { + base.OnLostFocus(e); + FocusChanged(HasFocus()); + } + + /// + /// Handle the change of the IsEnabled property. + /// + /// The source object. + /// The event data. + private void ControlIsEnabledChanged(object sender, DependencyPropertyChangedEventArgs e) + { + bool isEnabled = (bool)e.NewValue; + if (!isEnabled) + { + IsDropDownOpen = false; + } + } + + /// + /// Returns the + /// part, if + /// possible. + /// + /// + /// A object, + /// if possible. Otherwise, null. + /// + [SuppressMessage("Microsoft.Design", "CA1024:UsePropertiesWhereAppropriate", + Justification = "Following the GetTemplateChild pattern for the method.")] + protected virtual ISelectionAdapter GetSelectionAdapterPart() + { + var selector = GetTemplateChild(elementSelector) as Selector; + if (selector != null) + { + // Built in support for wrapping a Selector control + adapter = new SelectorSelectionAdapter(selector); + } + return adapter; + } + + /// + /// Populates the drop down + /// + private void PopulateDropDown() + { + populatingSubject.OnNext(Unit.Default); + } + + void DismissDropDown() + { + SelectedItem = null; + if (IsDropDownOpen) + { + IsDropDownOpen = false; + } + } + + /// + /// Converts the specified object to a string by using the + /// and + /// values + /// of the binding object specified by the + /// + /// property. + /// + /// The object to format as a string. + /// The string representation of the specified object. + /// + /// Override this method to provide a custom string conversion. + /// + protected virtual string FormatValue(object value) + { + return value == null ? string.Empty : value.ToString(); + } + + /// + /// Updates both the text box value and underlying text dependency + /// property value if and when they change. Automatically fires the + /// text changed events when there is a change. + /// + /// The new string value. + private void UpdateTextValue(string value) + { + UpdateAutoCompleteTextValue(value); + UpdateTextBoxValue(value); + } + + // Update the TextBox's Text dependency property + void UpdateTextBoxValue(string value) + { + var newValue = value ?? string.Empty; + + if (TextBox == null || TextBox.Text == newValue) + { + return; + } + + ignoreTextPropertyChange++; + TextBox.Text = newValue; + } + + void UpdateAutoCompleteTextValue(string value) + { + if (Text == value) return; + + ignoreTextPropertyChange++; + Text = value; + } + + /// + /// Handle the update of the text when the Text dependency property changes. + /// + /// The new text. + private void OnTextPropertyChanged(string newText) + { + // Only process this event if it is coming from someone outside + // setting the Text dependency property directly. + if (ignoreTextPropertyChange > 0) + { + ignoreTextPropertyChange--; + return; + } + + UpdateTextBoxValue(newText); + } + + /// + /// Notifies the + /// that the + /// + /// property has been set and the data can be filtered to provide + /// possible matches in the drop-down. + /// + /// + /// Call this method when you are providing custom population of + /// the drop-down portion of the AutoCompleteBox, to signal the control + /// that you are done with the population process. + /// Typically, you use PopulateComplete when the population process + /// is a long-running process and you want to cancel built-in filtering + /// of the ItemsSource items. In this case, you can handle the + /// Populated event and set PopulatingEventArgs.Cancel to true. + /// When the long-running process has completed you call + /// PopulateComplete to indicate the drop-down is populated. + /// + protected void PopulateComplete() + { + RefreshView(); + + if (SelectionAdapter != null && !Equals(SelectionAdapter.ItemsSource, view)) + { + SelectionAdapter.ItemsSource = view; + } + + bool isDropDownOpen = userCalledPopulate && (view.Count > 0); + if (isDropDownOpen != IsDropDownOpen) + { + ignorePropertyChange = true; + IsDropDownOpen = isDropDownOpen; + } + if (IsDropDownOpen) + { + OpeningDropDown(); + } + else + { + ClosingDropDown(true); + } + + // We always want to select the first suggestion after populating the drop down. + SelectFirstItem(); + } + + void SelectFirstItem() + { + if (!view.Any()) return; + + var newSelectedItem = view.First(); + SelectionAdapter.SelectedItem = newSelectedItem; + SelectedItem = newSelectedItem; + } + + + /// + /// A simple helper method to clear the view and ensure that a view + /// object is always present and not null. + /// + private void ClearView() + { + if (view == null) + { + view = new ObservableCollection(); + } + else + { + view.Clear(); + } + } + + /// + /// Walks through the items enumeration. Performance is not going to be perfect with the current implementation. + /// + private void RefreshView() + { + if (suggestions == null) + { + ClearView(); + return; + } + + int viewIndex = 0; + int viewCount = view.Count; + var items = suggestions; + foreach (var item in items) + { + if (viewCount > viewIndex && view[viewIndex] == item) + { + // Item is still in the view + viewIndex++; + } + else + { + // Insert the item + if (viewCount > viewIndex && view[viewIndex] != item) + { + // Replace item + // Unfortunately replacing via index throws a fatal + // exception: View[view_index] = item; + // Cost: O(n) vs O(1) + view.RemoveAt(viewIndex); + view.Insert(viewIndex, item); + viewIndex++; + } + else + { + // Add the item + if (viewIndex == viewCount) + { + // Constant time is preferred (Add). + view.Add(item); + } + else + { + view.Insert(viewIndex, item); + } + viewIndex++; + viewCount++; + } + } + } + } + + /// + /// Handle any change to the ItemsSource dependency property, update + /// the underlying ObservableCollection view, and set the selection + /// adapter's ItemsSource to the view if appropriate. + /// + /// The old enumerable reference. + /// The new enumerable reference. + private void OnItemsSourceChanged(IEnumerable oldValue, IEnumerable newValue) + { + // Remove handler for oldValue.CollectionChanged (if present) + var oldValueINotifyCollectionChanged = oldValue as INotifyCollectionChanged; + if (null != oldValueINotifyCollectionChanged && null != collectionChangedWeakEventListener) + { + collectionChangedWeakEventListener.Detach(); + collectionChangedWeakEventListener = null; + } + + // Add handler for newValue.CollectionChanged (if possible) + var newValueINotifyCollectionChanged = newValue as INotifyCollectionChanged; + if (null != newValueINotifyCollectionChanged) + { + collectionChangedWeakEventListener = new WeakEventListener(this) + { + OnEventAction = + (instance, source, eventArgs) => instance.ItemsSourceCollectionChanged(eventArgs), + OnDetachAction = + weakEventListener => + newValueINotifyCollectionChanged.CollectionChanged -= weakEventListener.OnEvent + }; + newValueINotifyCollectionChanged.CollectionChanged += collectionChangedWeakEventListener.OnEvent; + } + + // Store a local cached copy of the data + suggestions = newValue == null ? null : new List(newValue.Cast().ToList()); + + // Clear and set the view on the selection adapter + ClearView(); + if (SelectionAdapter != null && !Equals(SelectionAdapter.ItemsSource, view)) + { + SelectionAdapter.ItemsSource = view; + } + if (IsDropDownOpen) + { + RefreshView(); + } + } + + /// + /// Method that handles the ObservableCollection.CollectionChanged event for the ItemsSource property. + /// + /// The event data. + private void ItemsSourceCollectionChanged(NotifyCollectionChangedEventArgs e) + { + // Update the cache + if (e.Action == NotifyCollectionChangedAction.Remove && e.OldItems != null) + { + for (int index = 0; index < e.OldItems.Count; index++) + { + suggestions.RemoveAt(e.OldStartingIndex); + } + } + if (e.Action == NotifyCollectionChangedAction.Add && e.NewItems != null && suggestions.Count >= e.NewStartingIndex) + { + for (int index = 0; index < e.NewItems.Count; index++) + { + suggestions.Insert(e.NewStartingIndex + index, e.NewItems[index]); + } + } + if (e.Action == NotifyCollectionChangedAction.Replace && e.NewItems != null && e.OldItems != null) + { + foreach (var t in e.NewItems) + { + suggestions[e.NewStartingIndex] = t; + } + } + + // Update the view + if (e.Action == NotifyCollectionChangedAction.Remove || e.Action == NotifyCollectionChangedAction.Replace) + { + if (e.OldItems != null) + { + foreach (var t in e.OldItems) + { + view.Remove(t); + } + } + } + + if (e.Action == NotifyCollectionChangedAction.Reset) + { + // Significant changes to the underlying data. + ClearView(); + if (ItemsSource != null) + { + suggestions = new List(ItemsSource.Cast().ToList()); + } + } + + // Refresh the observable collection used in the selection adapter. + RefreshView(); + } + + /// + /// Handles the SelectionChanged event of the selection adapter. + /// + /// The source object. + /// The selection changed event data. + private void OnAdapterSelectionChanged(object sender, SelectionChangedEventArgs e) + { + SelectedItem = adapter.SelectedItem; + } + + /// + /// Handles the Commit event on the selection adapter. + /// + /// The source object. + /// The event data. + private void OnAdapterSelectionComplete(object sender, RoutedEventArgs e) + { + IsDropDownOpen = false; + + var selectedItem = SelectedItem; + + // Completion will update the selected value + ExpandSuggestion(selectedItem == null ? string.Empty : selectedItem.ToString()); + + // This forces the textbox to get keyboard focus, in the case where + // another part of the control may have temporarily received focus. + if (TextBox != null) + { + // Because LOL WPF focus shit, we need to make sure don't lose the caret index when we give this focus. + int caretIndex = TextBox.CaretIndex; + TextBox.Focus(); + TextBox.CaretIndex = caretIndex; + } + else + { + Focus(); + } + } + + /// + /// Handles the Cancel event on the selection adapter. + /// + /// The source object. + /// The event data. + private void OnAdapterSelectionCanceled(object sender, RoutedEventArgs e) + { + IsDropDownOpen = false; + + CancelSuggestion(); + + // This forces the textbox to get keyboard focus, in the case where + // another part of the control may have temporarily received focus. + if (TextBox != null) + { + TextBox.Focus(); + } + else + { + Focus(); + } + } + + /// + /// Handles MaxDropDownHeightChanged by re-arranging and updating the + /// popup arrangement. + /// + /// The new value. + [SuppressMessage("Microsoft.Usage", "CA1801:ReviewUnusedParameters", MessageId = "newValue", + Justification = "This makes it easy to add validation or other changes in the future.")] + private void OnMaxDropDownHeightChanged(double newValue) + { + if (DropDownPopup != null) + { + DropDownPopup.MaxDropDownHeight = newValue; + } + UpdateVisualState(true); + } + + private void OpenDropDown() + { + if (DropDownPopup != null) + { + DropDownPopup.IsOpen = true; + } + popupHasOpened = true; + } + + private void CloseDropDown() + { + if (popupHasOpened) + { + if (SelectionAdapter != null) + { + SelectionAdapter.SelectedItem = null; + } + if (DropDownPopup != null) + { + DropDownPopup.IsOpen = false; + } + } + } + + /// + /// Provides handling for the + /// event. + /// + /// A + /// that contains the event data. + [SuppressMessage("Microsoft.Design", "CA1062:Validate arguments of public methods", MessageId = "0")] + protected override void OnKeyDown(KeyEventArgs e) + { + if (e == null) + { + throw new ArgumentNullException("e"); + } + + base.OnKeyDown(e); + + if (e.Handled || !IsEnabled) + { + return; + } + + // The drop down is open, pass along the key event arguments to the + // selection adapter. If it isn't handled by the adapter's logic, + // then we handle some simple navigation scenarios for controlling + // the drop down. + if (IsDropDownOpen) + { + if (SelectionAdapter != null) + { + SelectionAdapter.HandleKeyDown(e); + if (e.Handled) + { + return; + } + } + + if (e.Key == Key.Escape) + { + OnAdapterSelectionCanceled(this, new RoutedEventArgs()); + e.Handled = true; + } + } + + // Standard drop down navigation + switch (e.Key) + { +// case Key.F4: +// IsDropDownOpen = !IsDropDownOpen; +// e.Handled = true; +// break; + + case Key.Enter: + if (IsDropDownOpen && SelectedItem != null) + { + OnAdapterSelectionComplete(this, new RoutedEventArgs()); + e.Handled = true; + } + + break; + } + } + + /// + /// Update the visual state of the control. + /// + /// + /// A value indicating whether to automatically generate transitions to + /// the new state, or instantly transition to the new state. + /// + void IUpdateVisualState.UpdateVisualState(bool useTransitions) + { + UpdateVisualState(useTransitions); + } + + /// + /// Update the current visual state of the button. + /// + /// + /// True to use transitions when updating the visual state, false to + /// snap directly to the new visual state. + /// + internal virtual void UpdateVisualState(bool useTransitions) + { + // Popup + VisualStateManager.GoToState(this, IsDropDownOpen ? VisualStates.StatePopupOpened : VisualStates.StatePopupClosed, useTransitions); + + // Handle the Common and Focused states + Interaction.UpdateVisualStateBase(useTransitions); + } + + private class EmptyAutoCompleteAdvisor : IAutoCompleteAdvisor + { + public static readonly IAutoCompleteAdvisor Instance = new EmptyAutoCompleteAdvisor(); + + private EmptyAutoCompleteAdvisor() + { + } + + public IObservable GetAutoCompletionSuggestions(string text, int caretPosition) + { + return Observable.Empty(); + } + } + + private void SubscribeToEvent(string eventName, IDisposable disposable) + { + eventSubscriptions[eventName] = disposable; + } + + private void UnsubscribeToEvent(string eventName) + { + IDisposable disposable; + if (eventSubscriptions.TryGetValue(eventName, out disposable)) + { + disposable.Dispose(); + } + } + + public IAutoCompleteTextInput InputElement + { + get { return (IAutoCompleteTextInput)GetValue(InputElementProperty); } + set { SetValue(InputElementProperty, value); } + } + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DependencyProperty InputElementProperty = + DependencyProperty.Register( + "InputElement", + typeof(IAutoCompleteTextInput), + typeof(AutoCompleteBox)); + } +} diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteBoxAutomationPeer.cs b/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteBoxAutomationPeer.cs new file mode 100644 index 0000000000..5ac3965c47 --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteBoxAutomationPeer.cs @@ -0,0 +1,300 @@ +// (c) Copyright Microsoft Corporation. +// This source is subject to the Microsoft Public License (Ms-PL). +// Please see http://go.microsoft.com/fwlink/?LinkID=131993] for details. +// All other rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Windows; +using System.Windows.Automation; +using System.Windows.Automation.Peers; +using System.Windows.Automation.Provider; + +namespace GitHub.UI +{ + /// + /// Exposes AutoCompleteBox types to UI Automation. + /// + /// Stable + public sealed class AutoCompleteBoxAutomationPeer : FrameworkElementAutomationPeer, IValueProvider, IExpandCollapseProvider, ISelectionProvider + { + /// + /// The name reported as the core class name. + /// + private const string autoCompleteBoxClassNameCore = "AutoCompleteBox"; + + /// + /// Gets the AutoCompleteBox that owns this + /// AutoCompleteBoxAutomationPeer. + /// + private AutoCompleteBox OwnerAutoCompleteBox + { + get { return (AutoCompleteBox)Owner; } + } + + /// + /// Gets a value indicating whether the UI automation provider allows + /// more than one child element to be selected concurrently. + /// + /// + /// This API supports the .NET Framework infrastructure and is not + /// intended to be used directly from your code. + /// + /// True if multiple selection is allowed; otherwise, false. + bool ISelectionProvider.CanSelectMultiple + { + get { return false; } + } + + /// + /// Gets a value indicating whether the UI automation provider + /// requires at least one child element to be selected. + /// + /// + /// This API supports the .NET Framework infrastructure and is not + /// intended to be used directly from your code. + /// + /// True if selection is required; otherwise, false. + bool ISelectionProvider.IsSelectionRequired + { + get { return false; } + } + + /// + /// Initializes a new instance of the AutoCompleteBoxAutomationPeer + /// class. + /// + /// + /// The AutoCompleteBox that is associated with this + /// AutoCompleteBoxAutomationPeer. + /// + public AutoCompleteBoxAutomationPeer(AutoCompleteBox owner) + : base(owner) + { + } + + /// + /// Gets the control type for the AutoCompleteBox that is associated + /// with this AutoCompleteBoxAutomationPeer. This method is called by + /// GetAutomationControlType. + /// + /// ComboBox AutomationControlType. + protected override AutomationControlType GetAutomationControlTypeCore() + { + return AutomationControlType.ComboBox; + } + + /// + /// Gets the name of the AutoCompleteBox that is associated with this + /// AutoCompleteBoxAutomationPeer. This method is called by + /// GetClassName. + /// + /// The name AutoCompleteBox. + protected override string GetClassNameCore() + { + return autoCompleteBoxClassNameCore; + } + + /// + /// Gets the control pattern for the AutoCompleteBox that is associated + /// with this AutoCompleteBoxAutomationPeer. + /// + /// The desired PatternInterface. + /// The desired AutomationPeer or null. + public override object GetPattern(PatternInterface patternInterface) + { + object iface = null; + var owner = OwnerAutoCompleteBox; + + if (patternInterface == PatternInterface.Value) + { + iface = this; + } + else if (patternInterface == PatternInterface.ExpandCollapse) + { + iface = this; + } + else if (owner.SelectionAdapter != null) + { + var peer = owner.SelectionAdapter.CreateAutomationPeer(); + if (peer != null) + { + iface = peer.GetPattern(patternInterface); + } + } + + return iface ?? base.GetPattern(patternInterface); + } + + /// + /// Blocking method that returns after the element has been expanded. + /// + /// + /// This API supports the .NET Framework infrastructure and is not + /// intended to be used directly from your code. + /// + void IExpandCollapseProvider.Expand() + { + if (!IsEnabled()) + { + throw new ElementNotEnabledException(); + } + + OwnerAutoCompleteBox.IsDropDownOpen = true; + } + + /// + /// Blocking method that returns after the element has been collapsed. + /// + /// + /// This API supports the .NET Framework infrastructure and is not + /// intended to be used directly from your code. + /// + void IExpandCollapseProvider.Collapse() + { + if (!IsEnabled()) + { + throw new ElementNotEnabledException(); + } + + OwnerAutoCompleteBox.IsDropDownOpen = false; + } + + /// + /// Gets an element's current Collapsed or Expanded state. + /// + /// + /// This API supports the .NET Framework infrastructure and is not + /// intended to be used directly from your code. + /// + ExpandCollapseState IExpandCollapseProvider.ExpandCollapseState + { + get + { + return OwnerAutoCompleteBox.IsDropDownOpen ? ExpandCollapseState.Expanded : ExpandCollapseState.Collapsed; + } + } + + /// + /// Raises the ExpandCollapse automation event. + /// + /// The old value. + /// The new value. + internal void RaiseExpandCollapseAutomationEvent(bool oldValue, bool newValue) + { + RaisePropertyChangedEvent( + ExpandCollapsePatternIdentifiers.ExpandCollapseStateProperty, + oldValue ? ExpandCollapseState.Expanded : ExpandCollapseState.Collapsed, + newValue ? ExpandCollapseState.Expanded : ExpandCollapseState.Collapsed); + } + + /// + /// Sets the value of a control. + /// + /// The value to set. The provider is responsible + /// for converting the value to the appropriate data type. + void IValueProvider.SetValue(string value) + { + OwnerAutoCompleteBox.Text = value; + } + + /// + /// Gets a value indicating whether the value of a control is + /// read-only. + /// + /// True if the value is read-only; false if it can be modified. + bool IValueProvider.IsReadOnly + { + get + { + return !OwnerAutoCompleteBox.IsEnabled; + } + } + + /// + /// Gets the value of the control. + /// + /// The value of the control. + string IValueProvider.Value + { + get + { + return OwnerAutoCompleteBox.Text ?? string.Empty; + } + } + + /// + /// Gets the collection of child elements of the AutoCompleteBox that + /// are associated with this AutoCompleteBoxAutomationPeer. This method + /// is called by GetChildren. + /// + /// + /// A collection of automation peer elements, or an empty collection + /// if there are no child elements. + /// + [SuppressMessage("Microsoft.Design", "CA1002:DoNotExposeGenericLists", Justification = "Required by automation")] + protected override List GetChildrenCore() + { + var children = new List(); + var owner = OwnerAutoCompleteBox; + + // TextBox part. + var textBox = owner.TextBox; + if (textBox != null) + { + var peer = CreatePeerForElement(textBox.Control); + if (peer != null) + { + children.Insert(0, peer); + } + } + + // Include SelectionAdapter's children. + if (owner.SelectionAdapter != null) + { + var selectionAdapterPeer = owner.SelectionAdapter.CreateAutomationPeer(); + if (selectionAdapterPeer != null) + { + var listChildren = selectionAdapterPeer.GetChildren(); + if (listChildren != null) + { + children.AddRange(listChildren); + } + } + } + + return children; + } + + /// + /// Retrieves a UI automation provider for each child element that is + /// selected. + /// + /// An array of UI automation providers. + /// + /// This API supports the .NET Framework infrastructure and is not + /// intended to be used directly from your code. + /// + IRawElementProviderSimple[] ISelectionProvider.GetSelection() + { + if (OwnerAutoCompleteBox.SelectionAdapter != null) + { + var selectedItem = OwnerAutoCompleteBox.SelectionAdapter.SelectedItem; + if (selectedItem != null) + { + var uie = selectedItem as UIElement; + if (uie != null) + { + var peer = CreatePeerForElement(uie); + if (peer != null) + { + return new[] { ProviderFromPeer(peer) }; + } + } + } + } + + return new IRawElementProviderSimple[] { }; + } + } +} \ No newline at end of file diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteTextInputExtensions.cs b/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteTextInputExtensions.cs new file mode 100644 index 0000000000..abad932547 --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteTextInputExtensions.cs @@ -0,0 +1,37 @@ +using System; +using System.Diagnostics; +using GitHub.Extensions; +using GitHub.Helpers; + +namespace GitHub.UI.Controls.AutoCompleteBox +{ + public static class AutoCompleteTextInputExtensions + { + /// + /// Given a text input and the current value, returns the expected new text. + /// + /// + /// + /// + /// + public static string GetExpandedText(this IAutoCompleteTextInput textInput, string value, int completionOffset) + { + Guard.ArgumentNotNull(textInput, "textInput"); + Guard.ArgumentNotNull(value, "value"); + + int caretIndex = textInput.CaretIndex; + int afterIndex = Math.Max(caretIndex, textInput.SelectionLength + textInput.SelectionStart); + int offset = completionOffset; + + var currentText = textInput.Text ?? ""; // Playing it safe + + if (offset > currentText.Length) throw new InvalidOperationException("The offset can't be larger than the current text length"); + if (afterIndex > currentText.Length) throw new InvalidOperationException("The afterIndex can't be larger than the current text length"); + + var before = currentText.Substring(0, offset); + var after = currentText.Substring(afterIndex); + string prefix = before + value + " "; + return prefix + after; + } + } +} diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/IAutoCompleteTextInput.cs b/src/GitHub.UI/Controls/AutoCompleteBox/IAutoCompleteTextInput.cs new file mode 100644 index 0000000000..95446edf45 --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/IAutoCompleteTextInput.cs @@ -0,0 +1,29 @@ +using System; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Reactive; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; + +namespace GitHub.UI +{ + public interface IAutoCompleteTextInput : INotifyPropertyChanged + { + void Focus(); + [SuppressMessage("Microsoft.Naming", "CA1716:IdentifiersShouldNotMatchKeywords", MessageId = "Select", + Justification = "Matches the underlying control method name")] + void Select(int position, int length); + void SelectAll(); + int CaretIndex { get; set; } + int SelectionStart { get; } + int SelectionLength { get; } + string Text { get; set; } + IObservable> PreviewKeyDown { get; } + IObservable> SelectionChanged { get; } + IObservable> TextChanged { get; } + UIElement Control { get; } + Point GetPositionFromCharIndex(int charIndex); + Thickness Margin { get; set; } + } +} \ No newline at end of file diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/IPopupTarget.cs b/src/GitHub.UI/Controls/AutoCompleteBox/IPopupTarget.cs new file mode 100644 index 0000000000..250fa9bca9 --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/IPopupTarget.cs @@ -0,0 +1,18 @@ + +using System.Windows; + +namespace GitHub.UI.Controls +{ + /// + /// Controls that implement this interface can specify where an associated popup should be located. + /// + /// + /// The PopupHelper is a generic class for managing Popups that align to the bottom of their associated control. + /// However, our AutoCompleteBox needs the Popup to align to where the completion is happening. Intellisense™ + /// controls behave in a similar fashion. We might find popups useful elsewhere. + /// + public interface IPopupTarget + { + Point PopupPosition { get; } + } +} diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/ISelectionAdapter.cs b/src/GitHub.UI/Controls/AutoCompleteBox/ISelectionAdapter.cs new file mode 100644 index 0000000000..191d09675d --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/ISelectionAdapter.cs @@ -0,0 +1,73 @@ +// (c) Copyright Microsoft Corporation. +// This source is subject to the Microsoft Public License (Ms-PL). +// Please see http://go.microsoft.com/fwlink/?LinkID=131993] for details. +// All other rights reserved. + +using System.Collections; +using System.Windows; +using System.Windows.Automation.Peers; +using System.Windows.Controls; +using System.Windows.Input; + +namespace GitHub.UI +{ + /// + /// Defines an item collection, selection members, and key handling for the + /// selection adapter contained in the drop-down portion of an + /// control. + /// + /// Stable + public interface ISelectionAdapter + { + /// + /// Gets or sets the selected item. + /// + /// The currently selected item. + object SelectedItem { get; set; } + + /// + /// Occurs when the + /// + /// property value changes. + /// + event SelectionChangedEventHandler SelectionChanged; + + /// + /// Gets or sets a collection that is used to generate content for the + /// selection adapter. + /// + /// The collection that is used to generate content for the + /// selection adapter. + IEnumerable ItemsSource { get; set; } + + /// + /// Occurs when a selected item is not cancelled and is committed as the + /// selected item. + /// + event RoutedEventHandler Commit; + + /// + /// Occurs when a selection has been canceled. + /// + event RoutedEventHandler Cancel; + + /// + /// Provides handling for the + /// event that occurs + /// when a key is pressed while the drop-down portion of the + /// has focus. + /// + /// A + /// that contains data about the + /// event. + void HandleKeyDown(KeyEventArgs e); + + /// + /// Returns an automation peer for the selection adapter, for use by the + /// Silverlight automation infrastructure. + /// + /// An automation peer for the selection adapter, if one is + /// available; otherwise, null. + AutomationPeer CreateAutomationPeer(); + } +} \ No newline at end of file diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/IUpdateVisualState.cs b/src/GitHub.UI/Controls/AutoCompleteBox/IUpdateVisualState.cs new file mode 100644 index 0000000000..abbecd5f85 --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/IUpdateVisualState.cs @@ -0,0 +1,23 @@ +// (c) Copyright Microsoft Corporation. +// This source is subject to the Microsoft Public License (Ms-PL). +// Please see http://go.microsoft.com/fwlink/?LinkID=131993] for details. +// All other rights reserved. + +namespace GitHub.UI +{ + /// + /// The IUpdateVisualState interface is used to provide the + /// InteractionHelper with access to the type's UpdateVisualState method. + /// + internal interface IUpdateVisualState + { + /// + /// Update the visual state of the control. + /// + /// + /// A value indicating whether to automatically generate transitions to + /// the new state, or instantly transition to the new state. + /// + void UpdateVisualState(bool useTransitions); + } +} \ No newline at end of file diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/InteractionHelper.cs b/src/GitHub.UI/Controls/AutoCompleteBox/InteractionHelper.cs new file mode 100644 index 0000000000..33dbe73cb8 --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/InteractionHelper.cs @@ -0,0 +1,158 @@ +// (c) Copyright Microsoft Corporation. +// This source is subject to the Microsoft Public License (Ms-PL). +// Please see http://go.microsoft.com/fwlink/?LinkID=131993] for details. +// All other rights reserved. + +using System.Diagnostics; +using System.Windows; +using System.Windows.Controls; + +namespace GitHub.UI +{ + /// + /// The InteractionHelper provides controls with support for all of the + /// common interactions like mouse movement, mouse clicks, key presses, + /// etc., and also incorporates proper event semantics when the control is + /// disabled. + /// + internal sealed class InteractionHelper + { + /// + /// Gets the control the InteractionHelper is targeting. + /// + public Control Control { get; private set; } + + /// + /// Gets a value indicating whether the control has focus. + /// + public bool IsFocused { get; private set; } + + /// + /// Gets a value indicating whether the mouse is over the control. + /// + public bool IsMouseOver { get; private set; } + + /// + /// Gets a value indicating whether the mouse button is pressed down + /// over the control. + /// + public bool IsPressed { get; private set; } + + /// + /// Reference used to call UpdateVisualState on the base class. + /// + private readonly IUpdateVisualState updateVisualState; + + /// + /// Initializes a new instance of the InteractionHelper class. + /// + /// Control receiving interaction. + public InteractionHelper(Control control) + { + Debug.Assert(control != null, "control should not be null!"); + Control = control; + updateVisualState = control as IUpdateVisualState; + + // Wire up the event handlers for events without a virtual override + control.Loaded += OnLoaded; + control.IsEnabledChanged += OnIsEnabledChanged; + } + + /// + /// Update the visual state of the control. + /// + /// + /// A value indicating whether to automatically generate transitions to + /// the new state, or instantly transition to the new state. + /// + /// + /// UpdateVisualState works differently than the rest of the injected + /// functionality. Most of the other events are overridden by the + /// calling class which calls Allow, does what it wants, and then calls + /// Base. UpdateVisualState is the opposite because a number of the + /// methods in InteractionHelper need to trigger it in the calling + /// class. We do this using the IUpdateVisualState internal interface. + /// + private void UpdateVisualState(bool useTransitions) + { + if (updateVisualState != null) + { + updateVisualState.UpdateVisualState(useTransitions); + } + } + + /// + /// Update the visual state of the control. + /// + /// + /// A value indicating whether to automatically generate transitions to + /// the new state, or instantly transition to the new state. + /// + public void UpdateVisualStateBase(bool useTransitions) + { + // Handle the Common states + if (!Control.IsEnabled) + { + VisualStates.GoToState(Control, useTransitions, VisualStates.StateDisabled, VisualStates.StateNormal); + } + else if (IsPressed) + { + VisualStates.GoToState(Control, useTransitions, VisualStates.StatePressed, VisualStates.StateMouseOver, VisualStates.StateNormal); + } + else if (IsMouseOver) + { + VisualStates.GoToState(Control, useTransitions, VisualStates.StateMouseOver, VisualStates.StateNormal); + } + else + { + VisualStates.GoToState(Control, useTransitions, VisualStates.StateNormal); + } + + // Handle the Focused states + if (IsFocused) + { + VisualStates.GoToState(Control, useTransitions, VisualStates.StateFocused, VisualStates.StateUnfocused); + } + else + { + VisualStates.GoToState(Control, useTransitions, VisualStates.StateUnfocused); + } + } + + /// + /// Handle the control's Loaded event. + /// + /// The control. + /// Event arguments. + private void OnLoaded(object sender, RoutedEventArgs e) + { + UpdateVisualState(false); + } + + /// + /// Handle changes to the control's IsEnabled property. + /// + /// The control. + /// Event arguments. + private void OnIsEnabledChanged(object sender, DependencyPropertyChangedEventArgs e) + { + bool enabled = (bool)e.NewValue; + if (!enabled) + { + IsPressed = false; + IsMouseOver = false; + IsFocused = false; + } + + UpdateVisualState(true); + } + + /// + /// Update the visual state of the control when its template is changed. + /// + public void OnApplyTemplateBase() + { + UpdateVisualState(false); + } + } +} \ No newline at end of file diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/PopupHelper.cs b/src/GitHub.UI/Controls/AutoCompleteBox/PopupHelper.cs new file mode 100644 index 0000000000..7ba010c413 --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/PopupHelper.cs @@ -0,0 +1,280 @@ +// (c) Copyright Microsoft Corporation. +// This source is subject to the Microsoft Public License (Ms-PL). +// Please see http://go.microsoft.com/fwlink/?LinkID=131993] for details. +// All other rights reserved. + +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using GitHub.Extensions; +using GitHub.Helpers; +using GitHub.UI.Controls; + +namespace GitHub.UI +{ + /// + /// PopupHelper is a simple wrapper type that helps abstract platform + /// differences out of the Popup. + /// + internal class PopupHelper + { + /// + /// Gets a value indicating whether a visual popup state is being used + /// in the current template for the Closed state. Setting this value to + /// true will delay the actual setting of Popup.IsOpen to false until + /// after the visual state's transition for Closed is complete. + /// + public bool UsesClosingVisualState { get; private set; } + + /// + /// Gets or sets the parent control. + /// + private Control Parent { get; set; } + + /// + /// Gets or sets the maximum drop down height value. + /// + public double MaxDropDownHeight { get; set; } + + /// + /// Gets the Popup control instance. + /// + public Popup Popup { get; private set; } + + /// + /// Gets or sets a value indicating whether the actual Popup is open. + /// + public bool IsOpen + { + get { return Popup.IsOpen; } + set { Popup.IsOpen = value; } + } + + /// + /// Gets or sets the popup child framework element. Can be used if an + /// assumption is made on the child type. + /// + private FrameworkElement PopupChild { get; set; } + + /// + /// The Closed event is fired after the Popup closes. + /// + public event EventHandler Closed; + + /// + /// Fired when the popup children have a focus event change, allows the + /// parent control to update visual states or react to the focus state. + /// + public event EventHandler FocusChanged; + + /// + /// Fired when the popup children intercept an event that may indicate + /// the need for a visual state update by the parent control. + /// + public event EventHandler UpdateVisualStates; + + /// + /// Initializes a new instance of the PopupHelper class. + /// + /// The parent control. + public PopupHelper(Control parent) + { + Guard.ArgumentNotNull(parent, "parent"); + Parent = parent; + } + + /// + /// Initializes a new instance of the PopupHelper class. + /// + /// The parent control. + /// The Popup template part. + public PopupHelper(Control parent, Popup popup) + : this(parent) + { + Guard.ArgumentNotNull(parent, "parent"); + Guard.ArgumentNotNull(popup, "popup"); + + Popup = popup; + + var target = parent as IPopupTarget; + if (target != null) + { + popup.CustomPopupPlacementCallback += (size, targetSize, offset) => new[] + { + new CustomPopupPlacement(target.PopupPosition, PopupPrimaryAxis.Horizontal) + }; + } + } + + /// + /// Fires the Closed event. + /// + /// The event data. + private void OnClosed(EventArgs e) + { + var handler = Closed; + if (handler != null) + { + handler(this, e); + } + } + + /// + /// Actually closes the popup after the VSM state animation completes. + /// + /// Event source. + /// Event arguments. + private void OnPopupClosedStateChanged(object sender, VisualStateChangedEventArgs e) + { + // Delayed closing of the popup until now + if (e != null && e.NewState != null && e.NewState.Name == VisualStates.StatePopupClosed) + { + if (Popup != null) + { + Popup.IsOpen = false; + } + OnClosed(EventArgs.Empty); + } + } + + /// + /// Should be called by the parent control before the base + /// OnApplyTemplate method is called. + /// + public void BeforeOnApplyTemplate() + { + if (UsesClosingVisualState) + { + // Unhook the event handler for the popup closed visual state group. + // This code is used to enable visual state transitions before + // actually setting the underlying Popup.IsOpen property to false. + VisualStateGroup groupPopupClosed = VisualStates.TryGetVisualStateGroup(Parent, VisualStates.GroupPopup); + if (null != groupPopupClosed) + { + groupPopupClosed.CurrentStateChanged -= OnPopupClosedStateChanged; + UsesClosingVisualState = false; + } + } + + if (Popup != null) + { + Popup.Closed -= Popup_Closed; + } + } + + /// + /// Should be called by the parent control after the base + /// OnApplyTemplate method is called. + /// + public void AfterOnApplyTemplate() + { + if (Popup != null) + { + Popup.Closed += Popup_Closed; + } + + var groupPopupClosed = VisualStates.TryGetVisualStateGroup(Parent, VisualStates.GroupPopup); + if (null != groupPopupClosed) + { + groupPopupClosed.CurrentStateChanged += OnPopupClosedStateChanged; + UsesClosingVisualState = true; + } + + // TODO: Consider moving to the DropDownPopup setter + // TODO: Although in line with other implementations, what happens + // when the template is swapped out? + if (Popup != null) + { + PopupChild = Popup.Child as FrameworkElement; + + if (PopupChild != null) + { + PopupChild.MinWidth = 203; // TODO: Make this configurable. + PopupChild.GotFocus += PopupChild_GotFocus; + PopupChild.LostFocus += PopupChild_LostFocus; + PopupChild.MouseEnter += PopupChild_MouseEnter; + PopupChild.MouseLeave += PopupChild_MouseLeave; + } + } + } + + /// + /// Connected to the Popup Closed event and fires the Closed event. + /// + /// The source object. + /// The event data. + private void Popup_Closed(object sender, EventArgs e) + { + OnClosed(EventArgs.Empty); + } + + /// + /// Connected to several events that indicate that the FocusChanged + /// event should bubble up to the parent control. + /// + /// The event data. + private void OnFocusChanged(EventArgs e) + { + EventHandler handler = FocusChanged; + if (handler != null) + { + handler(this, e); + } + } + + /// + /// Fires the UpdateVisualStates event. + /// + /// The event data. + private void OnUpdateVisualStates(EventArgs e) + { + EventHandler handler = UpdateVisualStates; + if (handler != null) + { + handler(this, e); + } + } + + /// + /// The popup child has received focus. + /// + /// The source object. + /// The event data. + private void PopupChild_GotFocus(object sender, RoutedEventArgs e) + { + OnFocusChanged(EventArgs.Empty); + } + + /// + /// The popup child has lost focus. + /// + /// The source object. + /// The event data. + private void PopupChild_LostFocus(object sender, RoutedEventArgs e) + { + OnFocusChanged(EventArgs.Empty); + } + + /// + /// The popup child has had the mouse enter its bounds. + /// + /// The source object. + /// The event data. + private void PopupChild_MouseEnter(object sender, MouseEventArgs e) + { + OnUpdateVisualStates(EventArgs.Empty); + } + + /// + /// The mouse has left the popup child's bounds. + /// + /// The source object. + /// The event data. + private void PopupChild_MouseLeave(object sender, MouseEventArgs e) + { + OnUpdateVisualStates(EventArgs.Empty); + } + } +} diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/RichTextBoxAutoCompleteTextInput.cs b/src/GitHub.UI/Controls/AutoCompleteBox/RichTextBoxAutoCompleteTextInput.cs new file mode 100644 index 0000000000..67def11de8 --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/RichTextBoxAutoCompleteTextInput.cs @@ -0,0 +1,201 @@ +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.IO; +using System.Reactive; +using System.Reactive.Linq; +using System.Text; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Documents; +using System.Windows.Input; +using System.Windows.Markup; + +namespace GitHub.UI +{ + [ContentProperty("TextBox")] + public class RichTextBoxAutoCompleteTextInput : IAutoCompleteTextInput + { + private static readonly int newLineLength = Environment.NewLine.Length; + const int promptRichTextBoxCaretIndexAdjustments = 2; + RichTextBox textBox; + + public event PropertyChangedEventHandler PropertyChanged; + + TextPointer ContentStart + { + get { return textBox.Document.ContentStart; } + } + + TextPointer ContentEnd + { + get + { + // RichTextBox always appends a new line at the end. So we need to back that shit up. + return textBox.Document.ContentEnd.GetPositionAtOffset(-1 * newLineLength) + ?? textBox.Document.ContentEnd; + } + } + + public void Select(int position, int length) + { + var textRange = new TextRange(ContentStart, ContentEnd); + + if (textRange.Text.Length >= (position + length)) + { + var start = textRange.Start.GetPositionAtOffset(GetOffsetIndex(position), LogicalDirection.Forward); + var end = textRange.Start.GetPositionAtOffset(GetOffsetIndex(position + length), LogicalDirection.Backward); + if (start != null && end != null) + textBox.Selection.Select(start, end); + } + } + + public void SelectAll() + { + textBox.Selection.Select(ContentStart, ContentEnd); + } + + public int CaretIndex + { + get + { + var start = ContentStart; + var caret = textBox.CaretPosition; + var range = new TextRange(start, caret); + return range.Text.Length; + } + set + { + Select(value, 0); + Debug.Assert(value == CaretIndex, + String.Format(CultureInfo.InvariantCulture, + "I just set the caret index to '{0}' but it's '{1}'", value, CaretIndex)); + } + } + + public int SelectionStart + { + get + { + return new TextRange(ContentStart, textBox.Selection.Start).Text.Length; + } + } + + public int SelectionLength + { + get { return CaretIndex - SelectionStart; } + } + +#if DEBUG + [SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope")] +#endif + public string Text + { + get + { + return new TextRange(ContentStart, ContentEnd).Text; + } + set + { + textBox.Document.Blocks.Clear(); + + if (!string.IsNullOrEmpty(value)) + { + using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(value))) + { + var contents = new TextRange(ContentStart, ContentEnd); + contents.Load(stream, DataFormats.Text); + } + } + } + } + + public IObservable> PreviewKeyDown + { + get; + private set; + } + + public IObservable> SelectionChanged { get; private set; } + + public IObservable> TextChanged { get; private set; } + + public UIElement Control { get { return textBox; } } + + public Point GetPositionFromCharIndex(int charIndex) + { + var offset = new TextRange(ContentStart, textBox.CaretPosition) + .Start + .GetPositionAtOffset(charIndex, LogicalDirection.Forward); + + return offset != null + ? offset.GetCharacterRect(LogicalDirection.Forward).BottomLeft + : new Point(0, 0); + } + + public Thickness Margin + { + get { return textBox.Margin; } + set { textBox.Margin = value; } + } + + public void Focus() + { + Keyboard.Focus(textBox); + } + + public RichTextBox TextBox + { + get + { + return textBox; + } + set + { + if (value != textBox) + { + textBox = value; + + PreviewKeyDown = Observable.FromEventPattern( + h => textBox.PreviewKeyDown += h, + h => textBox.PreviewKeyDown -= h); + + SelectionChanged = Observable.FromEventPattern( + h => textBox.SelectionChanged += h, + h => textBox.SelectionChanged -= h); + + TextChanged = Observable.FromEventPattern( + h => textBox.TextChanged += h, + h => textBox.TextChanged -= h); + + NotifyPropertyChanged("Control"); + } + + } + } + + // This is a fudge factor needed because of PromptRichTextBox. When commit messages are 51 characters or more, + // The PromptRichTextBox applies a styling that fucks up the CaretPosition by 2. :( + // This method helps us account for that. + int GetOffsetIndex(int selectionEnd) + { + if (textBox is PromptRichTextBox && selectionEnd >= PromptRichTextBox.BadCommitMessageLength) + { + return selectionEnd + promptRichTextBoxCaretIndexAdjustments; + } + return selectionEnd; + } + + private void NotifyPropertyChanged(String info) + { + var propertyChanged = PropertyChanged; + if (propertyChanged != null) + { + propertyChanged(this, new PropertyChangedEventArgs(info)); + } + } + } +} diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/SelectorSelectionAdapter.cs b/src/GitHub.UI/Controls/AutoCompleteBox/SelectorSelectionAdapter.cs new file mode 100644 index 0000000000..e3d2aab6b0 --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/SelectorSelectionAdapter.cs @@ -0,0 +1,354 @@ +// (c) Copyright Microsoft Corporation. +// This source is subject to the Microsoft Public License (Ms-PL). +// Please see http://go.microsoft.com/fwlink/?LinkID=131993] for details. +// All other rights reserved. + +using System.Collections; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Windows; +using System.Windows.Automation.Peers; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; + +namespace GitHub.UI +{ + /// + /// Represents the selection adapter contained in the drop-down portion of + /// an control. + /// + /// Stable + public class SelectorSelectionAdapter : ISelectionAdapter + { + /// + /// The Selector instance. + /// + private Selector selector; + + /// + /// Gets or sets a value indicating whether the selection change event + /// should not be fired. + /// + private bool IgnoringSelectionChanged { get; set; } + + /// + /// Gets or sets the underlying control. + /// + /// The underlying control. + [SuppressMessage("Microsoft.Design", "CA1062:Validate arguments of public methods", MessageId = "0", + Justification = "We do validate the parameter. Code Analysis just doesn't see it.")] + public Selector SelectorControl + { + get { return selector; } + + set + { + if (selector != null) + { + selector.SelectionChanged -= OnSelectionChanged; + selector.MouseLeftButtonUp -= OnSelectorMouseLeftButtonUp; + } + + selector = value; + + if (selector != null) + { + selector.SelectionChanged += OnSelectionChanged; + selector.MouseLeftButtonUp += OnSelectorMouseLeftButtonUp; + } + } + } + + /// + /// Occurs when the property value changes. + /// + public event SelectionChangedEventHandler SelectionChanged; + + /// + /// Occurs when an item is selected and is committed to the underlying + /// control. + /// + public event RoutedEventHandler Commit; + + /// + /// Occurs when a selection is canceled before it is committed. + /// + public event RoutedEventHandler Cancel; + + /// + /// Initializes a new instance of the class. + /// + public SelectorSelectionAdapter() + { + } + + /// + /// Initializes a new instance of the class with the specified + /// + /// control. + /// + /// The + /// control + /// to wrap as a + /// . + public SelectorSelectionAdapter(Selector selector) + { + SelectorControl = selector; + } + + /// + /// Gets or sets the selected item of the selection adapter. + /// + /// The selected item of the underlying selection adapter. + public object SelectedItem + { + get + { + return SelectorControl == null ? null : SelectorControl.SelectedItem; + } + + set + { + IgnoringSelectionChanged = true; + if (SelectorControl != null) + { + SelectorControl.SelectedItem = value; + } + + // Attempt to reset the scroll viewer's position + if (value == null) + { + ResetScrollViewer(); + } + + IgnoringSelectionChanged = false; + } + } + + /// + /// Gets or sets a collection that is used to generate the content of + /// the selection adapter. + /// + /// The collection used to generate content for the selection + /// adapter. + public IEnumerable ItemsSource + { + get + { + return SelectorControl == null ? null : SelectorControl.ItemsSource; + } + set + { + if (SelectorControl != null) + { + SelectorControl.ItemsSource = value; + } + } + } + + /// + /// If the control contains a ScrollViewer, this will reset the viewer + /// to be scrolled to the top. + /// + private void ResetScrollViewer() + { + if (SelectorControl != null) + { + var sv = SelectorControl.GetLogicalChildrenBreadthFirst().OfType().FirstOrDefault(); + if (sv != null) + { + sv.ScrollToTop(); + } + } + } + + /// + /// Handles the mouse left button up event on the selector control. + /// + /// The source object. + /// The event data. + private void OnSelectorMouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + OnCommit(); + } + + /// + /// Handles the SelectionChanged event on the Selector control. + /// + /// The source object. + /// The selection changed event data. + private void OnSelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (IgnoringSelectionChanged) + { + return; + } + + var handler = SelectionChanged; + if (handler != null) + { + handler(sender, e); + } + } + + /// + /// Increments the + /// + /// property of the underlying + /// + /// control. + /// + protected void SelectedIndexIncrement() + { + if (SelectorControl != null) + { + SelectorControl.SelectedIndex = + SelectorControl.SelectedIndex + 1 >= SelectorControl.Items.Count + ? SelectorControl.Items.Count - 1 + : SelectorControl.SelectedIndex + 1; + } + } + + /// + /// Decrements the + /// + /// property of the underlying + /// + /// control. + /// + protected void SelectedIndexDecrement() + { + if (SelectorControl != null) + { + int index = SelectorControl.SelectedIndex; + if (index >= 1) + { + SelectorControl.SelectedIndex--; + } + else + { + SelectorControl.SelectedIndex = 0; + } + } + } + + /// + /// Provides handling for the + /// event that occurs + /// when a key is pressed while the drop-down portion of the + /// has focus. + /// + /// A + /// that contains data about the + /// event. + public void HandleKeyDown(KeyEventArgs e) + { + switch (e.Key) + { + case Key.Enter: + case Key.Tab: + case Key.Right: + OnCommit(); + e.Handled = true; + break; + + case Key.Up: + SelectedIndexDecrement(); + e.Handled = true; + break; + + case Key.Down: + if ((ModifierKeys.Alt & Keyboard.Modifiers) == ModifierKeys.None) + { + SelectedIndexIncrement(); + e.Handled = true; + } + break; + + case Key.Escape: + OnCancel(); + e.Handled = true; + break; + } + } + + /// + /// Raises the + /// + /// event. + /// + protected virtual void OnCommit() + { + OnCommit(this, new RoutedEventArgs()); + } + + /// + /// Fires the Commit event. + /// + /// The source object. + /// The event data. + private void OnCommit(object sender, RoutedEventArgs e) + { + RoutedEventHandler handler = Commit; + if (handler != null) + { + handler(sender, e); + } + + AfterAdapterAction(); + } + + /// + /// Raises the + /// + /// event. + /// + protected virtual void OnCancel() + { + OnCancel(this, new RoutedEventArgs()); + } + + /// + /// Fires the Cancel event. + /// + /// The source object. + /// The event data. + private void OnCancel(object sender, RoutedEventArgs e) + { + var handler = Cancel; + if (handler != null) + { + handler(sender, e); + } + + AfterAdapterAction(); + } + + /// + /// Change the selection after the actions are complete. + /// + private void AfterAdapterAction() + { + IgnoringSelectionChanged = true; + if (SelectorControl != null) + { + SelectorControl.SelectedItem = null; + SelectorControl.SelectedIndex = -1; + } + IgnoringSelectionChanged = false; + } + + /// + /// Returns an automation peer for the underlying + /// + /// control, for use by the Silverlight automation infrastructure. + /// + /// An automation peer for use by the Silverlight automation + /// infrastructure. + public AutomationPeer CreateAutomationPeer() + { + return selector != null ? UIElementAutomationPeer.CreatePeerForElement(selector) : null; + } + } +} diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/TextBoxAutoCompleteTextInput.cs b/src/GitHub.UI/Controls/AutoCompleteBox/TextBoxAutoCompleteTextInput.cs new file mode 100644 index 0000000000..3b46c0a877 --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/TextBoxAutoCompleteTextInput.cs @@ -0,0 +1,121 @@ +using System; +using System.ComponentModel; +using System.Reactive; +using System.Reactive.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Markup; +using ReactiveUI.Wpf; +using ReactiveUI; + +namespace GitHub.UI +{ + [ContentProperty("TextBox")] + public class TextBoxAutoCompleteTextInput : IAutoCompleteTextInput + { + TextBox textBox; + + public event PropertyChangedEventHandler PropertyChanged; + + public void Select(int position, int length) + { + textBox.Select(position, length); + } + + public void SelectAll() + { + textBox.SelectAll(); + } + + public int CaretIndex + { + get { return textBox.CaretIndex; } + set { textBox.CaretIndex = value; } + } + + public int SelectionStart + { + get { return textBox.SelectionStart; } + set { textBox.SelectionStart = value; } + } + + public int SelectionLength + { + get { return textBox.SelectionLength; } + } + + public string Text + { + get { return textBox.Text; } + set { textBox.Text = value; } + } + + public IObservable> PreviewKeyDown + { + get; + private set; + } + + public IObservable> SelectionChanged { get; private set; } + public IObservable> TextChanged { get; private set; } + public UIElement Control { get { return textBox; } } + + public Point GetPositionFromCharIndex(int charIndex) + { + var position = textBox.GetRectFromCharacterIndex(charIndex).BottomLeft; + position.Offset(0, 10); // Vertically pad it. Yeah, Point is mutable. WTF? + return position; + } + + public void Focus() + { + Keyboard.Focus(textBox); + } + + public TextBox TextBox + { + get + { + return textBox; + } + set + { + if (value != textBox) + { + textBox = value; + + PreviewKeyDown = Observable.FromEventPattern( + h => textBox.PreviewKeyDown += h, + h => textBox.PreviewKeyDown -= h); + + SelectionChanged = Observable.FromEventPattern( + h => textBox.SelectionChanged += h, + h => textBox.SelectionChanged -= h); + + TextChanged = Observable.FromEventPattern( + h => textBox.TextChanged += h, + h => textBox.TextChanged -= h); + + NotifyPropertyChanged("Control"); + } + } + } + + public Thickness Margin + { + get { return textBox.Margin; } + set { textBox.Margin = value; } + } + + private void NotifyPropertyChanged(String info) + { + var propertyChanged = PropertyChanged; + if (propertyChanged != null) + { + propertyChanged(this, new PropertyChangedEventArgs(info)); + } + } + } +} \ No newline at end of file diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/VisualStates.cs b/src/GitHub.UI/Controls/AutoCompleteBox/VisualStates.cs new file mode 100644 index 0000000000..6aa065fdaf --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/VisualStates.cs @@ -0,0 +1,409 @@ +// (c) Copyright Microsoft Corporation. +// This source is subject to the Microsoft Public License (Ms-PL). +// Please see http://go.microsoft.com/fwlink/?LinkID=131993] for details. +// All other rights reserved. + +using System.Diagnostics; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; + +namespace GitHub.UI +{ + /// + /// Names and helpers for visual states in the controls. + /// + internal static class VisualStates + { + #region GroupCommon + /// + /// Common state group. + /// + public const string GroupCommon = "CommonStates"; + + /// + /// Normal state of the Common state group. + /// + public const string StateNormal = "Normal"; + + /// + /// Normal state of the Common state group. + /// + public const string StateReadOnly = "ReadOnly"; + + /// + /// MouseOver state of the Common state group. + /// + public const string StateMouseOver = "MouseOver"; + + /// + /// Pressed state of the Common state group. + /// + public const string StatePressed = "Pressed"; + + /// + /// Disabled state of the Common state group. + /// + public const string StateDisabled = "Disabled"; + #endregion GroupCommon + + #region GroupFocus + /// + /// Focus state group. + /// + public const string GroupFocus = "FocusStates"; + + /// + /// Unfocused state of the Focus state group. + /// + public const string StateUnfocused = "Unfocused"; + + /// + /// Focused state of the Focus state group. + /// + public const string StateFocused = "Focused"; + #endregion GroupFocus + + #region GroupSelection + /// + /// Selection state group. + /// + public const string GroupSelection = "SelectionStates"; + + /// + /// Selected state of the Selection state group. + /// + public const string StateSelected = "Selected"; + + /// + /// Unselected state of the Selection state group. + /// + public const string StateUnselected = "Unselected"; + + /// + /// Selected inactive state of the Selection state group. + /// + public const string StateSelectedInactive = "SelectedInactive"; + #endregion GroupSelection + + #region GroupExpansion + /// + /// Expansion state group. + /// + public const string GroupExpansion = "ExpansionStates"; + + /// + /// Expanded state of the Expansion state group. + /// + public const string StateExpanded = "Expanded"; + + /// + /// Collapsed state of the Expansion state group. + /// + public const string StateCollapsed = "Collapsed"; + #endregion GroupExpansion + + #region GroupPopup + /// + /// Popup state group. + /// + public const string GroupPopup = "PopupStates"; + + /// + /// Opened state of the Popup state group. + /// + public const string StatePopupOpened = "PopupOpened"; + + /// + /// Closed state of the Popup state group. + /// + public const string StatePopupClosed = "PopupClosed"; + #endregion + + #region GroupValidation + /// + /// ValidationStates state group. + /// + public const string GroupValidation = "ValidationStates"; + + /// + /// The valid state for the ValidationStates group. + /// + public const string StateValid = "Valid"; + + /// + /// Invalid, focused state for the ValidationStates group. + /// + public const string StateInvalidFocused = "InvalidFocused"; + + /// + /// Invalid, unfocused state for the ValidationStates group. + /// + public const string StateInvalidUnfocused = "InvalidUnfocused"; + #endregion + + #region GroupExpandDirection + /// + /// ExpandDirection state group. + /// + public const string GroupExpandDirection = "ExpandDirectionStates"; + + /// + /// Down expand direction state of ExpandDirection state group. + /// + public const string StateExpandDown = "ExpandDown"; + + /// + /// Up expand direction state of ExpandDirection state group. + /// + public const string StateExpandUp = "ExpandUp"; + + /// + /// Left expand direction state of ExpandDirection state group. + /// + public const string StateExpandLeft = "ExpandLeft"; + + /// + /// Right expand direction state of ExpandDirection state group. + /// + public const string StateExpandRight = "ExpandRight"; + #endregion + + #region GroupHasItems + /// + /// HasItems state group. + /// + public const string GroupHasItems = "HasItemsStates"; + + /// + /// HasItems state of the HasItems state group. + /// + public const string StateHasItems = "HasItems"; + + /// + /// NoItems state of the HasItems state group. + /// + public const string StateNoItems = "NoItems"; + #endregion GroupHasItems + + #region GroupIncrease + /// + /// Increment state group. + /// + public const string GroupIncrease = "IncreaseStates"; + + /// + /// State enabled for increment group. + /// + public const string StateIncreaseEnabled = "IncreaseEnabled"; + + /// + /// State disabled for increment group. + /// + public const string StateIncreaseDisabled = "IncreaseDisabled"; + #endregion GroupIncrease + + #region GroupDecrease + /// + /// Decrement state group. + /// + public const string GroupDecrease = "DecreaseStates"; + + /// + /// State enabled for decrement group. + /// + public const string StateDecreaseEnabled = "DecreaseEnabled"; + + /// + /// State disabled for decrement group. + /// + public const string StateDecreaseDisabled = "DecreaseDisabled"; + #endregion GroupDecrease + + #region GroupIteractionMode + /// + /// InteractionMode state group. + /// + public const string GroupInteractionMode = "InteractionModeStates"; + + /// + /// Edit of the DisplayMode state group. + /// + public const string StateEdit = "Edit"; + + /// + /// Display of the DisplayMode state group. + /// + public const string StateDisplay = "Display"; + #endregion GroupIteractionMode + + #region GroupLocked + /// + /// DisplayMode state group. + /// + public const string GroupLocked = "LockedStates"; + + /// + /// Edit of the DisplayMode state group. + /// + public const string StateLocked = "Locked"; + + /// + /// Display of the DisplayMode state group. + /// + public const string StateUnlocked = "Unlocked"; + #endregion GroupLocked + + #region GroupActive + /// + /// Active state. + /// + public const string StateActive = "Active"; + + /// + /// Inactive state. + /// + public const string StateInactive = "Inactive"; + + /// + /// Active state group. + /// + public const string GroupActive = "ActiveStates"; + #endregion GroupActive + + #region GroupWatermark + /// + /// Non-watermarked state. + /// + public const string StateUnwatermarked = "Unwatermarked"; + + /// + /// Watermarked state. + /// + public const string StateWatermarked = "Watermarked"; + + /// + /// Watermark state group. + /// + public const string GroupWatermark = "WatermarkStates"; + #endregion GroupWatermark + + #region GroupCalendarButtonFocus + /// + /// Unfocused state for Calendar Buttons. + /// + public const string StateCalendarButtonUnfocused = "CalendarButtonUnfocused"; + + /// + /// Focused state for Calendar Buttons. + /// + public const string StateCalendarButtonFocused = "CalendarButtonFocused"; + + /// + /// CalendarButtons Focus state group. + /// + public const string GroupCalendarButtonFocus = "CalendarButtonFocusStates"; + #endregion GroupCalendarButtonFocus + + #region GroupBusyStatus + /// + /// Busy state for BusyIndicator. + /// + public const string StateBusy = "Busy"; + + /// + /// Idle state for BusyIndicator. + /// + public const string StateIdle = "Idle"; + + /// + /// Busyness group name. + /// + public const string GroupBusyStatus = "BusyStatusStates"; + #endregion + + #region GroupVisibility + /// + /// Visible state name for BusyIndicator. + /// + public const string StateVisible = "Visible"; + + /// + /// Hidden state name for BusyIndicator. + /// + public const string StateHidden = "Hidden"; + + /// + /// BusyDisplay group. + /// + public const string GroupVisibility = "VisibilityStates"; + #endregion + + /// + /// Use VisualStateManager to change the visual state of the control. + /// + /// + /// Control whose visual state is being changed. + /// + /// + /// A value indicating whether to use transitions when updating the + /// visual state, or to snap directly to the new visual state. + /// + /// + /// Ordered list of state names and fallback states to transition into. + /// Only the first state to be found will be used. + /// + public static void GoToState(Control control, bool useTransitions, params string[] stateNames) + { + Debug.Assert(control != null, "control should not be null!"); + Debug.Assert(stateNames != null, "stateNames should not be null!"); + Debug.Assert(stateNames.Length > 0, "stateNames should not be empty!"); + + foreach (string name in stateNames) + { + if (VisualStateManager.GoToState(control, name, useTransitions)) + { + break; + } + } + } + + /// + /// Gets the implementation root of the Control. + /// + /// The DependencyObject. + /// + /// Implements Silverlight's corresponding internal property on Control. + /// + /// Returns the implementation root or null. + public static FrameworkElement GetImplementationRoot(DependencyObject dependencyObject) + { + Debug.Assert(dependencyObject != null, "DependencyObject should not be null."); + return (1 == VisualTreeHelper.GetChildrenCount(dependencyObject)) ? + VisualTreeHelper.GetChild(dependencyObject, 0) as FrameworkElement : + null; + } + + /// + /// This method tries to get the named VisualStateGroup for the + /// dependency object. The provided object's ImplementationRoot will be + /// looked up in this call. + /// + /// The dependency object. + /// The visual state group's name. + /// Returns null or the VisualStateGroup object. + public static VisualStateGroup TryGetVisualStateGroup(DependencyObject dependencyObject, string groupName) + { + var root = GetImplementationRoot(dependencyObject); + if (root == null) + { + return null; + } + + return VisualStateManager.GetVisualStateGroups(root) + .OfType() + .FirstOrDefault(group => string.CompareOrdinal(groupName, @group.Name) == 0); + } + } +} diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/VisualTreeExtensions.cs b/src/GitHub.UI/Controls/AutoCompleteBox/VisualTreeExtensions.cs new file mode 100644 index 0000000000..b6cc0966c5 --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/VisualTreeExtensions.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Windows; +using System.Windows.Media; + +namespace GitHub.UI +{ + public static class VisualTreeExtensions + { + /// + /// Retrieves all the visual children of a framework element. + /// + /// The parent framework element. + /// The visual children of the framework element. + internal static IEnumerable GetVisualChildren(this DependencyObject parent) + { + Debug.Assert(parent != null, "The parent cannot be null."); + + int childCount = VisualTreeHelper.GetChildrenCount(parent); + for (int counter = 0; counter < childCount; counter++) + { + yield return VisualTreeHelper.GetChild(parent, counter); + } + } + + /// + /// Retrieves all the logical children of a framework element using a + /// breadth-first search. A visual element is assumed to be a logical + /// child of another visual element if they are in the same namescope. + /// For performance reasons this method manually manages the queue + /// instead of using recursion. + /// + /// + /// License for this method. + /// + /// (c) Copyright Microsoft Corporation. + /// This source is subject to the Microsoft Public License (Ms-PL). + /// Please see http://go.microsoft.com/fwlink/?LinkID=131993] for details. + /// All other rights reserved. + /// + /// The parent framework element. + /// The logical children of the framework element. + internal static IEnumerable GetLogicalChildrenBreadthFirst(this FrameworkElement parent) + { + Debug.Assert(parent != null, "The parent cannot be null."); + + var queue = new Queue(parent.GetVisualChildren().OfType()); + + while (queue.Count > 0) + { + var element = queue.Dequeue(); + yield return element; + + foreach (var visualChild in element.GetVisualChildren().OfType()) + { + queue.Enqueue(visualChild); + } + } + } + + internal static Window GetActiveWindow(this Application application) + { + var windows = application.Windows; + if (windows.Count == 0) return null; + return windows.Count == 1 + ? windows[0] // Optimization. I think this is the common case for us. + : windows.Cast().FirstOrDefault(x => x.IsActive); + } + } +} diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/WeakEventListener.cs b/src/GitHub.UI/Controls/AutoCompleteBox/WeakEventListener.cs new file mode 100644 index 0000000000..f9a76e5581 --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/WeakEventListener.cs @@ -0,0 +1,84 @@ +// (c) Copyright Microsoft Corporation. +// This source is subject to the Microsoft Public License (Ms-PL). +// Please see http://go.microsoft.com/fwlink/?LinkID=131993] for details. +// All other rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; + +namespace GitHub.UI +{ + /// + /// Implements a weak event listener that allows the owner to be garbage + /// collected if its only remaining link is an event handler. + /// + /// Type of instance listening for the event. + /// Type of source for the event. + /// Type of event arguments for the event. + [SuppressMessage("Microsoft.Performance", "CA1812:AvoidUninstantiatedInternalClasses", Justification = "Used as link target in several projects.")] + internal class WeakEventListener where TInstance : class + { + /// + /// WeakReference to the instance listening for the event. + /// + private readonly WeakReference weakInstance; + + /// + /// Gets or sets the method to call when the event fires. + /// + public Action OnEventAction { get; set; } + + /// + /// Gets or sets the method to call when detaching from the event. + /// + public Action> OnDetachAction { get; set; } + + /// + /// Initializes a new instances of the WeakEventListener class. + /// + /// Instance subscribing to the event. + public WeakEventListener(TInstance instance) + { + if (null == instance) + { + throw new ArgumentNullException("instance"); + } + weakInstance = new WeakReference(instance); + } + + /// + /// Handler for the subscribed event calls OnEventAction to handle it. + /// + /// Event source. + /// Event arguments. + public void OnEvent(TSource source, TEventArgs eventArgs) + { + var target = (TInstance)weakInstance.Target; + if (null != target) + { + // Call registered action + if (null != OnEventAction) + { + OnEventAction(target, source, eventArgs); + } + } + else + { + // Detach from event + Detach(); + } + } + + /// + /// Detaches from the subscribed event. + /// + public void Detach() + { + if (null != OnDetachAction) + { + OnDetachAction(this); + OnDetachAction = null; + } + } + } +} \ No newline at end of file diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/_README.md b/src/GitHub.UI/Controls/AutoCompleteBox/_README.md new file mode 100644 index 0000000000..c601dbfeda --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/_README.md @@ -0,0 +1,40 @@ +# WPF Toolkit + +This folder contains code copied and adapted from the [WPF Toolkit](http://wpf.codeplex.com/) project under the MS-PL +license. + +This contains the AutoCompleteBox code. + +# LICENSE + +Microsoft Public License (Ms-PL) + +This license governs use of the accompanying software. If you use the software, you accept this license. If you do not accept the license, do not use the software. + +1. Definitions + +The terms "reproduce," "reproduction," "derivative works," and "distribution" have the same meaning here as under U.S. copyright law. + +A "contribution" is the original software, or any additions or changes to the software. + +A "contributor" is any person that distributes its contribution under this license. + +"Licensed patents" are a contributor's patent claims that read directly on its contribution. + +2. Grant of Rights + +(A) Copyright Grant- Subject to the terms of this license, including the license conditions and limitations in section 3, each contributor grants you a non-exclusive, worldwide, royalty-free copyright license to reproduce its contribution, prepare derivative works of its contribution, and distribute its contribution or any derivative works that you create. + +(B) Patent Grant- Subject to the terms of this license, including the license conditions and limitations in section 3, each contributor grants you a non-exclusive, worldwide, royalty-free license under its licensed patents to make, have made, use, sell, offer for sale, import, and/or otherwise dispose of its contribution in the software or derivative works of the contribution in the software. + +3. Conditions and Limitations + +(A) No Trademark License- This license does not grant you rights to use any contributors' name, logo, or trademarks. + +(B) If you bring a patent claim against any contributor over patents that you claim are infringed by the software, your patent license from such contributor to the software ends automatically. + +(C) If you distribute any portion of the software, you must retain all copyright, patent, trademark, and attribution notices that are present in the software. + +(D) If you distribute any portion of the software in source code form, you may do so only under this license by including a complete copy of this license with your distribution. If you distribute any portion of the software in compiled or object code form, you may only do so under a license that complies with this license. + +(E) The software is licensed "as-is." You bear the risk of using it. The contributors give no express warranties, guarantees or conditions. You may have additional consumer rights under your local laws which this license cannot change. To the extent permitted under your local laws, the contributors exclude the implied warranties of merchantability, fitness for a particular purpose and non-infringement. \ No newline at end of file diff --git a/src/GitHub.UI/GitHub.UI.csproj b/src/GitHub.UI/GitHub.UI.csproj index 29100432f7..0e677d034a 100644 --- a/src/GitHub.UI/GitHub.UI.csproj +++ b/src/GitHub.UI/GitHub.UI.csproj @@ -13,6 +13,8 @@ + + diff --git a/src/GitHub.UI/Helpers/Dpi.cs b/src/GitHub.UI/Helpers/Dpi.cs new file mode 100644 index 0000000000..c95981bf21 --- /dev/null +++ b/src/GitHub.UI/Helpers/Dpi.cs @@ -0,0 +1,65 @@ +using System; +using System.Windows; + +namespace GitHub.UI +{ + public struct Dpi + { + const double defaultDpi = 96.0; + readonly double horizontal; + readonly double horizontalScale; + readonly double vertical; + readonly double verticalScale; + + public static readonly Dpi Default = new Dpi(defaultDpi, defaultDpi); + + public Dpi(double horizontal, double vertical) + { + this.horizontal = horizontal; + horizontalScale = horizontal / defaultDpi; + this.vertical = vertical; + verticalScale = vertical / defaultDpi; + } + + public double Horizontal { get { return horizontal; } } + public double HorizontalScale { get { return horizontalScale; } } + public double Vertical { get { return vertical; } } + public double VerticalScale { get { return verticalScale; } } + + public Point Scale(Point point) + { + return new Point(point.X * HorizontalScale, point.Y * VerticalScale); + } + + public override bool Equals(object obj) + { + return obj is Dpi && this == (Dpi)obj; + } + + public override int GetHashCode() + { + // Implementation from Jon Skeet: http://stackoverflow.com/questions/263400/what-is-the-best-algorithm-for-an-overridden-system-object-gethashcode + unchecked // Overflow is fine, just wrap + { + int hash = (int)2166136261; + hash = hash * 16777619 ^ Horizontal.GetHashCode(); + hash = hash * 16777619 ^ Vertical.GetHashCode(); + return hash; + } + } + + public static bool operator ==(Dpi x, Dpi y) + { + const double epsilon = 0.001; + + // Since we're comparing double's we need to use an epsilon because LOL floating point numbers. + return Math.Abs(x.Horizontal - y.Horizontal) < epsilon + && Math.Abs(x.Vertical - y.Vertical) < epsilon; + } + + public static bool operator !=(Dpi x, Dpi y) + { + return !(x == y); + } + } +} diff --git a/src/GitHub.UI/Helpers/DpiManager.cs b/src/GitHub.UI/Helpers/DpiManager.cs new file mode 100644 index 0000000000..092bdafd43 --- /dev/null +++ b/src/GitHub.UI/Helpers/DpiManager.cs @@ -0,0 +1,21 @@ +namespace GitHub.UI.Helpers +{ + public interface IDpiManager + { + Dpi CurrentDpi { get; set; } + } + + public class DpiManager : IDpiManager + { + static readonly DpiManager dpiManagerInstance = new DpiManager(); + + public static DpiManager Instance { get { return dpiManagerInstance; } } + + private DpiManager() + { + CurrentDpi = Dpi.Default; + } + + public Dpi CurrentDpi { get; set; } + } +} diff --git a/src/GitHub.UI/Views/AutoCompleteSuggestionView.xaml b/src/GitHub.UI/Views/AutoCompleteSuggestionView.xaml new file mode 100644 index 0000000000..d0f48cfe2d --- /dev/null +++ b/src/GitHub.UI/Views/AutoCompleteSuggestionView.xaml @@ -0,0 +1,27 @@ + + + + + + + + + + + + diff --git a/src/GitHub.UI/Views/AutoCompleteSuggestionView.xaml.cs b/src/GitHub.UI/Views/AutoCompleteSuggestionView.xaml.cs new file mode 100644 index 0000000000..d76f4fb316 --- /dev/null +++ b/src/GitHub.UI/Views/AutoCompleteSuggestionView.xaml.cs @@ -0,0 +1,49 @@ +using System.Reactive.Linq; +using System.Windows; +using GitHub.Extensions; +using GitHub.Extensions.Reactive; +using GitHub.Models; +using GitHub.UI; +using ReactiveUI; + +namespace GitHub.UI +{ + /// + /// Interaction logic for AutoCompleteSuggestionView.xaml + /// + public partial class AutoCompleteSuggestionView : IViewFor + { + public AutoCompleteSuggestionView() + { + InitializeComponent(); + this.WhenActivated(d => + { + d(this.OneWayBind(ViewModel, vm => vm.Name, v => v.name.Text)); + d(this.OneWayBind(ViewModel, vm => vm.Description, v => v.description.Text)); + + var imageObservable = this.WhenAnyObservable(v => v.ViewModel.Image); + d(imageObservable.WhereNotNull().BindTo(this, v => v.image.Source)); + d(imageObservable.Select(image => image != null).BindTo(this, v => v.image.Visibility)); + d(imageObservable.Select(image => image != null) + .Select(visible => new Thickness((visible ? 5 : 0), 0, 0, 0)) + .BindTo(this, v => v.suggestionText.Margin)); + }); + } + + public AutoCompleteSuggestion ViewModel + { + get { return (AutoCompleteSuggestion)GetValue(ViewModelProperty); } + set { SetValue(ViewModelProperty, value); } + } + + public static readonly DependencyProperty ViewModelProperty = DependencyProperty + .Register("ViewModel", typeof(AutoCompleteSuggestion), typeof(AutoCompleteSuggestionView), + new PropertyMetadata(null)); + + object IViewFor.ViewModel + { + get { return ViewModel; } + set { ViewModel = (AutoCompleteSuggestion)value; } + } + } +} diff --git a/src/GitHub.VisualStudio.UI/Styles/ThemeBlue.xaml b/src/GitHub.VisualStudio.UI/Styles/ThemeBlue.xaml index 8863e8ffb2..02ab52e767 100644 --- a/src/GitHub.VisualStudio.UI/Styles/ThemeBlue.xaml +++ b/src/GitHub.VisualStudio.UI/Styles/ThemeBlue.xaml @@ -67,5 +67,57 @@ - + + + + #333333 + + + #b7b7b7 + + + Transparent + + + + #333333 + + + #b7b7b7 + + + #e7e7e7 + + + + #ffffff + + + #ffffff + + + #3998d6 + + + + #0a0a0a + + + #909090 + + + Transparent + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.UI/Styles/ThemeDark.xaml b/src/GitHub.VisualStudio.UI/Styles/ThemeDark.xaml index d47c6cbadc..f3ab5a0166 100644 --- a/src/GitHub.VisualStudio.UI/Styles/ThemeDark.xaml +++ b/src/GitHub.VisualStudio.UI/Styles/ThemeDark.xaml @@ -67,5 +67,57 @@ - + + + + #cccccc + + + #b7b7b7 + + + Transparent + + + + #cccccc + + + #b7b7b7 + + + #3e3e3e + + + + #ffffff + + + #ffffff + + + #3998d6 + + + + #ffffff + + + #ffffff + + + Transparent + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.UI/Styles/ThemeLight.xaml b/src/GitHub.VisualStudio.UI/Styles/ThemeLight.xaml index 5ccf074b68..1944cfd418 100644 --- a/src/GitHub.VisualStudio.UI/Styles/ThemeLight.xaml +++ b/src/GitHub.VisualStudio.UI/Styles/ThemeLight.xaml @@ -67,5 +67,57 @@ - + + + + #333333 + + + #b7b7b7 + + + Transparent + + + + #333333 + + + #b7b7b7 + + + #e7e7e7 + + + + #ffffff + + + #ffffff + + + #3998d6 + + + + #0a0a0a + + + #909090 + + + Transparent + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.UI/Views/Documents/IssueishCommentView.xaml b/src/GitHub.VisualStudio.UI/Views/Documents/IssueishCommentView.xaml index 6ea85947eb..7dee441c04 100644 --- a/src/GitHub.VisualStudio.UI/Views/Documents/IssueishCommentView.xaml +++ b/src/GitHub.VisualStudio.UI/Views/Documents/IssueishCommentView.xaml @@ -128,39 +128,53 @@ - - - - - - - + + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + - + + + + + + + + + + + + + Submit review diff --git a/src/GitHub.VisualStudio.UI/Views/PullRequestReviewCommentView.xaml b/src/GitHub.VisualStudio.UI/Views/PullRequestReviewCommentView.xaml index 41597a44dc..eab0a78381 100644 --- a/src/GitHub.VisualStudio.UI/Views/PullRequestReviewCommentView.xaml +++ b/src/GitHub.VisualStudio.UI/Views/PullRequestReviewCommentView.xaml @@ -161,39 +161,51 @@ - - - - - - - + + + + + + + + + ()) + : base(Substitute.For(), Substitute.For()) { } diff --git a/test/GitHub.App.UnitTests/ViewModels/CommentViewModelTests.cs b/test/GitHub.App.UnitTests/ViewModels/CommentViewModelTests.cs index 6fa4e42632..786719d517 100644 --- a/test/GitHub.App.UnitTests/ViewModels/CommentViewModelTests.cs +++ b/test/GitHub.App.UnitTests/ViewModels/CommentViewModelTests.cs @@ -65,11 +65,14 @@ await target.InitializeAsync( } CommentViewModel CreateTarget( - ICommentService commentService = null) + ICommentService commentService = null, + IAutoCompleteAdvisor autoCompleteAdvisor = null + ) { commentService = commentService ?? Substitute.For(); + autoCompleteAdvisor = autoCompleteAdvisor ?? Substitute.For(); - return new CommentViewModel(commentService); + return new CommentViewModel(commentService, autoCompleteAdvisor); } } } diff --git a/test/GitHub.App.UnitTests/ViewModels/Documents/IssueishCommentViewModelTests.cs b/test/GitHub.App.UnitTests/ViewModels/Documents/IssueishCommentViewModelTests.cs index f925182bee..cee68ba3d5 100644 --- a/test/GitHub.App.UnitTests/ViewModels/Documents/IssueishCommentViewModelTests.cs +++ b/test/GitHub.App.UnitTests/ViewModels/Documents/IssueishCommentViewModelTests.cs @@ -54,11 +54,13 @@ await target.InitializeAsync( } IssueishCommentViewModel CreateTarget( - ICommentService commentService = null) + ICommentService commentService = null, + IAutoCompleteAdvisor autoCompleteAdvisor = null) { commentService = commentService ?? Substitute.For(); + autoCompleteAdvisor = autoCompleteAdvisor ?? Substitute.For(); - return new IssueishCommentViewModel(commentService); + return new IssueishCommentViewModel(commentService, autoCompleteAdvisor); } } } diff --git a/test/GitHub.App.UnitTests/ViewModels/GitHubPane/PullRequestCreationViewModelTests.cs b/test/GitHub.App.UnitTests/ViewModels/GitHubPane/PullRequestCreationViewModelTests.cs index 4e8d1ca8b6..2c2df0a648 100644 --- a/test/GitHub.App.UnitTests/ViewModels/GitHubPane/PullRequestCreationViewModelTests.cs +++ b/test/GitHub.App.UnitTests/ViewModels/GitHubPane/PullRequestCreationViewModelTests.cs @@ -55,6 +55,7 @@ struct TestData public IConnection Connection; public IApiClient ApiClient; public IModelService ModelService; + public IAutoCompleteAdvisor AutoCompleteAdvisor { get; set; } public IModelServiceFactory GetModelServiceFactory() { @@ -78,6 +79,7 @@ static TestData PrepareTestData( var connection = Substitute.For(); var api = Substitute.For(); var ms = Substitute.For(); + var autoCompleteAdvisor = Substitute.For(); connection.HostAddress.Returns(HostAddress.Create("https://github.com")); @@ -121,7 +123,8 @@ static TestData PrepareTestData( NotificationService = notifications, Connection = connection, ApiClient = api, - ModelService = ms + ModelService = ms, + AutoCompleteAdvisor = autoCompleteAdvisor }; } @@ -147,7 +150,7 @@ public async Task TargetBranchDisplayNameIncludesRepoOwnerWhenForkAsync() var prservice = new PullRequestService(data.GitClient, data.GitService, Substitute.For(), Substitute.For(), Substitute.For(), data.ServiceProvider.GetOperatingSystem(), Substitute.For()); prservice.GetPullRequestTemplate(data.ActiveRepo).Returns(Observable.Empty()); var vm = new PullRequestCreationViewModel(data.GetModelServiceFactory(), prservice, data.NotificationService, - Substitute.For(), data.GitService); + Substitute.For(), data.GitService, data.AutoCompleteAdvisor); await vm.InitializeAsync(data.ActiveRepo, data.Connection); Assert.That("octokit/master", Is.EqualTo(vm.TargetBranch.DisplayName)); } @@ -183,7 +186,7 @@ public async Task CreatingPRsAsync( var prservice = new PullRequestService(data.GitClient, data.GitService, Substitute.For(), Substitute.For(), Substitute.For(), data.ServiceProvider.GetOperatingSystem(), Substitute.For()); var vm = new PullRequestCreationViewModel(data.GetModelServiceFactory(), prservice, data.NotificationService, - Substitute.For(), data.GitService); + Substitute.For(), data.GitService, data.AutoCompleteAdvisor); await vm.InitializeAsync(data.ActiveRepo, data.Connection); // the TargetBranch property gets set to whatever the repo default is (we assume master here), @@ -226,7 +229,7 @@ public async Task TemplateIsUsedIfPresentAsync() prservice.GetPullRequestTemplate(data.ActiveRepo).Returns(Observable.Return("Test PR template")); var vm = new PullRequestCreationViewModel(data.GetModelServiceFactory(), prservice, data.NotificationService, - Substitute.For(), data.GitService); + Substitute.For(), data.GitService, data.AutoCompleteAdvisor); await vm.InitializeAsync(data.ActiveRepo, data.Connection); Assert.That("Test PR template", Is.EqualTo(vm.Description)); @@ -246,7 +249,7 @@ public async Task LoadsDraft() var prservice = Substitute.For(); var vm = new PullRequestCreationViewModel(data.GetModelServiceFactory(), prservice, data.NotificationService, - draftStore, data.GitService); + draftStore, data.GitService, data.AutoCompleteAdvisor); await vm.InitializeAsync(data.ActiveRepo, data.Connection); Assert.That(vm.PRTitle, Is.EqualTo("This is a Title.")); @@ -261,7 +264,7 @@ public async Task UpdatesDraftWhenDescriptionChanges() var draftStore = Substitute.For(); var prservice = Substitute.For(); var vm = new PullRequestCreationViewModel(data.GetModelServiceFactory(), prservice, data.NotificationService, - draftStore, data.GitService, scheduler); + draftStore, data.GitService, data.AutoCompleteAdvisor, scheduler); await vm.InitializeAsync(data.ActiveRepo, data.Connection); vm.Description = "Body changed."; @@ -284,7 +287,7 @@ public async Task UpdatesDraftWhenTitleChanges() var draftStore = Substitute.For(); var prservice = Substitute.For(); var vm = new PullRequestCreationViewModel(data.GetModelServiceFactory(), prservice, data.NotificationService, - draftStore, data.GitService, scheduler); + draftStore, data.GitService, data.AutoCompleteAdvisor, scheduler); await vm.InitializeAsync(data.ActiveRepo, data.Connection); vm.PRTitle = "Title changed."; @@ -307,7 +310,7 @@ public async Task DeletesDraftWhenPullRequestSubmitted() var draftStore = Substitute.For(); var prservice = Substitute.For(); var vm = new PullRequestCreationViewModel(data.GetModelServiceFactory(), prservice, data.NotificationService, draftStore, - data.GitService, scheduler); + data.GitService, data.AutoCompleteAdvisor, scheduler); await vm.InitializeAsync(data.ActiveRepo, data.Connection); await vm.CreatePullRequest.Execute(); @@ -323,7 +326,7 @@ public async Task DeletesDraftWhenCanceled() var draftStore = Substitute.For(); var prservice = Substitute.For(); var vm = new PullRequestCreationViewModel(data.GetModelServiceFactory(), prservice, data.NotificationService, draftStore, - data.GitService, scheduler); + data.GitService, data.AutoCompleteAdvisor, scheduler); await vm.InitializeAsync(data.ActiveRepo, data.Connection); await vm.Cancel.Execute(); diff --git a/test/GitHub.App.UnitTests/ViewModels/GitHubPane/PullRequestReviewAuthoringViewModelTests.cs b/test/GitHub.App.UnitTests/ViewModels/GitHubPane/PullRequestReviewAuthoringViewModelTests.cs index 42c876cb35..f2d4d2925a 100644 --- a/test/GitHub.App.UnitTests/ViewModels/GitHubPane/PullRequestReviewAuthoringViewModelTests.cs +++ b/test/GitHub.App.UnitTests/ViewModels/GitHubPane/PullRequestReviewAuthoringViewModelTests.cs @@ -487,12 +487,15 @@ static PullRequestReviewAuthoringViewModel CreateTarget( IPullRequestSessionManager sessionManager = null, IMessageDraftStore draftStore = null, IPullRequestFilesViewModel files = null, - IScheduler timerScheduler = null) + IScheduler timerScheduler = null, + IAutoCompleteAdvisor autoCompleteAdvisor = null + ) { editorService = editorService ?? Substitute.For(); sessionManager = sessionManager ?? CreateSessionManager(); draftStore = draftStore ?? Substitute.For(); files = files ?? Substitute.For(); + autoCompleteAdvisor = autoCompleteAdvisor ?? Substitute.For(); timerScheduler = timerScheduler ?? DefaultScheduler.Instance; return new PullRequestReviewAuthoringViewModel( @@ -501,6 +504,7 @@ static PullRequestReviewAuthoringViewModel CreateTarget( sessionManager, draftStore, files, + autoCompleteAdvisor, timerScheduler); } diff --git a/test/GitHub.App.UnitTests/ViewModels/PullRequestReviewCommentThreadViewModelTests.cs b/test/GitHub.App.UnitTests/ViewModels/PullRequestReviewCommentThreadViewModelTests.cs index 72e2c88536..2420120dea 100644 --- a/test/GitHub.App.UnitTests/ViewModels/PullRequestReviewCommentThreadViewModelTests.cs +++ b/test/GitHub.App.UnitTests/ViewModels/PullRequestReviewCommentThreadViewModelTests.cs @@ -172,8 +172,9 @@ static IViewViewModelFactory CreateFactory() { var result = Substitute.For(); var commentService = Substitute.For(); + var autoCompleteAdvisor = Substitute.For(); result.CreateViewModel().Returns(_ => - new PullRequestReviewCommentViewModel(commentService)); + new PullRequestReviewCommentViewModel(commentService, autoCompleteAdvisor)); return result; } diff --git a/test/GitHub.App.UnitTests/ViewModels/PullRequestReviewCommentViewModelTests.cs b/test/GitHub.App.UnitTests/ViewModels/PullRequestReviewCommentViewModelTests.cs index 838d6fc8ca..d6d2fe8043 100644 --- a/test/GitHub.App.UnitTests/ViewModels/PullRequestReviewCommentViewModelTests.cs +++ b/test/GitHub.App.UnitTests/ViewModels/PullRequestReviewCommentViewModelTests.cs @@ -54,7 +54,8 @@ public async Task CanBeExecutedForPlaceholders() var thread = CreateThread(); var currentUser = Substitute.For(); var commentService = Substitute.For(); - var target = new PullRequestReviewCommentViewModel(commentService); + var autoCompleteAdvisor = Substitute.For(); + var target = new PullRequestReviewCommentViewModel(commentService, autoCompleteAdvisor); await target.InitializeAsPlaceholderAsync(session, thread, false, false); @@ -98,7 +99,8 @@ public async Task CannotBeExecutedForPlaceholders() var thread = CreateThread(); var currentUser = Substitute.For(); var commentService = Substitute.For(); - var target = new PullRequestReviewCommentViewModel(commentService); + var autoCompleteAdvisor = Substitute.For(); + var target = new PullRequestReviewCommentViewModel(commentService, autoCompleteAdvisor); await target.InitializeAsPlaceholderAsync(session, thread, false, false); @@ -218,16 +220,18 @@ static async Task CreateTarget( ICommentThreadViewModel thread = null, ActorModel currentUser = null, PullRequestReviewModel review = null, - PullRequestReviewCommentModel comment = null) + PullRequestReviewCommentModel comment = null, + IAutoCompleteAdvisor autoCompleteAdvisor = null) { session = session ?? CreateSession(); commentService = commentService ?? Substitute.For(); + autoCompleteAdvisor = autoCompleteAdvisor ?? Substitute.For(); thread = thread ?? CreateThread(); currentUser = currentUser ?? new ActorModel { Login = "CurrentUser" }; comment = comment ?? new PullRequestReviewCommentModel(); review = review ?? CreateReview(PullRequestReviewState.Approved, comment); - var result = new PullRequestReviewCommentViewModel(commentService); + var result = new PullRequestReviewCommentViewModel(commentService, autoCompleteAdvisor); await result.InitializeAsync(session, thread, review, comment, CommentEditState.None); return result; } diff --git a/test/GitHub.InlineReviews.UnitTests/ViewModels/InlineCommentPeekViewModelTests.cs b/test/GitHub.InlineReviews.UnitTests/ViewModels/InlineCommentPeekViewModelTests.cs index bbdf4668c2..5e9ac069e6 100644 --- a/test/GitHub.InlineReviews.UnitTests/ViewModels/InlineCommentPeekViewModelTests.cs +++ b/test/GitHub.InlineReviews.UnitTests/ViewModels/InlineCommentPeekViewModelTests.cs @@ -272,8 +272,9 @@ static IViewViewModelFactory CreateFactory() var draftStore = Substitute.For(); var commentService = Substitute.For(); var result = Substitute.For(); + var autoCompleteAdvisor = Substitute.For(); result.CreateViewModel().Returns(_ => - new PullRequestReviewCommentViewModel(commentService)); + new PullRequestReviewCommentViewModel(commentService, autoCompleteAdvisor)); result.CreateViewModel().Returns(_ => new PullRequestReviewCommentThreadViewModel(draftStore, result)); return result; diff --git a/test/GitHub.UI.UnitTests/Controls/AutoCompleteBoxTests.cs b/test/GitHub.UI.UnitTests/Controls/AutoCompleteBoxTests.cs new file mode 100644 index 0000000000..33baca44a3 --- /dev/null +++ b/test/GitHub.UI.UnitTests/Controls/AutoCompleteBoxTests.cs @@ -0,0 +1,293 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Reactive.Linq; +using System.Threading; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media.Imaging; +using GitHub.Extensions; +using GitHub.Models; +using GitHub.Services; +using GitHub.UI.Helpers; +using NSubstitute; +using NUnit.Framework; + +namespace GitHub.UI.UnitTests.Controls +{ + public class AutoCompleteBoxTests + { + [Apartment(ApartmentState.STA)] + public class TheItemsSourceProperty + { + [Test] + public void SelectsFirstItemWhenSetToNonEmptyCollection() + { + var obs = Observable.Return(new BitmapImage()); + + var suggestions = new List + { + new AutoCompleteSuggestion("aaaa", obs, ":", ":"), + new AutoCompleteSuggestion("bbbb", obs, ":", ":"), + new AutoCompleteSuggestion("ccc", obs, ":", ":") + }; + var result = new AutoCompleteResult(1, new ReadOnlyCollection(suggestions)); + var advisor = Substitute.For(); + advisor.GetAutoCompletionSuggestions(Arg.Any(), Arg.Any()) + .Returns(Observable.Return(result)); + + var textBox = new TextBox(); + var autoCompleteBox = new AutoCompleteBox(Substitute.For()) + { + SelectionAdapter = new SelectorSelectionAdapter(new ListBox()), + Advisor = advisor, + TextBox = new TextBoxAutoCompleteTextInput { TextBox = textBox } + }; + + textBox.Text = ":"; + + Assert.That(((AutoCompleteSuggestion)autoCompleteBox.SelectedItem).Name, Is.EqualTo("aaaa")); + Assert.That(autoCompleteBox.Text, Is.EqualTo(":")); + } + } + + [Apartment(ApartmentState.STA)] + public class TheIsDropDownOpenProperty + { + [Test] + public void IsTrueWhenTextBoxChangesWithPrefixedValue() + { + var obs = Observable.Return(new BitmapImage()); + + var suggestions = new List + { + new AutoCompleteSuggestion("aaaa", obs, ":", ":"), + new AutoCompleteSuggestion("bbbb", obs, ":", ":"), + new AutoCompleteSuggestion("ccc", obs, ":", ":") + }; + var result = new AutoCompleteResult(0, new ReadOnlyCollection(suggestions)); + var advisor = Substitute.For(); + advisor.GetAutoCompletionSuggestions(Arg.Any(), Arg.Any()) + .Returns(Observable.Return(result)); + + var textBox = new TextBox(); + var autoCompleteBox = new AutoCompleteBox(Substitute.For()) + { + SelectionAdapter = new SelectorSelectionAdapter(new ListBox()), + Advisor = advisor, + TextBox = new TextBoxAutoCompleteTextInput { TextBox = textBox } + }; + + textBox.Text = ":"; + + Assert.True(autoCompleteBox.IsDropDownOpen); + } + + [Test] + public void IsFalseAfterASuggestionIsSelected() + { + var obs = Observable.Return(new BitmapImage()); + + var suggestions = new List + { + new AutoCompleteSuggestion("aaaa", obs, ":", ":"), + new AutoCompleteSuggestion("bbbb", obs, ":", ":"), + new AutoCompleteSuggestion("ccc", obs, ":", ":") + }; + var result = new AutoCompleteResult(2, new ReadOnlyCollection(suggestions)); + var advisor = Substitute.For(); + advisor.GetAutoCompletionSuggestions(Arg.Any(), Arg.Any()) + .Returns(Observable.Return(result)); + + var selectionAdapter = new TestSelectorSelectionAdapter(); + var textBox = new TextBox(); + var autoCompleteBox = new AutoCompleteBox(Substitute.For()) + { + SelectionAdapter = selectionAdapter, + Advisor = advisor, + TextBox = new TextBoxAutoCompleteTextInput {TextBox = textBox} + }; + textBox.Text = "A :a"; + textBox.CaretIndex = 4; + Assert.AreEqual(4, textBox.CaretIndex); + Assert.AreEqual(4, autoCompleteBox.TextBox.CaretIndex); + Assert.True(autoCompleteBox.IsDropDownOpen); + + selectionAdapter.DoCommit(); + + Assert.That(textBox.Text, Is.EqualTo("A :aaaa: ")); + Assert.False(autoCompleteBox.IsDropDownOpen); + } + + [Test] + public void IsFalseAfterASuggestionIsCancelled() + { + var obs = Observable.Return(new BitmapImage()); + + var suggestions = new List + { + new AutoCompleteSuggestion("aaaa", obs, ":", ":"), + new AutoCompleteSuggestion("bbbb", obs, ":", ":"), + new AutoCompleteSuggestion("ccc", obs, ":", ":") + }; + var result = new AutoCompleteResult(2, new ReadOnlyCollection(suggestions)); + var advisor = Substitute.For(); + advisor.GetAutoCompletionSuggestions(Arg.Any(), Arg.Any()) + .Returns(Observable.Return(result)); + + var selectionAdapter = new TestSelectorSelectionAdapter(); + var textBox = new TextBox(); + var autoCompleteBox = new AutoCompleteBox(Substitute.For()) + { + SelectionAdapter = selectionAdapter, + Advisor = advisor, + TextBox = new TextBoxAutoCompleteTextInput { TextBox = textBox } + }; + textBox.Text = "A :a"; + textBox.CaretIndex = 4; + Assert.AreEqual(4, textBox.CaretIndex); + Assert.AreEqual(4, autoCompleteBox.TextBox.CaretIndex); + Assert.True(autoCompleteBox.IsDropDownOpen); + + selectionAdapter.DoCancel(); + + Assert.That(textBox.Text, Is.EqualTo("A :a")); + Assert.False(autoCompleteBox.IsDropDownOpen); + } + + [Test] + public void HandlesKeyPressesToSelectAndCancelSelections() + { + var obs = Observable.Return(new BitmapImage()); + + var suggestions = new List + { + new AutoCompleteSuggestion("aaaa", obs, ":", ":"), + new AutoCompleteSuggestion("bbbb", obs, ":", ":"), + new AutoCompleteSuggestion("ccc", obs, ":", ":") + }; + var result = new AutoCompleteResult(2, new ReadOnlyCollection(suggestions)); + var advisor = Substitute.For(); + advisor.GetAutoCompletionSuggestions(Arg.Any(), Arg.Any()) + .Returns(Observable.Return(result)); + + var selectionAdapter = new TestSelectorSelectionAdapter(); + var textBox = new TextBox(); + var autoCompleteBox = new AutoCompleteBox(Substitute.For()) + { + SelectionAdapter = selectionAdapter, + Advisor = advisor, + TextBox = new TextBoxAutoCompleteTextInput { TextBox = textBox } + }; + textBox.Text = "A :a"; + textBox.CaretIndex = 4; + Assert.AreEqual(4, textBox.CaretIndex); + Assert.AreEqual(4, autoCompleteBox.TextBox.CaretIndex); + Assert.True(autoCompleteBox.IsDropDownOpen); + selectionAdapter.SelectorControl.SelectedIndex = 1; // Select the second item + + selectionAdapter.DoKeyDown(Key.Enter); + + Assert.AreEqual("A :bbbb: ", textBox.Text); + Assert.False(autoCompleteBox.IsDropDownOpen); + + textBox.Text = "A :bbbb: :"; + textBox.CaretIndex = 10; + + // Ensure we can re-open the dropdown + Assert.True(autoCompleteBox.IsDropDownOpen); + + selectionAdapter.DoKeyDown(Key.Escape); + Assert.False(autoCompleteBox.IsDropDownOpen); + Assert.AreEqual("A :bbbb: :", textBox.Text); + } + + class TestSelectorSelectionAdapter : SelectorSelectionAdapter + { + public TestSelectorSelectionAdapter() + : base(new ListBox()) + { + } + + public void DoCommit() + { + base.OnCommit(); + } + + public void DoCancel() + { + base.OnCancel(); + } + + public void DoKeyDown(Key key) + { + var keyEventArgs = FakeKeyEventArgs.Create(key, false); + HandleKeyDown(keyEventArgs); + } + } + } + + public class FakeKeyEventArgs : KeyEventArgs + { + public static KeyEventArgs Create(Key realKey, bool isSystemKey, params Key[] pressedKeys) + { + return new FakeKeyEventArgs(realKey, isSystemKey, GetKeyStatesFromPressedKeys(pressedKeys)); + } + + public static KeyEventArgs Create(Key realKey, params Key[] pressedKeys) + { + return new FakeKeyEventArgs(realKey, false, GetKeyStatesFromPressedKeys(pressedKeys)); + } + + FakeKeyEventArgs(Key realKey, bool isSystemKey, IDictionary keyStatesMap) : base(GetKeyboardDevice(keyStatesMap), Substitute.For(), 1, realKey) + { + if (isSystemKey) + { + MarkSystem(); + } + RoutedEvent = ReflectionExtensions.CreateUninitialized(); + } + + public void MarkSystem() + { + ReflectionExtensions.Invoke(this, "MarkSystem"); + } + + static KeyboardDevice GetKeyboardDevice(IDictionary keyStatesMap) + { + return new FakeKeyboardDevice(keyStatesMap); + } + + static IDictionary GetKeyStatesFromPressedKeys(IEnumerable pressedKeys) + { + return pressedKeys == null ? null : pressedKeys.ToDictionary(k => k, k => KeyStates.Down); + } + } + + public class FakeKeyboardDevice : KeyboardDevice + { + readonly IDictionary keyStateMap; + + public FakeKeyboardDevice(IDictionary keyStateMap) : base(CreateFakeInputManager()) + { + this.keyStateMap = keyStateMap ?? new Dictionary(); + } + + protected override KeyStates GetKeyStatesFromSystem(Key key) + { + KeyStates keyStates; + keyStateMap.TryGetValue(key, out keyStates); + return keyStates; + } + + static InputManager CreateFakeInputManager() + { + Castle.DynamicProxy.Generators.AttributesToAvoidReplicating.Add(typeof(System.Security.Permissions.UIPermissionAttribute)); + // WARNING: This next call is pure evil, but ok here. See the note in the method implementation. + return ReflectionExtensions.CreateUninitialized(); + } + } + + } +} diff --git a/test/GitHub.UI.UnitTests/Controls/AutoCompleteSuggestionTests.cs b/test/GitHub.UI.UnitTests/Controls/AutoCompleteSuggestionTests.cs new file mode 100644 index 0000000000..4674997af9 --- /dev/null +++ b/test/GitHub.UI.UnitTests/Controls/AutoCompleteSuggestionTests.cs @@ -0,0 +1,58 @@ +using System.Reactive.Linq; +using System.Windows.Media.Imaging; +using GitHub.Models; +using NUnit.Framework; + +namespace GitHub.UI.UnitTests.Controls +{ + public class AutoCompleteSuggestionTests + { + public class TheToStringMethod + { + [TestCase(":", ":", ":foo:")] + [TestCase("@", "", "@foo")] + [TestCase("#", "", "#foo")] + [TestCase("@", null, "@foo")] + public void ReturnsWordSurroundedByPrefixAndSuffix(string prefix, string suffix, string expected) + { + var obs = Observable.Return(new BitmapImage()); + var suggestion = new AutoCompleteSuggestion("foo", obs, prefix, suffix); + Assert.AreEqual(expected, suggestion.ToString()); + } + } + + public class TheGetSortRankMethod + { + [TestCase("pat", "full name", 1)] + [TestCase("yosemite", "pat name", 0)] + [TestCase("minnie", "full pat", 0)] + [TestCase("patrick", "full name", 1)] + [TestCase("groot", "patrick name", 0)] + [TestCase("driver", "danica patrick", 0)] + [TestCase("patricka", "pat name", 1)] + [TestCase("nomatch", "full name", -1)] + public void ReturnsCorrectScoreForSuggestions(string login, string name, int expectedRank) + { + var obs = Observable.Return(new BitmapImage()); + + var suggestion = new AutoCompleteSuggestion(login, name, obs, "@", ""); + + int rank = suggestion.GetSortRank("pat"); + + Assert.AreEqual(expectedRank, rank); + } + + [Test] + public void ReturnsOneForEmptyString() + { + var obs = Observable.Return(new BitmapImage()); + + var suggestion = new AutoCompleteSuggestion("joe", "namathe", obs, "@", ""); + + int rank = suggestion.GetSortRank(""); + + Assert.AreEqual(1, rank); + } + } + } +} diff --git a/test/GitHub.UI.UnitTests/Controls/AutoCompleteTextInputExtensionsTests.cs b/test/GitHub.UI.UnitTests/Controls/AutoCompleteTextInputExtensionsTests.cs new file mode 100644 index 0000000000..bb9826a7e9 --- /dev/null +++ b/test/GitHub.UI.UnitTests/Controls/AutoCompleteTextInputExtensionsTests.cs @@ -0,0 +1,29 @@ +using GitHub.UI.Controls.AutoCompleteBox; +using NSubstitute; +using NUnit.Framework; + +namespace GitHub.UI.UnitTests.Controls +{ + class AutoCompleteTextInputExtensionsTests + { + public class TheGetExpandedTextMethod + { + [TestCase(":", 1, 0, ":apple: ")] + [TestCase(":a", 2, 0, ":apple: ")] + [TestCase(":ap", 3, 0, ":apple: ")] + [TestCase(":a", 1, 0, ":apple: a")] + [TestCase("Test :", 6, 5, "Test :apple: ")] + [TestCase("Test :ap", 8, 5, "Test :apple: ")] + [TestCase("Test :apother stuff", 8, 5, "Test :apple: other stuff")] + public void ReturnsExpandedText(string text, int caretIndex, int completionOffset, string expected) + { + var textInput = Substitute.For(); + textInput.CaretIndex.Returns(caretIndex); + textInput.Text.Returns(text); + + var expandedText = textInput.GetExpandedText(":apple:", completionOffset); + Assert.AreEqual(expected, expandedText); + } + } + } +} diff --git a/test/GitHub.UI.UnitTests/Helpers/AutoCompleteAdvisorTests.cs b/test/GitHub.UI.UnitTests/Helpers/AutoCompleteAdvisorTests.cs new file mode 100644 index 0000000000..a85fec36a0 --- /dev/null +++ b/test/GitHub.UI.UnitTests/Helpers/AutoCompleteAdvisorTests.cs @@ -0,0 +1,229 @@ +using System; +using System.Collections.Generic; +using System.Reactive.Linq; +using System.Threading.Tasks; +using System.Windows.Media.Imaging; +using GitHub.Models; +using GitHub.Services; +using NSubstitute; +using NUnit.Framework; + +namespace GitHub.UI.UnitTests.Helpers +{ + public class AutoCompleteAdvisorTests + { + public class TheParseAutoCompletionTokenMethod + { + [TestCase(":", 1, "", 0)] + [TestCase(":po", 3, "po", 0)] + [TestCase(":po", 2, "p", 0)] + [TestCase(":po or no :po", 2, "p", 0)] + [TestCase(":po or no :po yo", 13, "po", 10)] + [TestCase("This is :poo", 12, "poo", 8)] + [TestCase("This is :poo or is it", 12, "poo", 8)] + [TestCase("This is\r\n:poo or is it", 13, "poo", 9)] + [TestCase("This is :poo or is it :zap:", 12, "poo", 8)] + public void ParsesWordOffsetAndType( + string text, + int caretPosition, + string expectedPrefix, + int expectedOffset) + { + var token = AutoCompleteAdvisor.ParseAutoCompletionToken(text, caretPosition, ":"); + + Assert.AreEqual(expectedPrefix, token.SearchSearchPrefix); + Assert.AreEqual(expectedOffset, token.Offset); + } + + [TestCase("", 0)] + [TestCase("foo bar", 0)] + [TestCase("This has no special stuff", 5)] + [TestCase("This has a : but caret is after the space after it", 13)] + public void ReturnsNullForTextWithoutAnyTriggerCharactersMatchingCaretIndex(string text, int caretPosition) + { + Assert.Null(AutoCompleteAdvisor.ParseAutoCompletionToken(text, caretPosition, ":")); + } + + [TestCase("", 1)] + [TestCase("", -1)] + [TestCase("foo", 4)] + [TestCase("foo", -1)] + public void ThrowsExceptionWhenCaretIndexIsOutOfRangeOfText(string text, int caretIndex) + { + Assert.Throws( + () => AutoCompleteAdvisor.ParseAutoCompletionToken(text, caretIndex, ":")); + } + } + + public class TheGetAutoCompletionSuggestionsMethod + { + [Test] + public async Task ReturnsResultsWhenOnlyTokenTyped() + { + var obs = Observable.Return(new BitmapImage()); + + var suggestions = new List + { + new AutoCompleteSuggestion("rainbow", obs, ":", ":"), + new AutoCompleteSuggestion("poop", obs, ":", ":"), + new AutoCompleteSuggestion("poop_scoop", obs, ":", ":") + }.ToObservable(); + + var mentionsSource = Substitute.For(); + mentionsSource.GetSuggestions().Returns(Observable.Empty()); + mentionsSource.Prefix.Returns("@"); + + var emojiSource = Substitute.For(); + emojiSource.GetSuggestions().Returns(suggestions); + emojiSource.Prefix.Returns(":"); + + var advisor = new AutoCompleteAdvisor(new[] { mentionsSource, emojiSource }); + + var result = await advisor.GetAutoCompletionSuggestions(":", 1); + + Assert.AreEqual(0, result.Offset); + Assert.AreEqual(3, result.Suggestions.Count); + Assert.AreEqual("poop", result.Suggestions[0].Name); + Assert.AreEqual("poop_scoop", result.Suggestions[1].Name); + Assert.AreEqual("rainbow", result.Suggestions[2].Name); + } + + [Test] + public async Task ReturnsResultsWithNameMatchingToken() + { + var obs = Observable.Return(new BitmapImage()); + + var suggestions = new List + { + new AutoCompleteSuggestion("rainbow", obs, ":", ":"), + new AutoCompleteSuggestion("poop", obs, ":", ":"), + new AutoCompleteSuggestion("poop_scoop", obs, ":", ":") + }.ToObservable(); + + var mentionsSource = Substitute.For(); + mentionsSource.GetSuggestions().Returns(Observable.Empty()); + mentionsSource.Prefix.Returns("@"); + + var emojiSource = Substitute.For(); + emojiSource.GetSuggestions().Returns(suggestions); + emojiSource.Prefix.Returns(":"); + + var advisor = new AutoCompleteAdvisor(new[] { mentionsSource, emojiSource }); + + var result = await advisor.GetAutoCompletionSuggestions("this is :poo", 12); + + Assert.AreEqual(8, result.Offset); + Assert.AreEqual(2, result.Suggestions.Count); + Assert.AreEqual("poop", result.Suggestions[0].Name); + Assert.AreEqual("poop_scoop", result.Suggestions[1].Name); + } + + [Test] + public async Task ReturnsResultsWithDescriptionMatchingToken() + { + var obs = Observable.Return(new BitmapImage()); + + var suggestions = new List + { + new AutoCompleteSuggestion("rainbow", "John Doe", obs, "@", ""), + new AutoCompleteSuggestion("poop", "Alice Bob", obs, "@", ""), + new AutoCompleteSuggestion("poop_scoop", obs, "@", ""), + new AutoCompleteSuggestion("loop", "Jimmy Alice Cooper", obs, "@", ""), + }.ToObservable(); + + var mentionsSource = Substitute.For(); + mentionsSource.GetSuggestions().Returns(suggestions); + mentionsSource.Prefix.Returns("@"); + + var emojiSource = Substitute.For(); + emojiSource.GetSuggestions().Returns(Observable.Empty()); + emojiSource.Prefix.Returns(":"); + + var advisor = new AutoCompleteAdvisor(new[] { mentionsSource, emojiSource }); + + var result = await advisor.GetAutoCompletionSuggestions("this is @alice", 12); + + Assert.AreEqual(8, result.Offset); + Assert.AreEqual(2, result.Suggestions.Count); + Assert.AreEqual("loop", result.Suggestions[0].Name); + Assert.AreEqual("poop", result.Suggestions[1].Name); + } + + [Test] + public async Task ReturnsMentionsInCorrectOrder() + { + var obs = Observable.Return(new BitmapImage()); + + var suggestions = new List + { + // We need to have more than 10 matches to ensure we grab the most appropriate top ten + new AutoCompleteSuggestion("zztop1", "RainbowBright Doe", obs, "@", ""), + new AutoCompleteSuggestion("zztop2", "RainbowBright Doe", obs, "@", ""), + new AutoCompleteSuggestion("zztop3", "RainbowBright Doe", obs, "@", ""), + new AutoCompleteSuggestion("zztop4", "RainbowBright Doe", obs, "@", ""), + new AutoCompleteSuggestion("zztop5", "RainbowBright Doe", obs, "@", ""), + new AutoCompleteSuggestion("zztop6", "RainbowBright Doe", obs, "@", ""), + new AutoCompleteSuggestion("zztop7", "RainbowBright Doe", obs, "@", ""), + new AutoCompleteSuggestion("zztop8", "RainbowBright Doe", obs, "@", ""), + new AutoCompleteSuggestion("zztop9", "RainbowBright Doe", obs, "@", ""), + new AutoCompleteSuggestion("zztop10", "RainbowBright Doe", obs, "@", ""), + new AutoCompleteSuggestion("rainbowbright", "Jimmy Alice Cooper", obs, "@", ""), + new AutoCompleteSuggestion("apricot", "Bob Rainbow", obs, "@", ""), + new AutoCompleteSuggestion("rainbow", "John Doe", obs, "@", ""), + new AutoCompleteSuggestion("poop_scoop", obs, "@", ""), + new AutoCompleteSuggestion("zeke", "RainbowBright Doe", obs, "@", ""), + new AutoCompleteSuggestion("bill", "RainbowBright Doe", obs, "@", "") + }.ToObservable(); + + var mentionsSource = Substitute.For(); + mentionsSource.GetSuggestions().Returns(suggestions); + mentionsSource.Prefix.Returns("@"); + + var emojiSource = Substitute.For(); + emojiSource.GetSuggestions().Returns(Observable.Empty()); + emojiSource.Prefix.Returns(":"); + + var advisor = new AutoCompleteAdvisor(new[] { mentionsSource, emojiSource }); + + var result = await advisor.GetAutoCompletionSuggestions("this is @rainbow sucka", 16); + + Assert.AreEqual("rainbow", result.Suggestions[0].Name); + Assert.AreEqual("rainbowbright", result.Suggestions[1].Name); + Assert.AreEqual("apricot", result.Suggestions[2].Name); + Assert.AreEqual("bill", result.Suggestions[3].Name); // Bill and Zeke have the same name + Assert.AreEqual("zeke", result.Suggestions[4].Name); // but the secondary sort is by login + } + + [Theory] + [TestCase("", 0)] + [TestCase("Foo bar baz", 0)] + [TestCase("Foo bar baz", 3)] + public async Task ReturnsEmptyAutoCompleteResult(string text, int caretIndex) + { + var autoCompleteSource = Substitute.For(); + autoCompleteSource.GetSuggestions().Returns(Observable.Empty()); + autoCompleteSource.Prefix.Returns(":"); + + var advisor = new AutoCompleteAdvisor(new[] {autoCompleteSource}); + + var result = await advisor.GetAutoCompletionSuggestions(text, 0); + + Assert.AreSame(AutoCompleteResult.Empty, result); + } + + [Test] + public async Task ReturnsEmptyAutoCompleteResultWhenSourceThrowsException() + { + var autoCompleteSource = Substitute.For(); + autoCompleteSource.GetSuggestions().Returns(Observable.Throw(new Exception("FAIL!"))); + autoCompleteSource.Prefix.Returns("@"); + + var advisor = new AutoCompleteAdvisor(new[] { autoCompleteSource }); + + var result = await advisor.GetAutoCompletionSuggestions("@", 1); + + Assert.AreSame(AutoCompleteResult.Empty, result); + } + } + } +}