File: BackgroundWorkIndicator\BackgroundWorkIndicatorContext.cs
Web Access
Project: src\src\EditorFeatures\Core.Wpf\Microsoft.CodeAnalysis.EditorFeatures.Wpf_2jb51xel_wpftmp.csproj (Microsoft.CodeAnalysis.EditorFeatures.Wpf)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
 
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.Collections;
using Microsoft.CodeAnalysis.Editor.Shared.Extensions;
using Microsoft.CodeAnalysis.Editor.Shared.Utilities;
using Microsoft.VisualStudio.Text;
using Microsoft.VisualStudio.Text.Adornments;
using Microsoft.VisualStudio.Text.Editor;
using Microsoft.VisualStudio.Utilities;
using Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis.Editor.BackgroundWorkIndicator;
 
internal partial class WpfBackgroundWorkIndicatorFactory
{
    /// <summary>
    /// Implementation of an <see cref="IUIThreadOperationContext"/> for the background work indicator.
    /// </summary>
    private sealed class BackgroundWorkIndicatorContext : IBackgroundWorkIndicatorContext
    {
        /// <summary>
        /// What sort of UI update request we've enqueued to <see cref="_uiUpdateQueue"/>.  This effectively is just a
        /// boolean, but with clearer names to make it obvious what is going on.
        /// </summary>
        private enum UIUpdateRequest
        {
            UpdateTooltip,
            DismissTooltip,
        }
 
        /// <summary>
        /// Cancellation token exposed to clients through <see cref="UserCancellationToken"/>.
        /// </summary>
        private readonly CancellationTokenSource _cancellationTokenSource = new();
 
        /// <summary>
        /// Lock controlling mutation of all data (except <see cref="_dismissed"/>) in this indicator, or in any
        /// sub-scopes. Any read/write of mutable data must be protected by this.
        /// </summary>
        public readonly object Gate = new();
 
        private readonly WpfBackgroundWorkIndicatorFactory _factory;
        private readonly ITextView _textView;
        private readonly ITextBuffer _subjectBuffer;
        private readonly IToolTipPresenter _toolTipPresenter;
        private readonly ITrackingSpan _trackingSpan;
        private readonly string _firstDescription;
 
        /// <summary>
        /// Work queue used to batch up UI update and Dispose requests.  A value of <see langword="true"/> means
        /// just update the tool-tip. A value of <see langword="false"/> means we want to dismiss the tool-tip.
        /// </summary>
        private readonly AsyncBatchingWorkQueue<UIUpdateRequest> _uiUpdateQueue;
 
        /// <summary>
        /// Set of scopes we have.  We always start with one (the one created by the initial call to create the work
        /// indicator). However, the client of the background indicator can add more.
        /// </summary>
        private ImmutableArray<BackgroundWorkIndicatorScope> _scopes = ImmutableArray<BackgroundWorkIndicatorScope>.Empty;
 
        /// <summary>
        /// If we've been dismissed or not.  Once dismissed, we will close the tool-tip showing information.  This
        /// field must only be accessed on the UI thread.
        /// </summary>
        private bool _dismissed = false;
 
        private IThreadingContext ThreadingContext => _factory._threadingContext;
 
        public PropertyCollection Properties { get; } = new();
        public CancellationToken UserCancellationToken => _cancellationTokenSource.Token;
        public IEnumerable<IUIThreadOperationScope> Scopes => _scopes;
 
        private bool _cancelOnEdit_DoNotAccessDirectly;
        private bool _cancelOnFocusLost_DoNotAccessDirectly;
 
        public BackgroundWorkIndicatorContext(
            WpfBackgroundWorkIndicatorFactory factory,
            ITextView textView,
            SnapshotSpan applicableToSpan,
            string firstDescription,
            bool cancelOnEdit,
            bool cancelOnFocusLost)
        {
            _factory = factory;
            _textView = textView;
            _subjectBuffer = applicableToSpan.Snapshot.TextBuffer;
 
            _cancelOnEdit_DoNotAccessDirectly = cancelOnEdit;
            _cancelOnFocusLost_DoNotAccessDirectly = cancelOnFocusLost;
 
            // Create a tool-tip at the requested position.  Turn off all default behavior for it.  We'll be
            // controlling everything ourselves.
            _toolTipPresenter = factory._toolTipPresenterFactory.Create(textView, new ToolTipParameters(
                trackMouse: false,
                ignoreBufferChange: true,
                keepOpenFunc: null,
                ignoreCaretPositionChange: true,
                dismissWhenOffscreen: false));
 
            _trackingSpan = applicableToSpan.CreateTrackingSpan(SpanTrackingMode.EdgeInclusive);
 
            _firstDescription = firstDescription;
 
            _uiUpdateQueue = new AsyncBatchingWorkQueue<UIUpdateRequest>(
                DelayTimeSpan.Short,
                UpdateUIAsync,
                EqualityComparer<UIUpdateRequest>.Default,
                factory._listener,
                this.ThreadingContext.DisposalToken);
 
            _toolTipPresenter.Dismissed += OnToolTipPresenterDismissed;
            _subjectBuffer.Changed += OnTextBufferChanged;
            textView.LostAggregateFocus += OnTextViewLostAggregateFocus;
        }
 
        public void Dispose()
            => _uiUpdateQueue.AddWork(UIUpdateRequest.DismissTooltip);
 
        /// <summary>
        /// Called after anyone consuming us makes a change that should be reflected in the UI.
        /// </summary>
        internal void EnqueueUIUpdate()
            => _uiUpdateQueue.AddWork(UIUpdateRequest.UpdateTooltip);
 
        /// <summary>
        /// The same as Dispose.  Anyone taking ownership of this context wants to show their own UI, so we can just
        /// close ours.
        /// </summary>
        public void TakeOwnership()
            => this.Dispose();
 
        private void OnTextBufferChanged(object? sender, TextContentChangedEventArgs e)
        {
            if (CancelOnEdit)
                CancelAndDispose();
        }
 
        private void OnTextViewLostAggregateFocus(object? sender, EventArgs e)
        {
            if (CancelOnFocusLost)
                CancelAndDispose();
        }
 
        private void OnToolTipPresenterDismissed(object sender, EventArgs e)
            => CancelAndDispose();
 
        public void CancelAndDispose()
        {
            _cancellationTokenSource.Cancel();
            this.Dispose();
        }
 
        public bool CancelOnEdit
        {
            get
            {
                lock (Gate)
                    return _cancelOnEdit_DoNotAccessDirectly;
            }
 
            set
            {
                lock (Gate)
                    _cancelOnEdit_DoNotAccessDirectly = value;
            }
        }
 
        public bool CancelOnFocusLost
        {
            get
            {
                lock (Gate)
                    return _cancelOnFocusLost_DoNotAccessDirectly;
            }
 
            set
            {
                lock (Gate)
                    _cancelOnFocusLost_DoNotAccessDirectly = value;
            }
        }
 
        private ValueTask UpdateUIAsync(ImmutableSegmentedList<UIUpdateRequest> requests, CancellationToken cancellationToken)
        {
            Contract.ThrowIfTrue(requests.IsDefault || requests.IsEmpty, "We must have gotten an actual request to process.");
            Contract.ThrowIfTrue(requests.Count > 2, "At most we can have two requests in the queue (one to update, one to dismiss).");
            Contract.ThrowIfFalse(
                requests.Contains(UIUpdateRequest.DismissTooltip) || requests.Contains(UIUpdateRequest.UpdateTooltip),
                "We didn't get an actual event we know about.");
 
            return requests.Contains(UIUpdateRequest.DismissTooltip)
                ? DismissUIAsync()
                : UpdateUIAsync();
 
            async ValueTask DismissUIAsync()
            {
                await this.ThreadingContext.JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken);
 
                // Ensure we only dismiss once.
                if (_dismissed)
                    return;
 
                _dismissed = true;
 
                // Unhook any event handlers we've setup.
                //
                // note we have to disconnect from dismissal notifications so our own dismiss below doesn't cause us to
                // re-enter and cancel ourselves.
                _toolTipPresenter.Dismissed -= OnToolTipPresenterDismissed;
                _subjectBuffer.Changed -= OnTextBufferChanged;
                _textView.LostAggregateFocus -= OnTextViewLostAggregateFocus;
 
                // Finally, dismiss the actual tool-tip.
                _toolTipPresenter.Dismiss();
 
                // Let our factory know that we were disposed so it can let go of us as well.
                _factory.OnContextDisposed(this);
            }
 
            async ValueTask UpdateUIAsync()
            {
                // Build the current description in the background, then switch to the UI thread to actually update the
                // tool-tip with it.
                var data = this.BuildData();
 
                await this.ThreadingContext.JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken);
 
                // If we've been dismissed already, then no point in continuing.
                if (_dismissed)
                    return;
 
                // Todo: build a richer tool-tip that makes use of things like the progress reported, and perhaps has a
                // close button.
                _toolTipPresenter.StartOrUpdate(
                    _trackingSpan, [string.Format(EditorFeaturesResources._0_Esc_to_cancel, data.description)]);
            }
        }
 
        public IUIThreadOperationScope AddScope(bool allowCancellation, string description)
        {
            var scope = new BackgroundWorkIndicatorScope(this, description);
            lock (this.Gate)
            {
                _scopes = _scopes.Add(scope);
            }
 
            // We changed.  Enqueue work to make sure the UI reflects this.
            this.EnqueueUIUpdate();
            return scope;
        }
 
        public void RemoveScope(BackgroundWorkIndicatorScope scope)
        {
            lock (this.Gate)
            {
                Contract.ThrowIfFalse(_scopes.Contains(scope));
                _scopes = _scopes.Remove(scope);
            }
 
            // We changed.  Enqueue work to make sure the UI reflects this.
            this.EnqueueUIUpdate();
        }
 
        private (string description, ProgressInfo progressInfo) BuildData()
        {
            lock (Gate)
            {
                var description = _firstDescription;
                var progressInfo = new ProgressInfo();
 
                foreach (var scope in _scopes)
                {
                    var scopeData = scope.ReadData_MustBeCalledUnderLock();
 
                    // use the description of the last scope if we have one.  We don't have enough room to show all
                    // the descriptions at once.
                    description = scopeData.description;
 
                    var scopeProgressInfo = scopeData.progressInfo;
                    progressInfo = new ProgressInfo(
                        progressInfo.CompletedItems + scopeProgressInfo.CompletedItems,
                        progressInfo.TotalItems + scopeProgressInfo.TotalItems);
                }
 
                return (description, progressInfo);
            }
        }
 
        string IUIThreadOperationContext.Description => BuildData().description;
 
        bool IUIThreadOperationContext.AllowCancellation => true;
    }
}