From eaee9437287205b3dea857e6d051eb3b283d1a3d Mon Sep 17 00:00:00 2001 From: HelloThisWorld Date: Sat, 1 Aug 2026 07:12:37 +0800 Subject: [PATCH 1/5] feat: add Rainbow Arc Weld composition renderer --- .../VisualProgress/RainbowArcRenderer.h | 2060 +++++++++++++++++ .../RainbowArcVisualConstants.h | 95 + .../VisualProgressRenderModel.h | 668 ++++++ 3 files changed, 2823 insertions(+) create mode 100644 src/winterm/VisualProgress/RainbowArcRenderer.h create mode 100644 src/winterm/VisualProgress/RainbowArcVisualConstants.h create mode 100644 src/winterm/VisualProgress/VisualProgressRenderModel.h diff --git a/src/winterm/VisualProgress/RainbowArcRenderer.h b/src/winterm/VisualProgress/RainbowArcRenderer.h new file mode 100644 index 000000000..d149e6eb3 --- /dev/null +++ b/src/winterm/VisualProgress/RainbowArcRenderer.h @@ -0,0 +1,2060 @@ +// Copyright (c) winTerm contributors. +// Licensed under the MIT license. + +#pragma once + +#include "RainbowArcVisualConstants.h" +#include "VisualProgressRenderModel.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace winTerm::VisualProgress +{ + namespace WU = winrt::Windows::UI; + namespace WUC = winrt::Windows::UI::Composition; + namespace WUCore = winrt::Windows::UI::Core; + namespace WUVM = winrt::Windows::UI::ViewManagement; + namespace WUX = winrt::Windows::UI::Xaml; + namespace WUXA = winrt::Windows::UI::Xaml::Automation; + namespace WUXAP = winrt::Windows::UI::Xaml::Automation::Peers; + namespace WUXC = winrt::Windows::UI::Xaml::Controls; + namespace WUXH = winrt::Windows::UI::Xaml::Hosting; + namespace WUXM = winrt::Windows::UI::Xaml::Media; + namespace Numerics = winrt::Windows::Foundation::Numerics; + + // A fail-open, pane-local composition renderer. It owns no CPU frame loop + // and no timer. Continuous motion, comet travel, breathing, fades, and + // particle movement are all executed by Windows.UI.Composition. + // + // TryCreate and all public mutators must be called on the XAML UI thread. + // Any failure degrades decorative output without escaping to terminal code. + class RainbowArcRenderer final : public std::enable_shared_from_this + { + public: + static std::shared_ptr TryCreate(const winrt::Windows::UI::Xaml::Controls::Grid& host) noexcept + { + if (!host) + { + return nullptr; + } + + try + { + auto renderer = std::shared_ptr{ new RainbowArcRenderer{} }; + if (!renderer->_initialize(host)) + { + return nullptr; + } + renderer->_subscribeEvents(); + renderer->RefreshEnvironment(); + return renderer; + } + catch (...) + { + return nullptr; + } + } + + ~RainbowArcRenderer() + { + Close(); + } + + RainbowArcRenderer(const RainbowArcRenderer&) = delete; + RainbowArcRenderer& operator=(const RainbowArcRenderer&) = delete; + + void Apply(const ProgressSnapshot& snapshot) noexcept + { + if (_closed || _faulted) + { + return; + } + + ++_presentationGeneration; + _terminalPresentationCompleted = false; + _snapshot = snapshot; + _refreshRuntimeEnvironment(); + const auto now = _now(); + auto plan = _renderState.Apply(snapshot, _environment, now); + _applyWithDegradation(plan, true); + } + + void SetPaneActive(const bool active) noexcept + { + if (_closed || _faulted || _environment.paneActive == active) + { + return; + } + + _environment.paneActive = active; + const auto plan = _renderState.RefreshEnvironment(_environment, _now()); + _applyWithDegradation(plan, false); + } + + void RefreshEnvironment() noexcept + { + if (_closed || _faulted) + { + return; + } + + _refreshRuntimeEnvironment(); + const auto plan = _renderState.RefreshEnvironment(_environment, _now()); + _applyWithDegradation(plan, false); + } + + void Close() noexcept + { + if (_closed) + { + return; + } + _closed = true; + + try + { + _unsubscribeEvents(); + _stopAllAnimations(); + _releaseAllSparks(); + _clearTerminalBatch(); + _renderState.Close(); + + if (_host) + { + try + { + winrt::Windows::UI::Xaml::Hosting::ElementCompositionPreview::SetElementChildVisual( + _host, + winrt::Windows::UI::Composition::Visual{ nullptr }); + } + catch (...) + { + } + _removeFallbackVisuals(); + } + } + catch (...) + { + } + + _releaseCompositionHandles(); + _host = nullptr; + _coreWindow = nullptr; + _accessibilitySettings = nullptr; + _uiSettings = nullptr; + } + + bool Faulted() const noexcept + { + return _faulted; + } + + RenderTier Tier() const noexcept + { + return _renderState.Tier(); + } + + bool UsesStaticFallback() const noexcept + { + return _environment.UsesStaticFallback() || _renderState.Tier() >= RenderTier::StaticGradient; + } + + uint8_t LiveSparkCount() const noexcept + { + return _sparkPool.Live(); + } + + static uint8_t SharedLiveSparkCount() noexcept + { + return _sharedSparkBudget.Live(); + } + + private: + struct SparkVisual + { + WUC::SpriteVisual visual{ nullptr }; + WUC::CompositionColorBrush brush{ nullptr }; + WUC::Vector3KeyFrameAnimation ambientMovement{ nullptr }; + WUC::ScalarKeyFrameAnimation ambientOpacity{ nullptr }; + WUC::ColorKeyFrameAnimation ambientColor{ nullptr }; + WUC::Vector3KeyFrameAnimation burstMovement{ nullptr }; + WUC::ScalarKeyFrameAnimation burstOpacity{ nullptr }; + WUC::ColorKeyFrameAnimation burstColor{ nullptr }; + WUC::CompositionScopedBatch batch{ nullptr }; + winrt::event_token completedToken{}; + bool completedSubscribed{}; + std::optional handle; + bool ambient{}; + }; + + RainbowArcRenderer() noexcept : + _sparkPool{ _sharedSparkBudget } + { + } + + static RenderTimestamp _now() noexcept + { + return std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()); + } + + static winrt::Windows::Foundation::TimeSpan _timeSpan(const std::chrono::milliseconds value) noexcept + { + return winrt::Windows::Foundation::TimeSpan{ value }; + } + + static WU::Color _color(const uint32_t argb) noexcept + { + return WU::ColorHelper::FromArgb( + static_cast((argb >> 24u) & 0xffu), + static_cast((argb >> 16u) & 0xffu), + static_cast((argb >> 8u) & 0xffu), + static_cast(argb & 0xffu)); + } + + static WU::Color _withAlpha(WU::Color value, const uint8_t alpha) noexcept + { + value.A = alpha; + return value; + } + + bool _initialize(const WUXC::Grid& host) noexcept + { + _host = host; + _environment.featureEnabled = true; + _environment.rendererAvailable = true; + + try + { + _host.IsHitTestVisible(false); + WUXA::AutomationProperties::SetAccessibilityView(_host, WUXAP::AccessibilityView::Raw); + _initializeSolidFallback(); + } + catch (...) + { + _renderState.Tier(RenderTier::Disabled); + _faulted = true; + return false; + } + + _renderState.Tier(RenderTier::Solid); + + try + { + _uiSettings = WUVM::UISettings{}; + _accessibilitySettings = WUVM::AccessibilitySettings{}; + } + catch (...) + { + // Static solid fallback does not depend on either object. + } + + try + { + _initializeCompositionBase(); + _renderState.Tier(RenderTier::Solid); + } + catch (...) + { + _releaseCompositionHandles(); + _showFallback(true); + return true; + } + + try + { + _initializeGradientStage(); + _renderState.Tier(RenderTier::StaticGradient); + } + catch (...) + { + _renderState.Tier(RenderTier::Solid); + _detachCompositionForFallback(); + return true; + } + + try + { + _initializeHeadStage(); + _renderState.Tier(RenderTier::NoSparks); + } + catch (...) + { + _renderState.Tier(RenderTier::StaticGradient); + return true; + } + + try + { + _initializeSparkStage(); + _renderState.Tier(RenderTier::Full); + } + catch (...) + { + _releaseAllSparks(); + _renderState.Tier(RenderTier::NoSparks); + } + + _updateGeometry(); + _showFallback(false); + return true; + } + + void _initializeSolidFallback() + { + _fallbackTrack = WUXC::Border{}; + _fallbackFill = WUXC::Border{}; + _fallbackTrackBrush = WUXM::SolidColorBrush{ _trackColor }; + _fallbackFillBrush = WUXM::SolidColorBrush{ _runningColor }; + + const auto radius = WUX::CornerRadiusHelper::FromUniformRadius(RainbowArcVisualConstants::TrackCornerRadius); + const auto margin = WUX::ThicknessHelper::FromLengths( + RainbowArcVisualConstants::HorizontalInset, + 0.0, + RainbowArcVisualConstants::HorizontalInset, + RainbowArcVisualConstants::BottomInset); + + _fallbackTrack.Height(RainbowArcVisualConstants::TrackHeight); + _fallbackTrack.Margin(margin); + _fallbackTrack.VerticalAlignment(WUX::VerticalAlignment::Bottom); + _fallbackTrack.HorizontalAlignment(WUX::HorizontalAlignment::Stretch); + _fallbackTrack.CornerRadius(radius); + _fallbackTrack.IsHitTestVisible(false); + _fallbackTrack.Background(_fallbackTrackBrush); + + _fallbackFill.Height(RainbowArcVisualConstants::TrackHeight); + _fallbackFill.Margin(margin); + _fallbackFill.VerticalAlignment(WUX::VerticalAlignment::Bottom); + _fallbackFill.HorizontalAlignment(WUX::HorizontalAlignment::Left); + _fallbackFill.CornerRadius(radius); + _fallbackFill.IsHitTestVisible(false); + _fallbackFill.Width(0.0); + _fallbackFill.Background(_fallbackFillBrush); + + _host.Children().Append(_fallbackTrack); + _host.Children().Append(_fallbackFill); + _showFallback(true); + } + + void _initializeCompositionBase() + { + const auto elementVisual = WUXH::ElementCompositionPreview::GetElementVisual(_host); + _compositor = elementVisual.Compositor(); + _root = _compositor.CreateContainerVisual(); + + _trackBrush = _compositor.CreateColorBrush(_color(RainbowArcVisualConstants::DarkTrack)); + _statusBrush = _compositor.CreateColorBrush(_color(RainbowArcVisualConstants::RunningSolid)); + + _trackGeometry = _compositor.CreateRoundedRectangleGeometry(); + _trackShape = _compositor.CreateSpriteShape(_trackGeometry); + _trackShape.FillBrush(_trackBrush); + _trackVisual = _compositor.CreateShapeVisual(); + _trackVisual.Shapes().Append(_trackShape); + + _solidFillGeometry = _compositor.CreateRoundedRectangleGeometry(); + _solidFillShape = _compositor.CreateSpriteShape(_solidFillGeometry); + _solidFillShape.FillBrush(_statusBrush); + _solidFillVisual = _compositor.CreateShapeVisual(); + _solidFillVisual.Shapes().Append(_solidFillShape); + + _fillBoundary = _compositor.CreateContainerVisual(); + _fillInsetClip = _compositor.CreateInsetClip(); + _fillBoundary.Clip(_fillInsetClip); + _fillBoundary.Children().InsertAtTop(_solidFillVisual); + + _root.Children().InsertAtTop(_trackVisual); + _root.Children().InsertAtTop(_fillBoundary); + WUXH::ElementCompositionPreview::SetElementChildVisual(_host, _root); + } + + void _initializeGradientStage() + { + _rainbowBrush = _createRainbowBrush(); + _rainbowFillGeometry = _compositor.CreateRoundedRectangleGeometry(); + _rainbowFillShape = _compositor.CreateSpriteShape(_rainbowFillGeometry); + _rainbowFillShape.FillBrush(_rainbowBrush); + _rainbowFillVisual = _compositor.CreateShapeVisual(); + _rainbowFillVisual.Shapes().Append(_rainbowFillShape); + _fillBoundary.Children().InsertAtTop(_rainbowFillVisual); + + _cometBrush = _createCometBrush(); + _cometSolidBrush = _compositor.CreateColorBrush(_runningColor); + _cometTail = _compositor.CreateSpriteVisual(); + _cometTail.Brush(_cometBrush); + _cometContainer = _compositor.CreateContainerVisual(); + _cometClipGeometry = _compositor.CreateRoundedRectangleGeometry(); + _cometClip = _compositor.CreateGeometricClip(_cometClipGeometry); + _cometContainer.Clip(_cometClip); + _cometContainer.Children().InsertAtTop(_cometTail); + _cometContainer.IsVisible(false); + _root.Children().InsertAtTop(_cometContainer); + + _successSweep = _compositor.CreateSpriteVisual(); + _successSweep.Brush(_compositor.CreateColorBrush(_withAlpha(_color(RainbowArcVisualConstants::WhiteHot), 215))); + _successSweep.Opacity(0.0f); + _fillBoundary.Children().InsertAtTop(_successSweep); + + _rainbowMovementAnimation = _compositor.CreateVector2KeyFrameAnimation(); + _rainbowMovementAnimation.InsertKeyFrame(0.0f, { 0.0f, 0.0f }); + _rainbowMovementAnimation.InsertKeyFrame(1.0f, { 1.0f, 0.0f }); + _rainbowMovementAnimation.Duration(_timeSpan(RainbowArcVisualConstants::RainbowCycleDuration)); + _rainbowMovementAnimation.IterationBehavior(WUC::AnimationIterationBehavior::Forever); + + _fillProgressAnimation = _compositor.CreateScalarKeyFrameAnimation(); + _headProgressAnimation = _compositor.CreateVector3KeyFrameAnimation(); + _cometTailAnimation = _compositor.CreateVector3KeyFrameAnimation(); + _cometHeadAnimation = _compositor.CreateVector3KeyFrameAnimation(); + _cometHeadOpacityAnimation = _compositor.CreateScalarKeyFrameAnimation(); + _successSweepMovementAnimation = _compositor.CreateVector3KeyFrameAnimation(); + _successSweepOpacityAnimation = _compositor.CreateScalarKeyFrameAnimation(); + _successTintAnimation = _compositor.CreateScalarKeyFrameAnimation(); + } + + void _initializeHeadStage() + { + _headRoot = _compositor.CreateContainerVisual(); + _headRoot.IsVisible(false); + + _outerBloomBrush = _createRadialBrush(_withAlpha(_color(RainbowArcVisualConstants::RainbowMagenta), 100), 0); + _innerGlowBrush = _createRadialBrush(_withAlpha(_color(RainbowArcVisualConstants::RainbowCyan), 205), 0); + _headTrailBrush = _createTrailBrush(); + _errorOuterBloomBrush = _createRadialBrush(_withAlpha(_color(RainbowArcVisualConstants::ErrorSolid), 115), 0); + _errorInnerGlowBrush = _createRadialBrush(_withAlpha(_color(RainbowArcVisualConstants::ErrorSolid), 220), 0); + _errorTrailBrush = _createStatusTrailBrush(_color(RainbowArcVisualConstants::ErrorSolid)); + + _outerBloom = _compositor.CreateSpriteVisual(); + _outerBloom.Brush(_outerBloomBrush); + _outerBloom.Size({ RainbowArcVisualConstants::OuterBloomWidth, RainbowArcVisualConstants::OuterBloomHeight }); + _outerBloom.Offset({ -RainbowArcVisualConstants::OuterBloomWidth / 2.0f, -RainbowArcVisualConstants::OuterBloomHeight / 2.0f, 0.0f }); + + _innerGlow = _compositor.CreateSpriteVisual(); + _innerGlow.Brush(_innerGlowBrush); + _innerGlow.Size({ RainbowArcVisualConstants::InnerGlowWidth, RainbowArcVisualConstants::InnerGlowHeight }); + _innerGlow.Offset({ -RainbowArcVisualConstants::InnerGlowWidth / 2.0f, -RainbowArcVisualConstants::InnerGlowHeight / 2.0f, 0.0f }); + + _headTrail = _compositor.CreateSpriteVisual(); + _headTrail.Brush(_headTrailBrush); + _headTrail.Size({ RainbowArcVisualConstants::HeadTrailWidth, RainbowArcVisualConstants::TrackHeight }); + _headTrail.Offset({ -RainbowArcVisualConstants::HeadTrailWidth, -RainbowArcVisualConstants::TrackHeight / 2.0f, 0.0f }); + + _warmCore = _compositor.CreateSpriteVisual(); + _warmCore.Brush(_compositor.CreateColorBrush(_color(RainbowArcVisualConstants::WarmWhite))); + _warmCore.Size({ RainbowArcVisualConstants::WarmCoreWidth, RainbowArcVisualConstants::TrackHeight }); + _warmCore.Offset({ -RainbowArcVisualConstants::WarmCoreWidth / 2.0f, -RainbowArcVisualConstants::TrackHeight / 2.0f, 0.0f }); + + _whiteCore = _compositor.CreateSpriteVisual(); + _whiteCore.Brush(_compositor.CreateColorBrush(_color(RainbowArcVisualConstants::WhiteHot))); + _whiteCore.Size({ RainbowArcVisualConstants::WhiteCoreWidth, RainbowArcVisualConstants::TrackHeight }); + _whiteCore.Offset({ -RainbowArcVisualConstants::WhiteCoreWidth / 2.0f, -RainbowArcVisualConstants::TrackHeight / 2.0f, 0.0f }); + + _headRoot.Children().InsertAtTop(_outerBloom); + _headRoot.Children().InsertAtTop(_innerGlow); + _headRoot.Children().InsertAtTop(_headTrail); + _headRoot.Children().InsertAtTop(_warmCore); + _headRoot.Children().InsertAtTop(_whiteCore); + _root.Children().InsertAtTop(_headRoot); + + _waitingBreatheAnimation = _compositor.CreateScalarKeyFrameAnimation(); + _waitingBreatheAnimation.InsertKeyFrame(0.0f, 0.55f); + _waitingBreatheAnimation.InsertKeyFrame(0.5f, 1.0f); + _waitingBreatheAnimation.InsertKeyFrame(1.0f, 0.55f); + _waitingBreatheAnimation.Duration(_timeSpan(RainbowArcVisualConstants::WaitingBreatheDuration)); + _waitingBreatheAnimation.IterationBehavior(WUC::AnimationIterationBehavior::Forever); + + _regressionDipAnimation = _compositor.CreateScalarKeyFrameAnimation(); + _regressionDipAnimation.InsertKeyFrame(0.0f, 1.0f); + _regressionDipAnimation.InsertKeyFrame(0.45f, 0.32f); + _regressionDipAnimation.InsertKeyFrame(1.0f, 1.0f); + _regressionDipAnimation.Duration(_timeSpan(RainbowArcVisualConstants::RegressionOpacityDipDuration)); + + _errorPulseAnimation = _compositor.CreateScalarKeyFrameAnimation(); + _errorPulseAnimation.InsertKeyFrame(0.0f, 1.0f); + _errorPulseAnimation.InsertKeyFrame(0.45f, 0.42f); + _errorPulseAnimation.InsertKeyFrame(1.0f, 1.0f); + _errorPulseAnimation.Duration(_timeSpan(RainbowArcVisualConstants::ErrorPulseDuration)); + + _successHeadScaleAnimation = _compositor.CreateVector3KeyFrameAnimation(); + _successHeadScaleAnimation.InsertKeyFrame(0.0f, { 1.0f, 1.0f, 1.0f }); + _successHeadScaleAnimation.InsertKeyFrame(0.55f, { 1.32f, 1.32f, 1.0f }); + _successHeadScaleAnimation.InsertKeyFrame(1.0f, { 1.0f, 1.0f, 1.0f }); + _successHeadScaleAnimation.Duration(_timeSpan(RainbowArcVisualConstants::SuccessIntensifyDuration)); + + _terminalFadeAnimation = _compositor.CreateScalarKeyFrameAnimation(); + } + + void _initializeSparkStage() + { + for (std::size_t index = 0; index < _sparks.size(); ++index) + { + auto& spark = _sparks[index]; + spark.brush = _compositor.CreateColorBrush(_color(RainbowArcVisualConstants::WhiteHot)); + spark.visual = _compositor.CreateSpriteVisual(); + spark.visual.Brush(spark.brush); + const auto size = index + 1 == _sparks.size() ? + RainbowArcVisualConstants::MaximumSparkSize : + (index % 3 == 0 ? 2.0f : RainbowArcVisualConstants::TypicalSparkSize); + spark.visual.Size({ size, size }); + spark.visual.Opacity(0.0f); + _headRoot.Children().InsertAtTop(spark.visual); + + spark.ambientMovement = _compositor.CreateVector3KeyFrameAnimation(); + spark.ambientOpacity = _compositor.CreateScalarKeyFrameAnimation(); + spark.ambientColor = _compositor.CreateColorKeyFrameAnimation(); + spark.burstMovement = _compositor.CreateVector3KeyFrameAnimation(); + spark.burstOpacity = _compositor.CreateScalarKeyFrameAnimation(); + spark.burstColor = _compositor.CreateColorKeyFrameAnimation(); + + spark.ambientOpacity.InsertKeyFrame(0.0f, 0.0f); + spark.ambientOpacity.InsertKeyFrame(0.01f, 1.0f); + spark.ambientOpacity.InsertKeyFrame(0.13f, 0.0f); + spark.ambientOpacity.InsertKeyFrame(1.0f, 0.0f); + spark.ambientColor.InsertKeyFrame(0.0f, _color(RainbowArcVisualConstants::WhiteHot)); + spark.ambientColor.InsertKeyFrame(0.04f, _color(RainbowArcVisualConstants::SparkYellow)); + spark.ambientColor.InsertKeyFrame(0.09f, _color(RainbowArcVisualConstants::SparkOrange)); + spark.ambientColor.InsertKeyFrame(0.13f, _withAlpha(_color(RainbowArcVisualConstants::SparkOrange), 0)); + spark.ambientColor.InsertKeyFrame(1.0f, _withAlpha(_color(RainbowArcVisualConstants::SparkOrange), 0)); + + spark.burstOpacity.InsertKeyFrame(0.0f, 1.0f); + spark.burstOpacity.InsertKeyFrame(0.72f, 0.72f); + spark.burstOpacity.InsertKeyFrame(1.0f, 0.0f); + spark.burstColor.InsertKeyFrame(0.0f, _color(RainbowArcVisualConstants::WhiteHot)); + spark.burstColor.InsertKeyFrame(0.32f, _color(RainbowArcVisualConstants::SparkYellow)); + spark.burstColor.InsertKeyFrame(0.72f, _color(RainbowArcVisualConstants::SparkOrange)); + spark.burstColor.InsertKeyFrame(1.0f, _withAlpha(_color(RainbowArcVisualConstants::SparkOrange), 0)); + } + } + + WUC::CompositionLinearGradientBrush _createRainbowBrush() + { + auto brush = _compositor.CreateLinearGradientBrush(); + brush.MappingMode(WUC::CompositionMappingMode::Relative); + brush.ExtendMode(WUC::CompositionGradientExtendMode::Wrap); + brush.StartPoint({ 0.0f, 0.5f }); + brush.EndPoint({ 1.0f, 0.5f }); + brush.Scale({ 0.5f, 1.0f }); + const std::array colors{ + RainbowArcVisualConstants::RainbowRed, + RainbowArcVisualConstants::RainbowOrange, + RainbowArcVisualConstants::RainbowYellow, + RainbowArcVisualConstants::RainbowGreen, + RainbowArcVisualConstants::RainbowCyan, + RainbowArcVisualConstants::RainbowBlue, + RainbowArcVisualConstants::RainbowViolet, + RainbowArcVisualConstants::RainbowMagenta, + RainbowArcVisualConstants::RainbowRed, + }; + for (std::size_t index = 0; index < colors.size(); ++index) + { + const auto offset = static_cast(index) / static_cast(colors.size() - 1); + brush.ColorStops().Append(_compositor.CreateColorGradientStop(offset, _color(colors[index]))); + } + return brush; + } + + WUC::CompositionLinearGradientBrush _createCometBrush() + { + auto brush = _compositor.CreateLinearGradientBrush(); + brush.MappingMode(WUC::CompositionMappingMode::Relative); + brush.StartPoint({ 0.0f, 0.5f }); + brush.EndPoint({ 1.0f, 0.5f }); + brush.ColorStops().Append(_compositor.CreateColorGradientStop(0.0f, _withAlpha(_color(RainbowArcVisualConstants::RainbowViolet), 0))); + brush.ColorStops().Append(_compositor.CreateColorGradientStop(0.28f, _withAlpha(_color(RainbowArcVisualConstants::RainbowBlue), 80))); + brush.ColorStops().Append(_compositor.CreateColorGradientStop(0.58f, _withAlpha(_color(RainbowArcVisualConstants::RainbowCyan), 180))); + brush.ColorStops().Append(_compositor.CreateColorGradientStop(0.82f, _color(RainbowArcVisualConstants::RainbowYellow))); + brush.ColorStops().Append(_compositor.CreateColorGradientStop(1.0f, _color(RainbowArcVisualConstants::RainbowMagenta))); + return brush; + } + + WUC::CompositionLinearGradientBrush _createTrailBrush() + { + auto brush = _compositor.CreateLinearGradientBrush(); + brush.MappingMode(WUC::CompositionMappingMode::Relative); + brush.StartPoint({ 0.0f, 0.5f }); + brush.EndPoint({ 1.0f, 0.5f }); + brush.ColorStops().Append(_compositor.CreateColorGradientStop(0.0f, _withAlpha(_color(RainbowArcVisualConstants::RainbowCyan), 0))); + brush.ColorStops().Append(_compositor.CreateColorGradientStop(0.62f, _withAlpha(_color(RainbowArcVisualConstants::RainbowCyan), 95))); + brush.ColorStops().Append(_compositor.CreateColorGradientStop(1.0f, _withAlpha(_color(RainbowArcVisualConstants::WarmWhite), 220))); + return brush; + } + + WUC::CompositionLinearGradientBrush _createStatusTrailBrush(const WU::Color color) + { + auto brush = _compositor.CreateLinearGradientBrush(); + brush.MappingMode(WUC::CompositionMappingMode::Relative); + brush.StartPoint({ 0.0f, 0.5f }); + brush.EndPoint({ 1.0f, 0.5f }); + brush.ColorStops().Append(_compositor.CreateColorGradientStop(0.0f, _withAlpha(color, 0))); + brush.ColorStops().Append(_compositor.CreateColorGradientStop(0.65f, _withAlpha(color, 110))); + brush.ColorStops().Append(_compositor.CreateColorGradientStop(1.0f, _withAlpha(color, 230))); + return brush; + } + + WUC::CompositionRadialGradientBrush _createRadialBrush(const WU::Color center, const uint8_t edgeAlpha) + { + auto brush = _compositor.CreateRadialGradientBrush(); + brush.MappingMode(WUC::CompositionMappingMode::Relative); + brush.EllipseCenter({ 0.5f, 0.5f }); + brush.EllipseRadius({ 0.5f, 0.5f }); + brush.ColorStops().Append(_compositor.CreateColorGradientStop(0.0f, center)); + brush.ColorStops().Append(_compositor.CreateColorGradientStop(0.38f, _withAlpha(center, static_cast(center.A / 2u)))); + brush.ColorStops().Append(_compositor.CreateColorGradientStop(1.0f, _withAlpha(center, edgeAlpha))); + return brush; + } + + void _subscribeEvents() + { + const auto weak = weak_from_this(); + _loadedToken = _host.Loaded([weak](auto&&, auto&&) { + if (const auto self = weak.lock()) + { + self->_environment.hostLoaded = true; + self->_environment.tabVisible = true; + self->_updateGeometry(); + self->RefreshEnvironment(); + } + }); + _loadedSubscribed = true; + + _unloadedToken = _host.Unloaded([weak](auto&&, auto&&) { + if (const auto self = weak.lock()) + { + self->_environment.hostLoaded = false; + self->_environment.tabVisible = false; + self->_pauseForIneligibleHost(); + } + }); + _unloadedSubscribed = true; + + _sizeChangedToken = _host.SizeChanged([weak](auto&&, auto&&) { + if (const auto self = weak.lock()) + { + self->_updateGeometry(); + self->RefreshEnvironment(); + } + }); + _sizeChangedSubscribed = true; + + try + { + _themeChangedToken = _host.ActualThemeChanged([weak](auto&&, auto&&) { + if (const auto self = weak.lock()) + { + self->RefreshEnvironment(); + } + }); + _themeChangedSubscribed = true; + } + catch (...) + { + } + + try + { + _coreWindow = WUCore::CoreWindow::GetForCurrentThread(); + if (_coreWindow) + { + _environment.windowVisible = _coreWindow.Visible(); + _coreActivatedToken = _coreWindow.Activated([weak](auto&&, const WUCore::WindowActivatedEventArgs& args) { + if (const auto self = weak.lock()) + { + self->_environment.windowFocused = args.WindowActivationState() != WUCore::CoreWindowActivationState::Deactivated; + self->RefreshEnvironment(); + } + }); + _coreActivatedSubscribed = true; + + _coreVisibilityToken = _coreWindow.VisibilityChanged([weak](auto&&, const WUCore::VisibilityChangedEventArgs& args) { + if (const auto self = weak.lock()) + { + self->_environment.windowVisible = args.Visible(); + self->RefreshEnvironment(); + } + }); + _coreVisibilitySubscribed = true; + } + } + catch (...) + { + } + + try + { + if (_accessibilitySettings) + { + _highContrastToken = _accessibilitySettings.HighContrastChanged([weak](auto&&, auto&&) { + if (const auto self = weak.lock()) + { + self->RefreshEnvironment(); + } + }); + _highContrastSubscribed = true; + } + } + catch (...) + { + } + + // AnimationsEnabledChanged is newer than the base UISettings API. + // Registration is opportunistic; every Apply/Refresh also queries + // AnimationsEnabled, so an unavailable event remains fail-open. + try + { + if (_uiSettings) + { + _animationsToken = _uiSettings.AnimationsEnabledChanged([weak](auto&&, auto&&) { + if (const auto self = weak.lock()) + { + self->RefreshEnvironment(); + } + }); + _animationsSubscribed = true; + } + } + catch (...) + { + } + } + + void _unsubscribeEvents() noexcept + { + try + { + if (_loadedSubscribed && _host) + { + _host.Loaded(_loadedToken); + } + if (_unloadedSubscribed && _host) + { + _host.Unloaded(_unloadedToken); + } + if (_sizeChangedSubscribed && _host) + { + _host.SizeChanged(_sizeChangedToken); + } + if (_themeChangedSubscribed && _host) + { + _host.ActualThemeChanged(_themeChangedToken); + } + if (_coreActivatedSubscribed && _coreWindow) + { + _coreWindow.Activated(_coreActivatedToken); + } + if (_coreVisibilitySubscribed && _coreWindow) + { + _coreWindow.VisibilityChanged(_coreVisibilityToken); + } + if (_highContrastSubscribed && _accessibilitySettings) + { + _accessibilitySettings.HighContrastChanged(_highContrastToken); + } + if (_animationsSubscribed && _uiSettings) + { + _uiSettings.AnimationsEnabledChanged(_animationsToken); + } + } + catch (...) + { + } + _loadedSubscribed = false; + _unloadedSubscribed = false; + _sizeChangedSubscribed = false; + _themeChangedSubscribed = false; + _coreActivatedSubscribed = false; + _coreVisibilitySubscribed = false; + _highContrastSubscribed = false; + _animationsSubscribed = false; + } + + void _refreshRuntimeEnvironment() noexcept + { + try + { + _environment.hostLoaded = _host && _host.IsLoaded(); + _environment.tabVisible = _environment.hostLoaded; + // Host.Visibility is renderer-owned and becomes Collapsed for + // Hidden snapshots. Treating it as an external pane signal + // would prevent the next visible snapshot from reopening it. + _environment.paneVisible = _environment.hostLoaded; + } + catch (...) + { + _environment.hostLoaded = false; + _environment.tabVisible = false; + _environment.paneVisible = false; + } + + try + { + if (_uiSettings) + { + _environment.animationsEnabled = _uiSettings.AnimationsEnabled(); + } + } + catch (...) + { + _environment.animationsEnabled = false; + } + + try + { + if (_accessibilitySettings) + { + _environment.highContrast = _accessibilitySettings.HighContrast(); + } + } + catch (...) + { + _environment.highContrast = true; + } + + try + { + if (_coreWindow) + { + _environment.windowVisible = _coreWindow.Visible(); + } + } + catch (...) + { + } + + _environment.rendererAvailable = _renderState.Tier() != RenderTier::Disabled; + _updatePalette(); + } + + void _updatePalette() noexcept + { + try + { + WU::Color track; + WU::Color running; + WU::Color hot = _color(RainbowArcVisualConstants::WhiteHot); + if (_environment.highContrast && _uiSettings) + { + const auto foreground = _uiSettings.GetColorValue(WUVM::UIColorType::Foreground); + track = _withAlpha(foreground, 110); + running = foreground; + hot = foreground; + } + else + { + const auto light = _isLightTheme(); + track = _color(light ? RainbowArcVisualConstants::LightTrack : RainbowArcVisualConstants::DarkTrack); + running = _color(RainbowArcVisualConstants::RunningSolid); + } + + _trackColor = track; + _runningColor = running; + _hotColor = hot; + if (_trackBrush) + { + _trackBrush.Color(track); + } + if (_whiteCore && _whiteCore.Brush()) + { + _whiteCore.Brush().as().Color(hot); + } + if (_fallbackTrack) + { + _fallbackTrackBrush.Color(track); + } + } + catch (...) + { + } + } + + bool _isLightTheme() const noexcept + { + try + { + if (_host.ActualTheme() == WUX::ElementTheme::Light) + { + return true; + } + if (_host.ActualTheme() == WUX::ElementTheme::Dark) + { + return false; + } + if (_uiSettings) + { + const auto background = _uiSettings.GetColorValue(WUVM::UIColorType::Background); + return (static_cast(background.R) * 299u + + static_cast(background.G) * 587u + + static_cast(background.B) * 114u) > 127500u; + } + } + catch (...) + { + } + return false; + } + + void _updateGeometry() noexcept + { + try + { + const auto width = std::max(0.0f, static_cast(_host.ActualWidth())); + const auto height = std::max(0.0f, static_cast(_host.ActualHeight())); + _trackWidth = std::max(0.0f, width - (2.0f * RainbowArcVisualConstants::HorizontalInset)); + _trackY = std::max(0.0f, height - RainbowArcVisualConstants::BottomInset - RainbowArcVisualConstants::TrackHeight); + _headY = _trackY + (RainbowArcVisualConstants::TrackHeight / 2.0f); + _drawable = _trackWidth >= RainbowArcVisualConstants::MinimumDrawableTrackWidth && height >= RainbowArcVisualConstants::TrackHeight; + + if (_root) + { + _root.Size({ width, height }); + _trackVisual.Size({ _trackWidth, RainbowArcVisualConstants::TrackHeight }); + _trackVisual.Offset({ RainbowArcVisualConstants::HorizontalInset, _trackY, 0.0f }); + _trackGeometry.Size({ _trackWidth, RainbowArcVisualConstants::TrackHeight }); + _trackGeometry.CornerRadius({ RainbowArcVisualConstants::TrackCornerRadius, RainbowArcVisualConstants::TrackCornerRadius }); + + _fillBoundary.Size({ _trackWidth, RainbowArcVisualConstants::TrackHeight }); + _fillBoundary.Offset({ RainbowArcVisualConstants::HorizontalInset, _trackY, 0.0f }); + _solidFillVisual.Size({ _trackWidth, RainbowArcVisualConstants::TrackHeight }); + _solidFillGeometry.Size({ _trackWidth, RainbowArcVisualConstants::TrackHeight }); + _solidFillGeometry.CornerRadius({ RainbowArcVisualConstants::TrackCornerRadius, RainbowArcVisualConstants::TrackCornerRadius }); + + if (_rainbowFillVisual) + { + _rainbowFillVisual.Size({ _trackWidth, RainbowArcVisualConstants::TrackHeight }); + _rainbowFillGeometry.Size({ _trackWidth, RainbowArcVisualConstants::TrackHeight }); + _rainbowFillGeometry.CornerRadius({ RainbowArcVisualConstants::TrackCornerRadius, RainbowArcVisualConstants::TrackCornerRadius }); + _cometContainer.Size({ _trackWidth, RainbowArcVisualConstants::TrackHeight }); + _cometContainer.Offset({ RainbowArcVisualConstants::HorizontalInset, _trackY, 0.0f }); + _cometClipGeometry.Size({ _trackWidth, RainbowArcVisualConstants::TrackHeight }); + _cometClipGeometry.CornerRadius({ RainbowArcVisualConstants::TrackCornerRadius, RainbowArcVisualConstants::TrackCornerRadius }); + _cometTail.Size({ _trackWidth * RainbowArcVisualConstants::IndeterminateTailFraction, RainbowArcVisualConstants::TrackHeight }); + _successSweep.Size({ std::max(2.0f, _trackWidth * RainbowArcVisualConstants::SuccessSweepWidthFraction), RainbowArcVisualConstants::TrackHeight }); + } + } + + _updateFallback(_renderState.CurrentProgress(_now()), _snapshot.mode, _snapshot.status); + _showFallback(_renderState.Tier() == RenderTier::Solid || + !_root || + _environment.UsesStaticFallback()); + } + catch (...) + { + _drawable = false; + } + } + + void _applyWithDegradation(RenderTransitionPlan plan, const bool semanticUpdate) noexcept + { + for (uint8_t attempt = 0; attempt < 5 && !_closed; ++attempt) + { + try + { + _render(plan, semanticUpdate); + return; + } + catch (...) + { + _stopAllAnimations(); + _releaseAllSparks(); + const auto next = NextLowerRenderTier(_renderState.Tier()); + _renderState.Tier(next); + _environment.rendererAvailable = next != RenderTier::Disabled; + if (next == RenderTier::Solid) + { + _detachCompositionForFallback(); + } + else if (next == RenderTier::Disabled) + { + _faulted = true; + _showFallback(false); + return; + } + plan = _renderState.RefreshEnvironment(_environment, _now()); + } + } + } + + void _render(const RenderTransitionPlan& plan, const bool semanticUpdate) + { + if (!_host || _renderState.Tier() == RenderTier::Disabled) + { + throw winrt::hresult_error{ winrt::hresult{ static_cast(0x80004005u) } }; + } + + // A terminal presentation owns its final hide. Theme, focus, + // visibility, and accessibility refreshes must not replay the + // retained success snapshot after that presentation has faded. + // Only Apply, which represents a new semantic snapshot, clears + // this latch. + const auto terminalPresentationInFlight = _terminalBatchSubscribed && _activeTerminalBatchGeneration != 0; + if (!semanticUpdate && (_terminalPresentationCompleted || terminalPresentationInFlight)) + { + // A refresh in the middle of the bounded terminal animation + // cannot safely rebuild its Composition batch. Complete it + // immediately and latch the hidden state instead of cancelling + // the fade and leaving a static success presentation behind. + _terminalPresentationCompleted = true; + _clearTerminalBatch(); + _pauseForIneligibleHost(); + return; + } + + const auto terminalFade = !plan.visible && + plan.kind == RenderTransitionKind::Cancelled && + plan.fadeOut && + _root && + _renderState.Tier() < RenderTier::StaticGradient; + const auto visible = (plan.visible || terminalFade) && _drawable; + if (!visible) + { + _pauseForIneligibleHost(); + return; + } + + if (_renderState.Tier() == RenderTier::Solid || !_root || _environment.UsesStaticFallback()) + { + _showFallback(true); + _updateFallback(plan.targetProgress, plan.mode, plan.status); + _releaseAllSparks(); + return; + } + + _showFallback(false); + _root.IsVisible(true); + _root.Opacity(1.0f); + _clearTerminalBatch(); + _stopStatusAnimations(); + _setErrorHeadTreatment(false); + + if (terminalFade) + { + _renderCancelled(plan); + return; + } + + if (plan.staticFallback) + { + _renderStatic(plan); + return; + } + + switch (plan.status) + { + case ProgressStatus::Waiting: + _renderWaiting(plan); + break; + case ProgressStatus::Success: + _renderSuccess(plan); + break; + case ProgressStatus::Error: + _renderError(plan); + break; + case ProgressStatus::Cancelled: + _renderCancelled(plan); + break; + case ProgressStatus::Running: + default: + if (plan.mode == ProgressMode::Indeterminate) + { + _renderIndeterminate(plan); + } + else + { + _renderDeterminate(plan); + } + break; + } + + if (plan.sparksEligible) + { + _startAmbientSparks(); + if (semanticUpdate && _snapshot.sequence != 0 && _snapshot.sequence != _lastBurstSequence && _snapshot.sequence % 13u == 0u) + { + _lastBurstSequence = _snapshot.sequence; + _emitBurst(static_cast(RainbowArcVisualConstants::StrongSparkBurstMinimum)); + } + } + else if (plan.status != ProgressStatus::Success) + { + _stopAmbientSparks(); + } + } + + void _renderStatic(const RenderTransitionPlan& plan) + { + _stopContinuousAnimations(); + _releaseAllSparks(); + if (_renderState.Tier() == RenderTier::StaticGradient && !_environment.highContrast && plan.status == ProgressStatus::Running) + { + if (plan.mode == ProgressMode::Indeterminate) + { + _showStaticComet(); + } + else + { + _showGradientFill(plan.targetProgress, false, plan.phaseReset, plan.duration); + } + return; + } + + _showSolidFill(plan.targetProgress, plan.mode, _statusColor(plan.status)); + } + + void _renderDeterminate(const RenderTransitionPlan& plan) + { + _cometContainer.IsVisible(false); + _showGradientFill(plan.targetProgress, plan.animateProgress, plan.phaseReset, plan.duration); + _setRainbowMovement(plan.rainbowMoving); + } + + void _renderIndeterminate(const RenderTransitionPlan& plan) + { + _fillBoundary.IsVisible(false); + _cometContainer.IsVisible(true); + _solidFillVisual.IsVisible(false); + _rainbowFillVisual.IsVisible(false); + _headRoot.IsVisible(true); + _setRainbowMovement(false); + + if (!plan.indeterminateMoving) + { + _showStaticComet(); + return; + } + + const auto tailWidth = _trackWidth * RainbowArcVisualConstants::IndeterminateTailFraction; + const auto startX = -tailWidth; + const auto endX = _trackWidth; + _cometTail.Brush(_cometBrush); + _cometTail.Offset({ startX, 0.0f, 0.0f }); + + _cometTailAnimation.InsertKeyFrame(0.0f, { startX, 0.0f, 0.0f }); + _cometTailAnimation.InsertKeyFrame(1.0f, { endX, 0.0f, 0.0f }); + _cometTailAnimation.Duration(_timeSpan(RainbowArcVisualConstants::IndeterminateCycleDuration)); + _cometTailAnimation.IterationBehavior(WUC::AnimationIterationBehavior::Forever); + + _cometHeadAnimation.InsertKeyFrame(0.0f, { RainbowArcVisualConstants::HorizontalInset, _headY, 0.0f }); + _cometHeadAnimation.InsertKeyFrame(1.0f, { RainbowArcVisualConstants::HorizontalInset + _trackWidth + tailWidth, _headY, 0.0f }); + _cometHeadAnimation.Duration(_timeSpan(RainbowArcVisualConstants::IndeterminateCycleDuration)); + _cometHeadAnimation.IterationBehavior(WUC::AnimationIterationBehavior::Forever); + + _cometHeadOpacityAnimation.InsertKeyFrame(0.0f, 0.0f); + _cometHeadOpacityAnimation.InsertKeyFrame(0.06f, 1.0f); + _cometHeadOpacityAnimation.InsertKeyFrame(0.91f, 1.0f); + _cometHeadOpacityAnimation.InsertKeyFrame(1.0f, 0.0f); + _cometHeadOpacityAnimation.Duration(_timeSpan(RainbowArcVisualConstants::IndeterminateCycleDuration)); + _cometHeadOpacityAnimation.IterationBehavior(WUC::AnimationIterationBehavior::Forever); + + if (!_indeterminateRunning) + { + _cometTail.StartAnimation(L"Offset", _cometTailAnimation); + _headRoot.StartAnimation(L"Offset", _cometHeadAnimation); + _headRoot.StartAnimation(L"Opacity", _cometHeadOpacityAnimation); + _indeterminateRunning = true; + } + } + + void _renderWaiting(const RenderTransitionPlan& plan) + { + _stopContinuousAnimations(); + _showSolidFill(plan.targetProgress, ProgressMode::Determinate, _statusColor(ProgressStatus::Waiting)); + if (plan.breathe && _headRoot && _headRoot.IsVisible()) + { + _headRoot.StartAnimation(L"Opacity", _waitingBreatheAnimation); + _waitingRunning = true; + } + } + + void _renderSuccess(const RenderTransitionPlan& plan) + { + _stopContinuousAnimations(); + _stopAmbientSparks(); + + if (!plan.animateProgress && !plan.successSweep && !plan.finalSparkBurst && !plan.fadeOut) + { + _showSolidFill(plan.targetProgress, ProgressMode::Determinate, _statusColor(ProgressStatus::Success)); + return; + } + + // Burst batches remain independent so the terminal presentation + // batch contains only the bounded head/fill/sweep/fade sequence. + if (plan.finalSparkBurst) + { + _emitBurst(static_cast(RainbowArcVisualConstants::StrongSparkBurstMaximum)); + } + + _beginTerminalBatch(); + _showGradientFill(plan.targetProgress, plan.animateProgress, false, plan.duration); + _statusBrush.Color(_statusColor(ProgressStatus::Success)); + _solidFillVisual.IsVisible(true); + _solidFillVisual.Opacity(0.0f); + _successTintAnimation.InsertKeyFrame(0.0f, 0.0f); + _successTintAnimation.InsertKeyFrame(1.0f, 0.82f); + _successTintAnimation.Duration(_timeSpan(RainbowArcVisualConstants::SuccessSweepDuration)); + _solidFillVisual.StartAnimation(L"Opacity", _successTintAnimation); + + if (_headRoot && _headRoot.IsVisible()) + { + _headRoot.StartAnimation(L"Scale", _successHeadScaleAnimation); + } + + if (plan.successSweep) + { + const auto sweepWidth = std::max(2.0f, _trackWidth * RainbowArcVisualConstants::SuccessSweepWidthFraction); + _successSweepMovementAnimation.InsertKeyFrame(0.0f, { -sweepWidth, 0.0f, 0.0f }); + _successSweepMovementAnimation.InsertKeyFrame(1.0f, { _trackWidth, 0.0f, 0.0f }); + _successSweepMovementAnimation.Duration(_timeSpan(RainbowArcVisualConstants::SuccessSweepDuration)); + _successSweepOpacityAnimation.InsertKeyFrame(0.0f, 0.0f); + _successSweepOpacityAnimation.InsertKeyFrame(0.18f, 0.92f); + _successSweepOpacityAnimation.InsertKeyFrame(0.82f, 0.92f); + _successSweepOpacityAnimation.InsertKeyFrame(1.0f, 0.0f); + _successSweepOpacityAnimation.Duration(_timeSpan(RainbowArcVisualConstants::SuccessSweepDuration)); + _successSweep.StartAnimation(L"Offset", _successSweepMovementAnimation); + _successSweep.StartAnimation(L"Opacity", _successSweepOpacityAnimation); + } + + if (plan.fadeOut) + { + _terminalFadeAnimation.DelayTime(_timeSpan(RainbowArcVisualConstants::SuccessPresentationDuration)); + _terminalFadeAnimation.Duration(_timeSpan(RainbowArcVisualConstants::SuccessFadeDuration)); + _terminalFadeAnimation.InsertKeyFrame(0.0f, 1.0f); + _terminalFadeAnimation.InsertKeyFrame(1.0f, 0.0f); + _root.StartAnimation(L"Opacity", _terminalFadeAnimation); + } + _endTerminalBatch(true); + } + + void _renderError(const RenderTransitionPlan& plan) + { + _stopContinuousAnimations(); + _releaseAllSparks(); + _showSolidFill(plan.targetProgress, ProgressMode::Determinate, _statusColor(ProgressStatus::Error)); + _setErrorHeadTreatment(true); + if (plan.errorPulse && _headRoot && _headRoot.IsVisible()) + { + _headRoot.StartAnimation(L"Opacity", _errorPulseAnimation); + } + } + + void _setErrorHeadTreatment(const bool enabled) + { + if (!_outerBloom || !_innerGlow || !_headTrail) + { + return; + } + _outerBloom.Brush(enabled ? _errorOuterBloomBrush : _outerBloomBrush); + _innerGlow.Brush(enabled ? _errorInnerGlowBrush : _innerGlowBrush); + _headTrail.Brush(enabled ? _errorTrailBrush : _headTrailBrush); + } + + void _renderCancelled(const RenderTransitionPlan& plan) + { + _stopContinuousAnimations(); + _releaseAllSparks(); + if (!plan.fadeOut) + { + _hideImmediately(); + return; + } + + _beginTerminalBatch(); + _terminalFadeAnimation.DelayTime(_timeSpan(std::chrono::milliseconds::zero())); + _terminalFadeAnimation.Duration(_timeSpan(RainbowArcVisualConstants::CancelFadeDuration)); + _terminalFadeAnimation.InsertKeyFrame(0.0f, 1.0f); + _terminalFadeAnimation.InsertKeyFrame(1.0f, 0.0f); + _root.StartAnimation(L"Opacity", _terminalFadeAnimation); + _endTerminalBatch(true); + } + + void _showGradientFill(const float progress, + const bool animate, + const bool regression, + const std::chrono::milliseconds duration) + { + _fillBoundary.IsVisible(true); + _cometContainer.IsVisible(false); + _rainbowFillVisual.IsVisible(true); + _rainbowFillVisual.Opacity(1.0f); + _solidFillVisual.IsVisible(false); + + const auto target = std::clamp(progress, 0.0f, 1.0f); + const auto current = std::clamp(_renderState.CurrentProgress(_now()), 0.0f, 1.0f); + const auto targetInset = (1.0f - target) * _trackWidth; + const auto currentInset = (1.0f - current) * _trackWidth; + const auto targetHead = Numerics::float3{ RainbowArcVisualConstants::HorizontalInset + (target * _trackWidth), _headY, 0.0f }; + const auto currentHead = Numerics::float3{ RainbowArcVisualConstants::HorizontalInset + (current * _trackWidth), _headY, 0.0f }; + + _fillInsetClip.StopAnimation(L"RightInset"); + _fillInsetClip.RightInset(targetInset); + const auto canShowHead = _headRoot && _renderState.Tier() < RenderTier::StaticGradient; + if (_headRoot) + { + _headRoot.IsVisible(canShowHead && target > 0.0f); + } + if (canShowHead) + { + _headRoot.StopAnimation(L"Offset"); + _headRoot.Offset(targetHead); + _headRoot.Opacity(1.0f); + } + + if (animate && duration > std::chrono::milliseconds::zero()) + { + _fillProgressAnimation.InsertKeyFrame(0.0f, currentInset); + _fillProgressAnimation.InsertKeyFrame(1.0f, targetInset); + _fillProgressAnimation.Duration(_timeSpan(duration)); + _fillInsetClip.StartAnimation(L"RightInset", _fillProgressAnimation); + if (canShowHead && target > 0.0f) + { + _headProgressAnimation.InsertKeyFrame(0.0f, currentHead); + _headProgressAnimation.InsertKeyFrame(1.0f, targetHead); + _headProgressAnimation.Duration(_timeSpan(duration)); + _headRoot.StartAnimation(L"Offset", _headProgressAnimation); + } + } + + if (regression && canShowHead && target > 0.0f) + { + _headRoot.StartAnimation(L"Opacity", _regressionDipAnimation); + } + } + + void _showSolidFill(const float progress, const ProgressMode mode, const WU::Color color) + { + _stopContinuousAnimations(); + _fillBoundary.IsVisible(mode != ProgressMode::Hidden); + _cometContainer.IsVisible(false); + _rainbowFillVisual.IsVisible(false); + _solidFillVisual.IsVisible(mode != ProgressMode::Hidden); + _solidFillVisual.Opacity(1.0f); + _statusBrush.Color(color); + + if (mode == ProgressMode::Indeterminate) + { + _showStaticComet(color); + return; + } + + const auto target = std::clamp(progress, 0.0f, 1.0f); + _fillInsetClip.RightInset((1.0f - target) * _trackWidth); + if (_headRoot) + { + _headRoot.Offset({ RainbowArcVisualConstants::HorizontalInset + (target * _trackWidth), _headY, 0.0f }); + _headRoot.IsVisible(target > 0.0f && _renderState.Tier() < RenderTier::StaticGradient); + _headRoot.Opacity(1.0f); + } + } + + void _showStaticComet() + { + _showStaticComet(_runningColor); + if (_cometBrush && !_environment.highContrast) + { + _cometTail.Brush(_cometBrush); + } + } + + void _showStaticComet(const WU::Color color) + { + _stopContinuousAnimations(); + _fillBoundary.IsVisible(false); + _cometContainer.IsVisible(true); + const auto tailWidth = _trackWidth * RainbowArcVisualConstants::IndeterminateTailFraction; + _cometTail.Size({ tailWidth, RainbowArcVisualConstants::TrackHeight }); + _cometTail.Offset({ (_trackWidth - tailWidth) / 2.0f, 0.0f, 0.0f }); + if (_environment.highContrast || _renderState.Tier() >= RenderTier::Solid) + { + _cometSolidBrush.Color(color); + _cometTail.Brush(_cometSolidBrush); + } + if (_headRoot) + { + _headRoot.Offset({ RainbowArcVisualConstants::HorizontalInset + (_trackWidth / 2.0f) + (tailWidth / 2.0f), _headY, 0.0f }); + _headRoot.IsVisible(_renderState.Tier() < RenderTier::StaticGradient); + _headRoot.Opacity(1.0f); + } + } + + void _setRainbowMovement(const bool enabled) + { + if (!_rainbowBrush) + { + return; + } + if (enabled && !_rainbowRunning) + { + _rainbowBrush.StartAnimation(L"Offset", _rainbowMovementAnimation); + _rainbowRunning = true; + } + else if (!enabled && _rainbowRunning) + { + _rainbowBrush.StopAnimation(L"Offset"); + _rainbowRunning = false; + } + } + + void _startAmbientSparks() + { + if (_renderState.Tier() != RenderTier::Full || !_headRoot || _ambientSparksRunning) + { + return; + } + + const auto now = _now(); + uint8_t started{}; + for (uint8_t ambientIndex = 0; ambientIndex < RainbowArcVisualConstants::AmbientSparkSlotCount; ++ambientIndex) + { + const auto handle = _sparkPool.Acquire(now, RainbowArcVisualConstants::MaximumSparkLifetime, true); + if (!handle) + { + break; + } + auto& spark = _sparks[handle->slot]; + spark.handle = handle; + spark.ambient = true; + _configureAmbientSpark(spark, handle->slot, ambientIndex); + ++started; + } + _ambientSparksRunning = started != 0; + } + + void _configureAmbientSpark(SparkVisual& spark, const uint8_t slot, const uint8_t ambientIndex) + { + const auto distance = RainbowArcVisualConstants::MinimumSparkTravel + + static_cast((slot * 5u + ambientIndex * 3u) % 11u); + const auto vertical = ambientIndex % 2 == 0 ? -4.0f : 4.0f; + const auto origin = Numerics::float3{ 0.0f, -spark.visual.Size().y / 2.0f, 0.0f }; + const auto end = Numerics::float3{ distance, vertical, 0.0f }; + const auto cycle = ambientIndex == 0 ? + RainbowArcVisualConstants::AmbientSparkCycleOne : + RainbowArcVisualConstants::AmbientSparkCycleTwo; + const auto delay = RainbowArcVisualConstants::AmbientSparkStagger * ambientIndex; + + spark.ambientMovement.InsertKeyFrame(0.0f, origin); + spark.ambientMovement.InsertKeyFrame(0.13f, end); + spark.ambientMovement.InsertKeyFrame(1.0f, end); + spark.ambientMovement.Duration(_timeSpan(cycle)); + spark.ambientMovement.DelayTime(_timeSpan(delay)); + spark.ambientMovement.IterationBehavior(WUC::AnimationIterationBehavior::Forever); + spark.ambientOpacity.Duration(_timeSpan(cycle)); + spark.ambientOpacity.DelayTime(_timeSpan(delay)); + spark.ambientOpacity.IterationBehavior(WUC::AnimationIterationBehavior::Forever); + spark.ambientColor.Duration(_timeSpan(cycle)); + spark.ambientColor.DelayTime(_timeSpan(delay)); + spark.ambientColor.IterationBehavior(WUC::AnimationIterationBehavior::Forever); + spark.visual.StartAnimation(L"Offset", spark.ambientMovement); + spark.visual.StartAnimation(L"Opacity", spark.ambientOpacity); + spark.brush.StartAnimation(L"Color", spark.ambientColor); + } + + void _stopAmbientSparks() noexcept + { + if (!_ambientSparksRunning) + { + return; + } + for (auto& spark : _sparks) + { + if (!spark.ambient || !spark.handle) + { + continue; + } + _stopSparkAnimations(spark); + _sparkPool.Release(*spark.handle); + spark.handle.reset(); + spark.ambient = false; + } + _ambientSparksRunning = false; + } + + void _emitBurst(const uint8_t requested) + { + if (_renderState.Tier() != RenderTier::Full || !_environment.AllowsContinuousAnimation() || !_environment.paneActive) + { + return; + } + + const auto count = std::min(requested, static_cast(RainbowArcVisualConstants::StrongSparkBurstMaximum)); + const auto now = _now(); + for (uint8_t burstIndex = 0; burstIndex < count; ++burstIndex) + { + const auto lifetime = RainbowArcVisualConstants::MinimumSparkLifetime + + std::chrono::milliseconds{ static_cast((burstIndex * 29u + _snapshot.sequence) % 141u) }; + const auto handle = _sparkPool.Acquire(now, lifetime, false); + if (!handle) + { + break; + } + auto& spark = _sparks[handle->slot]; + spark.handle = handle; + spark.ambient = false; + _configureBurstSpark(spark, *handle, burstIndex, lifetime); + } + } + + void _configureBurstSpark(SparkVisual& spark, + const SparkHandle handle, + const uint8_t burstIndex, + const std::chrono::milliseconds lifetime) + { + _clearSparkBatch(spark); + const auto distance = std::min( + RainbowArcVisualConstants::MaximumSparkTravel, + RainbowArcVisualConstants::MinimumSparkTravel + static_cast((handle.slot * 7u + burstIndex * 3u) % 13u)); + constexpr std::array verticalPattern{ -6.0f, 4.5f, -2.5f, 6.0f, -4.0f, 2.0f }; + const auto vertical = verticalPattern[burstIndex % verticalPattern.size()]; + const auto origin = Numerics::float3{ 0.0f, -spark.visual.Size().y / 2.0f, 0.0f }; + const auto end = Numerics::float3{ distance, vertical, 0.0f }; + + spark.burstMovement.InsertKeyFrame(0.0f, origin); + spark.burstMovement.InsertKeyFrame(1.0f, end); + spark.burstMovement.Duration(_timeSpan(lifetime)); + spark.burstMovement.DelayTime(_timeSpan(std::chrono::milliseconds{ burstIndex * 18 })); + spark.burstMovement.IterationBehavior(WUC::AnimationIterationBehavior::Count); + spark.burstMovement.IterationCount(1); + spark.burstOpacity.Duration(_timeSpan(lifetime)); + spark.burstOpacity.DelayTime(_timeSpan(std::chrono::milliseconds{ burstIndex * 18 })); + spark.burstOpacity.IterationBehavior(WUC::AnimationIterationBehavior::Count); + spark.burstOpacity.IterationCount(1); + spark.burstColor.Duration(_timeSpan(lifetime)); + spark.burstColor.DelayTime(_timeSpan(std::chrono::milliseconds{ burstIndex * 18 })); + spark.burstColor.IterationBehavior(WUC::AnimationIterationBehavior::Count); + spark.burstColor.IterationCount(1); + + spark.batch = _compositor.CreateScopedBatch(WUC::CompositionBatchTypes::Animation); + spark.visual.StartAnimation(L"Offset", spark.burstMovement); + spark.visual.StartAnimation(L"Opacity", spark.burstOpacity); + spark.brush.StartAnimation(L"Color", spark.burstColor); + spark.batch.End(); + + const auto weak = weak_from_this(); + spark.completedToken = spark.batch.Completed([weak, handle](auto&&, auto&&) { + if (const auto self = weak.lock()) + { + self->_completeSpark(handle); + } + }); + spark.completedSubscribed = true; + } + + void _completeSpark(const SparkHandle handle) noexcept + { + if (!handle || handle.slot >= _sparks.size()) + { + return; + } + auto& spark = _sparks[handle.slot]; + if (!spark.handle || spark.handle->generation != handle.generation) + { + return; + } + _stopSparkAnimations(spark); + _sparkPool.Release(handle); + spark.handle.reset(); + spark.ambient = false; + } + + void _stopSparkAnimations(SparkVisual& spark) noexcept + { + try + { + if (spark.visual) + { + spark.visual.StopAnimation(L"Offset"); + spark.visual.StopAnimation(L"Opacity"); + spark.visual.Opacity(0.0f); + } + if (spark.brush) + { + spark.brush.StopAnimation(L"Color"); + } + } + catch (...) + { + } + } + + void _clearSparkBatch(SparkVisual& spark) noexcept + { + try + { + if (spark.completedSubscribed && spark.batch) + { + spark.batch.Completed(spark.completedToken); + } + } + catch (...) + { + } + spark.completedSubscribed = false; + spark.batch = nullptr; + } + + void _releaseAllSparks() noexcept + { + for (auto& spark : _sparks) + { + _stopSparkAnimations(spark); + _clearSparkBatch(spark); + spark.handle.reset(); + spark.ambient = false; + } + _sparkPool.ReleaseAll(); + _ambientSparksRunning = false; + } + + void _beginTerminalBatch() + { + _clearTerminalBatch(); + _terminalBatch = _compositor.CreateScopedBatch(WUC::CompositionBatchTypes::Animation); + _activeTerminalBatchGeneration = ++_nextTerminalBatchGeneration; + } + + void _endTerminalBatch(const bool hideOnCompletion) + { + if (!_terminalBatch) + { + return; + } + _terminalBatch.End(); + const auto weak = weak_from_this(); + const auto presentationGeneration = _presentationGeneration; + const auto terminalBatchGeneration = _activeTerminalBatchGeneration; + _terminalBatchToken = _terminalBatch.Completed([weak, hideOnCompletion, presentationGeneration, terminalBatchGeneration](auto&&, auto&&) { + if (const auto self = weak.lock()) + { + if (self->_presentationGeneration != presentationGeneration || + self->_activeTerminalBatchGeneration != terminalBatchGeneration) + { + return; + } + self->_stopContinuousAnimations(); + self->_releaseAllSparks(); + if (hideOnCompletion && self->_root) + { + self->_terminalPresentationCompleted = true; + self->_root.IsVisible(false); + } + } + }); + _terminalBatchSubscribed = true; + } + + void _clearTerminalBatch() noexcept + { + // Invalidate first so a queued callback from a replaced batch is + // stale even when its replacement belongs to the same snapshot. + _activeTerminalBatchGeneration = 0; + try + { + if (_terminalBatchSubscribed && _terminalBatch) + { + _terminalBatch.Completed(_terminalBatchToken); + } + } + catch (...) + { + } + _terminalBatchSubscribed = false; + _terminalBatch = nullptr; + } + + void _stopStatusAnimations() noexcept + { + try + { + if (_root) + { + _root.StopAnimation(L"Opacity"); + _root.Opacity(1.0f); + } + if (_headRoot) + { + _headRoot.StopAnimation(L"Opacity"); + _headRoot.StopAnimation(L"Scale"); + _headRoot.Opacity(1.0f); + _headRoot.Scale({ 1.0f, 1.0f, 1.0f }); + } + if (_solidFillVisual) + { + _solidFillVisual.StopAnimation(L"Opacity"); + _solidFillVisual.Opacity(1.0f); + } + if (_successSweep) + { + _successSweep.StopAnimation(L"Offset"); + _successSweep.StopAnimation(L"Opacity"); + _successSweep.Opacity(0.0f); + } + } + catch (...) + { + } + _waitingRunning = false; + } + + void _stopContinuousAnimations() noexcept + { + try + { + _setRainbowMovement(false); + if (_cometTail) + { + _cometTail.StopAnimation(L"Offset"); + } + if (_headRoot) + { + _headRoot.StopAnimation(L"Offset"); + _headRoot.StopAnimation(L"Opacity"); + _headRoot.Opacity(1.0f); + } + } + catch (...) + { + } + _indeterminateRunning = false; + _waitingRunning = false; + } + + void _stopAllAnimations() noexcept + { + _stopContinuousAnimations(); + _stopStatusAnimations(); + try + { + if (_fillInsetClip) + { + _fillInsetClip.StopAnimation(L"RightInset"); + } + } + catch (...) + { + } + } + + void _pauseForIneligibleHost() noexcept + { + _stopAllAnimations(); + _releaseAllSparks(); + _showFallback(false); + try + { + if (_root) + { + _root.IsVisible(false); + } + } + catch (...) + { + } + } + + void _hideImmediately() noexcept + { + _pauseForIneligibleHost(); + } + + void _showFallback(const bool visible) noexcept + { + try + { + if (_fallbackTrack) + { + _fallbackTrack.Visibility(visible && _drawable ? WUX::Visibility::Visible : WUX::Visibility::Collapsed); + } + if (_fallbackFill) + { + _fallbackFill.Visibility(visible && _drawable ? WUX::Visibility::Visible : WUX::Visibility::Collapsed); + } + if (_root) + { + _root.IsVisible(!visible && _drawable); + } + } + catch (...) + { + } + } + + void _updateFallback(const float progress, const ProgressMode mode, const ProgressStatus status) noexcept + { + if (!_fallbackFill || !_fallbackTrack) + { + return; + } + try + { + auto width = std::clamp(progress, 0.0f, 1.0f) * _trackWidth; + auto left = RainbowArcVisualConstants::HorizontalInset; + if (mode == ProgressMode::Indeterminate) + { + width = _trackWidth * RainbowArcVisualConstants::IndeterminateTailFraction; + left += (_trackWidth - width) / 2.0f; + } + else if (mode == ProgressMode::Hidden) + { + width = 0.0f; + } + _fallbackFill.Width(width); + _fallbackFill.Margin(WUX::ThicknessHelper::FromLengths( + left, + 0.0, + 0.0, + RainbowArcVisualConstants::BottomInset)); + _fallbackTrackBrush.Color(_trackColor); + _fallbackFillBrush.Color(_statusColor(status)); + _fallbackFill.Visibility(width > 0.0f ? WUX::Visibility::Visible : WUX::Visibility::Collapsed); + } + catch (...) + { + } + } + + WU::Color _statusColor(const ProgressStatus status) const noexcept + { + if (_environment.highContrast) + { + return _runningColor; + } + switch (status) + { + case ProgressStatus::Waiting: + return _color(RainbowArcVisualConstants::WaitingSolid); + case ProgressStatus::Success: + return _color(RainbowArcVisualConstants::SuccessSolid); + case ProgressStatus::Error: + return _color(RainbowArcVisualConstants::ErrorSolid); + case ProgressStatus::Running: + case ProgressStatus::Cancelled: + default: + return _runningColor; + } + } + + void _detachCompositionForFallback() noexcept + { + try + { + if (_host) + { + WUXH::ElementCompositionPreview::SetElementChildVisual(_host, WUC::Visual{ nullptr }); + } + } + catch (...) + { + } + _releaseCompositionHandles(); + _showFallback(true); + } + + void _removeFallbackVisuals() noexcept + { + try + { + uint32_t index{}; + if (_fallbackTrack && _host.Children().IndexOf(_fallbackTrack, index)) + { + _host.Children().RemoveAt(index); + } + if (_fallbackFill && _host.Children().IndexOf(_fallbackFill, index)) + { + _host.Children().RemoveAt(index); + } + } + catch (...) + { + } + _fallbackTrack = nullptr; + _fallbackFill = nullptr; + _fallbackTrackBrush = nullptr; + _fallbackFillBrush = nullptr; + } + + void _releaseCompositionHandles() noexcept + { + _clearTerminalBatch(); + _root = nullptr; + _trackVisual = nullptr; + _trackGeometry = nullptr; + _trackShape = nullptr; + _trackBrush = nullptr; + _fillBoundary = nullptr; + _fillInsetClip = nullptr; + _solidFillVisual = nullptr; + _solidFillGeometry = nullptr; + _solidFillShape = nullptr; + _statusBrush = nullptr; + _rainbowFillVisual = nullptr; + _rainbowFillGeometry = nullptr; + _rainbowFillShape = nullptr; + _rainbowBrush = nullptr; + _cometContainer = nullptr; + _cometClip = nullptr; + _cometClipGeometry = nullptr; + _cometTail = nullptr; + _cometBrush = nullptr; + _cometSolidBrush = nullptr; + _successSweep = nullptr; + _headRoot = nullptr; + _outerBloom = nullptr; + _outerBloomBrush = nullptr; + _errorOuterBloomBrush = nullptr; + _innerGlow = nullptr; + _innerGlowBrush = nullptr; + _errorInnerGlowBrush = nullptr; + _headTrail = nullptr; + _headTrailBrush = nullptr; + _errorTrailBrush = nullptr; + _warmCore = nullptr; + _whiteCore = nullptr; + _rainbowMovementAnimation = nullptr; + _fillProgressAnimation = nullptr; + _headProgressAnimation = nullptr; + _cometTailAnimation = nullptr; + _cometHeadAnimation = nullptr; + _cometHeadOpacityAnimation = nullptr; + _waitingBreatheAnimation = nullptr; + _regressionDipAnimation = nullptr; + _successSweepMovementAnimation = nullptr; + _successSweepOpacityAnimation = nullptr; + _successTintAnimation = nullptr; + _errorPulseAnimation = nullptr; + _successHeadScaleAnimation = nullptr; + _terminalFadeAnimation = nullptr; + for (auto& spark : _sparks) + { + _clearSparkBatch(spark); + spark = {}; + } + _compositor = nullptr; + } + + inline static SparkBudget _sharedSparkBudget{}; + + SparkPool _sparkPool; + std::array _sparks{}; + VisualProgressRenderState _renderState{}; + RenderEnvironment _environment{}; + ProgressSnapshot _snapshot{}; + + WUXC::Grid _host{ nullptr }; + WUXC::Border _fallbackTrack{ nullptr }; + WUXC::Border _fallbackFill{ nullptr }; + WUXM::SolidColorBrush _fallbackTrackBrush{ nullptr }; + WUXM::SolidColorBrush _fallbackFillBrush{ nullptr }; + + WUC::Compositor _compositor{ nullptr }; + WUC::ContainerVisual _root{ nullptr }; + WUC::ShapeVisual _trackVisual{ nullptr }; + WUC::CompositionRoundedRectangleGeometry _trackGeometry{ nullptr }; + WUC::CompositionSpriteShape _trackShape{ nullptr }; + WUC::CompositionColorBrush _trackBrush{ nullptr }; + + WUC::ContainerVisual _fillBoundary{ nullptr }; + WUC::InsetClip _fillInsetClip{ nullptr }; + WUC::ShapeVisual _solidFillVisual{ nullptr }; + WUC::CompositionRoundedRectangleGeometry _solidFillGeometry{ nullptr }; + WUC::CompositionSpriteShape _solidFillShape{ nullptr }; + WUC::CompositionColorBrush _statusBrush{ nullptr }; + WUC::ShapeVisual _rainbowFillVisual{ nullptr }; + WUC::CompositionRoundedRectangleGeometry _rainbowFillGeometry{ nullptr }; + WUC::CompositionSpriteShape _rainbowFillShape{ nullptr }; + WUC::CompositionLinearGradientBrush _rainbowBrush{ nullptr }; + + WUC::ContainerVisual _cometContainer{ nullptr }; + WUC::CompositionGeometricClip _cometClip{ nullptr }; + WUC::CompositionRoundedRectangleGeometry _cometClipGeometry{ nullptr }; + WUC::SpriteVisual _cometTail{ nullptr }; + WUC::CompositionLinearGradientBrush _cometBrush{ nullptr }; + WUC::CompositionColorBrush _cometSolidBrush{ nullptr }; + WUC::SpriteVisual _successSweep{ nullptr }; + + WUC::ContainerVisual _headRoot{ nullptr }; + WUC::SpriteVisual _outerBloom{ nullptr }; + WUC::CompositionRadialGradientBrush _outerBloomBrush{ nullptr }; + WUC::CompositionRadialGradientBrush _errorOuterBloomBrush{ nullptr }; + WUC::SpriteVisual _innerGlow{ nullptr }; + WUC::CompositionRadialGradientBrush _innerGlowBrush{ nullptr }; + WUC::CompositionRadialGradientBrush _errorInnerGlowBrush{ nullptr }; + WUC::SpriteVisual _headTrail{ nullptr }; + WUC::CompositionLinearGradientBrush _headTrailBrush{ nullptr }; + WUC::CompositionLinearGradientBrush _errorTrailBrush{ nullptr }; + WUC::SpriteVisual _warmCore{ nullptr }; + WUC::SpriteVisual _whiteCore{ nullptr }; + + WUC::Vector2KeyFrameAnimation _rainbowMovementAnimation{ nullptr }; + WUC::ScalarKeyFrameAnimation _fillProgressAnimation{ nullptr }; + WUC::Vector3KeyFrameAnimation _headProgressAnimation{ nullptr }; + WUC::Vector3KeyFrameAnimation _cometTailAnimation{ nullptr }; + WUC::Vector3KeyFrameAnimation _cometHeadAnimation{ nullptr }; + WUC::ScalarKeyFrameAnimation _cometHeadOpacityAnimation{ nullptr }; + WUC::ScalarKeyFrameAnimation _waitingBreatheAnimation{ nullptr }; + WUC::ScalarKeyFrameAnimation _regressionDipAnimation{ nullptr }; + WUC::Vector3KeyFrameAnimation _successSweepMovementAnimation{ nullptr }; + WUC::ScalarKeyFrameAnimation _successSweepOpacityAnimation{ nullptr }; + WUC::ScalarKeyFrameAnimation _successTintAnimation{ nullptr }; + WUC::ScalarKeyFrameAnimation _errorPulseAnimation{ nullptr }; + WUC::Vector3KeyFrameAnimation _successHeadScaleAnimation{ nullptr }; + WUC::ScalarKeyFrameAnimation _terminalFadeAnimation{ nullptr }; + + WUC::CompositionScopedBatch _terminalBatch{ nullptr }; + winrt::event_token _terminalBatchToken{}; + uint64_t _presentationGeneration{}; + uint64_t _nextTerminalBatchGeneration{}; + uint64_t _activeTerminalBatchGeneration{}; + bool _terminalBatchSubscribed{}; + bool _terminalPresentationCompleted{}; + + WUVM::UISettings _uiSettings{ nullptr }; + WUVM::AccessibilitySettings _accessibilitySettings{ nullptr }; + WUCore::CoreWindow _coreWindow{ nullptr }; + + winrt::event_token _loadedToken{}; + winrt::event_token _unloadedToken{}; + winrt::event_token _sizeChangedToken{}; + winrt::event_token _themeChangedToken{}; + winrt::event_token _coreActivatedToken{}; + winrt::event_token _coreVisibilityToken{}; + winrt::event_token _highContrastToken{}; + winrt::event_token _animationsToken{}; + bool _loadedSubscribed{}; + bool _unloadedSubscribed{}; + bool _sizeChangedSubscribed{}; + bool _themeChangedSubscribed{}; + bool _coreActivatedSubscribed{}; + bool _coreVisibilitySubscribed{}; + bool _highContrastSubscribed{}; + bool _animationsSubscribed{}; + + WU::Color _trackColor{ _color(RainbowArcVisualConstants::DarkTrack) }; + WU::Color _runningColor{ _color(RainbowArcVisualConstants::RunningSolid) }; + WU::Color _hotColor{ _color(RainbowArcVisualConstants::WhiteHot) }; + uint64_t _lastBurstSequence{}; + float _trackWidth{}; + float _trackY{}; + float _headY{}; + bool _drawable{}; + bool _rainbowRunning{}; + bool _indeterminateRunning{}; + bool _waitingRunning{}; + bool _ambientSparksRunning{}; + bool _faulted{}; + bool _closed{}; + }; +} diff --git a/src/winterm/VisualProgress/RainbowArcVisualConstants.h b/src/winterm/VisualProgress/RainbowArcVisualConstants.h new file mode 100644 index 000000000..be43242ad --- /dev/null +++ b/src/winterm/VisualProgress/RainbowArcVisualConstants.h @@ -0,0 +1,95 @@ +// Copyright (c) winTerm contributors. +// Licensed under the MIT license. + +#pragma once + +#include +#include +#include + +namespace winTerm::VisualProgress::RainbowArcVisualConstants +{ + // Geometry is expressed in XAML effective pixels (DIPs). The composition + // child visual inherits XAML's rasterization scale, so these values remain + // correct at 100%, 125%, 150%, and 200% display scaling. + inline constexpr float TrackHeight{ 6.0f }; + inline constexpr float HorizontalInset{ 10.0f }; + inline constexpr float BottomInset{ 8.0f }; + inline constexpr float TrackCornerRadius{ TrackHeight / 2.0f }; + inline constexpr float MinimumDrawableTrackWidth{ 2.0f }; + + inline constexpr float WhiteCoreWidth{ 2.5f }; + inline constexpr float WarmCoreWidth{ 5.0f }; + inline constexpr float HeadTrailWidth{ 8.0f }; + inline constexpr float InnerGlowWidth{ 12.0f }; + inline constexpr float InnerGlowHeight{ 10.0f }; + inline constexpr float OuterBloomWidth{ 26.0f }; + inline constexpr float OuterBloomHeight{ 16.0f }; + // Reserves the track, its bottom inset, and the full outer bloom without + // making Pane.cpp duplicate renderer geometry knowledge. + inline constexpr float OverlayHostHeight{ BottomInset + TrackHeight + OuterBloomHeight }; + inline constexpr float SuccessSweepWidthFraction{ 0.16f }; + inline constexpr float IndeterminateTailFraction{ 0.25f }; + + inline constexpr float TypicalSparkSize{ 1.5f }; + inline constexpr float MinimumSparkSize{ 1.0f }; + inline constexpr float MaximumSparkSize{ 3.0f }; + inline constexpr float MinimumSparkTravel{ 6.0f }; + inline constexpr float MaximumSparkTravel{ 18.0f }; + inline constexpr float MaximumSparkVerticalTravel{ 7.0f }; + inline constexpr float SparkBandHeight{ 22.0f }; + + // All animation periods are compositor-managed. They are centralized here + // to keep the tuning surface out of Pane.cpp and out of terminal-core code. + inline constexpr std::chrono::milliseconds RainbowCycleDuration{ 2000 }; + inline constexpr std::chrono::milliseconds IndeterminateCycleDuration{ 1800 }; + inline constexpr std::chrono::milliseconds DeterminateInterpolationDuration{ 220 }; + inline constexpr std::chrono::milliseconds RegressionInterpolationDuration{ 240 }; + inline constexpr std::chrono::milliseconds RegressionOpacityDipDuration{ 90 }; + inline constexpr std::chrono::milliseconds WaitingBreatheDuration{ 1600 }; + inline constexpr std::chrono::milliseconds SuccessAdvanceDuration{ 220 }; + inline constexpr std::chrono::milliseconds SuccessIntensifyDuration{ 140 }; + inline constexpr std::chrono::milliseconds SuccessSweepDuration{ 320 }; + inline constexpr std::chrono::milliseconds SuccessPresentationDuration{ 480 }; + inline constexpr std::chrono::milliseconds SuccessFadeDuration{ 650 }; + inline constexpr std::chrono::milliseconds ErrorPulseDuration{ 220 }; + inline constexpr std::chrono::milliseconds CancelFadeDuration{ 180 }; + inline constexpr std::chrono::milliseconds MinimumSparkLifetime{ 120 }; + inline constexpr std::chrono::milliseconds MaximumSparkLifetime{ 260 }; + inline constexpr std::chrono::milliseconds AmbientSparkCycleOne{ 1450 }; + inline constexpr std::chrono::milliseconds AmbientSparkCycleTwo{ 1950 }; + inline constexpr std::chrono::milliseconds AmbientSparkStagger{ 370 }; + + // Fixed renderer and recognition bounds. These are capacities, not tuning + // suggestions: callers must fail open rather than grow them dynamically. + inline constexpr std::size_t RainbowStopCount{ 9 }; + inline constexpr std::size_t SparkPoolCapacityPerPane{ 8 }; + inline constexpr std::size_t SparkCapacityPerWindowOrProcess{ 24 }; + inline constexpr std::size_t NormalSparkBurstMinimum{ 1 }; + inline constexpr std::size_t NormalSparkBurstMaximum{ 2 }; + inline constexpr std::size_t StrongSparkBurstMinimum{ 3 }; + inline constexpr std::size_t StrongSparkBurstMaximum{ 6 }; + inline constexpr std::size_t AmbientSparkSlotCount{ 2 }; + + // ARGB palette. High Contrast colors are resolved from UISettings at run + // time and therefore deliberately do not live in this fixed palette. + inline constexpr uint32_t DarkTrack{ 0x99111820u }; + inline constexpr uint32_t LightTrack{ 0x665F6F7Cu }; + inline constexpr uint32_t RunningSolid{ 0xFF63E6BEu }; + inline constexpr uint32_t WaitingSolid{ 0xFFF4C95Du }; + inline constexpr uint32_t SuccessSolid{ 0xFF4DDC88u }; + inline constexpr uint32_t ErrorSolid{ 0xFFFF6B6Bu }; + inline constexpr uint32_t WarmWhite{ 0xFFFFF2B3u }; + inline constexpr uint32_t WhiteHot{ 0xFFFFFFFFu }; + inline constexpr uint32_t SparkYellow{ 0xFFFFD166u }; + inline constexpr uint32_t SparkOrange{ 0xFFFF8A3Du }; + + inline constexpr uint32_t RainbowRed{ 0xFFFF3B30u }; + inline constexpr uint32_t RainbowOrange{ 0xFFFF8A00u }; + inline constexpr uint32_t RainbowYellow{ 0xFFFFD60Au }; + inline constexpr uint32_t RainbowGreen{ 0xFF34C759u }; + inline constexpr uint32_t RainbowCyan{ 0xFF32D6E9u }; + inline constexpr uint32_t RainbowBlue{ 0xFF0A84FFu }; + inline constexpr uint32_t RainbowViolet{ 0xFF7D5CFFu }; + inline constexpr uint32_t RainbowMagenta{ 0xFFFF2D95u }; +} diff --git a/src/winterm/VisualProgress/VisualProgressRenderModel.h b/src/winterm/VisualProgress/VisualProgressRenderModel.h new file mode 100644 index 000000000..b5a9a30c7 --- /dev/null +++ b/src/winterm/VisualProgress/VisualProgressRenderModel.h @@ -0,0 +1,668 @@ +// Copyright (c) winTerm contributors. +// Licensed under the MIT license. + +#pragma once + +#include "RainbowArcVisualConstants.h" +#include "VisualProgressModel.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace winTerm::VisualProgress +{ + // Ordered from most capable to least capable. Degradation is local to the + // affected renderer and never changes terminal behavior. + enum class RenderTier : uint8_t + { + Full, + NoSparks, + StaticGradient, + Solid, + Disabled, + }; + + constexpr RenderTier NextLowerRenderTier(const RenderTier tier) noexcept + { + switch (tier) + { + case RenderTier::Full: + return RenderTier::NoSparks; + case RenderTier::NoSparks: + return RenderTier::StaticGradient; + case RenderTier::StaticGradient: + return RenderTier::Solid; + case RenderTier::Solid: + case RenderTier::Disabled: + default: + return RenderTier::Disabled; + } + } + + struct RenderEnvironment + { + bool featureEnabled{ true }; + bool rendererAvailable{ true }; + bool hostLoaded{}; + bool tabVisible{}; + bool paneVisible{ true }; + bool paneActive{}; + bool windowVisible{ true }; + bool windowFocused{ true }; + bool animationsEnabled{ true }; + bool highContrast{}; + + constexpr bool CanPresent() const noexcept + { + return featureEnabled && rendererAvailable && hostLoaded && tabVisible && paneVisible && windowVisible; + } + + constexpr bool UsesStaticFallback() const noexcept + { + return highContrast || !animationsEnabled; + } + + constexpr bool AllowsContinuousAnimation() const noexcept + { + return CanPresent() && paneActive && windowFocused && animationsEnabled && !highContrast; + } + }; + + enum class RenderTransitionKind : uint8_t + { + None, + Hide, + ShowDeterminate, + Advance, + PhaseRegression, + Indeterminate, + Waiting, + Success, + Error, + Cancelled, + EnvironmentRefresh, + }; + + // Render timestamps are supplied by the caller. Tests can pass arbitrary + // monotonically increasing values and never need wall-clock sleeps. + using RenderTimestamp = std::chrono::milliseconds; + + struct RenderTransitionPlan + { + RenderTransitionKind kind{ RenderTransitionKind::None }; + RenderTier tier{ RenderTier::Disabled }; + ProgressMode mode{ ProgressMode::Hidden }; + ProgressStatus status{ ProgressStatus::Cancelled }; + float fromProgress{}; + float targetProgress{}; + std::chrono::milliseconds duration{}; + bool visible{}; + bool animateProgress{}; + bool phaseReset{}; + bool rainbowMoving{}; + bool indeterminateMoving{}; + bool headVisible{}; + bool breathe{}; + bool successSweep{}; + bool finalSparkBurst{}; + bool errorPulse{}; + bool fadeOut{}; + bool sparksEligible{}; + bool staticFallback{}; + bool releaseAfterTransition{}; + }; + + class VisualProgressRenderState final + { + public: + explicit constexpr VisualProgressRenderState(const RenderTier tier = RenderTier::Full) noexcept : + _tier{ tier } + { + } + + RenderTransitionPlan Apply(const ProgressSnapshot& snapshot, + const RenderEnvironment& environment, + const RenderTimestamp now) noexcept + { + if (_closed) + { + return _disabledPlan(); + } + + const auto current = CurrentProgress(now); + const auto previous = _snapshot; + _environment = environment; + _snapshot = snapshot; + + auto plan = _basePlan(snapshot, current); + const auto canShow = snapshot.visible && snapshot.mode != ProgressMode::Hidden && environment.CanPresent() && _tier != RenderTier::Disabled; + plan.visible = canShow; + + if (!canShow) + { + plan.kind = previous.visible && snapshot.status == ProgressStatus::Cancelled ? + RenderTransitionKind::Cancelled : + RenderTransitionKind::Hide; + plan.fadeOut = previous.visible && + snapshot.status == ProgressStatus::Cancelled && + environment.AllowsContinuousAnimation() && + _tier < RenderTier::StaticGradient; + plan.duration = plan.fadeOut ? RainbowArcVisualConstants::CancelFadeDuration : std::chrono::milliseconds::zero(); + plan.releaseAfterTransition = true; + // A background tab or minimized window can receive its first + // semantic update while it cannot present. Keep that latest + // target so an environment refresh renders it immediately; + // only an explicitly hidden snapshot clears progress. + plan.targetProgress = _semanticTargetWhileHidden(snapshot, current); + _setTransition(plan.targetProgress, + plan.targetProgress, + now, + std::chrono::milliseconds::zero()); + _presenting = false; + return plan; + } + + _presenting = true; + plan.staticFallback = environment.UsesStaticFallback() || _tier >= RenderTier::StaticGradient; + const auto motionAllowed = environment.AllowsContinuousAnimation() && _tier < RenderTier::StaticGradient; + + switch (snapshot.status) + { + case ProgressStatus::Waiting: + plan.kind = RenderTransitionKind::Waiting; + plan.targetProgress = _meaningfulProgress(snapshot, current); + plan.headVisible = snapshot.mode == ProgressMode::Determinate && plan.targetProgress > 0.0f; + plan.breathe = motionAllowed; + plan.duration = std::chrono::milliseconds::zero(); + break; + case ProgressStatus::Success: + plan.kind = RenderTransitionKind::Success; + plan.targetProgress = snapshot.mode == ProgressMode::Determinate ? 1.0f : current; + plan.headVisible = plan.targetProgress > 0.0f; + plan.animateProgress = motionAllowed && std::fabs(plan.targetProgress - current) > ProgressEpsilon; + plan.duration = plan.animateProgress ? RainbowArcVisualConstants::SuccessAdvanceDuration : std::chrono::milliseconds::zero(); + plan.successSweep = motionAllowed; + plan.finalSparkBurst = motionAllowed && environment.paneActive && _tier == RenderTier::Full; + plan.fadeOut = motionAllowed; + plan.releaseAfterTransition = true; + break; + case ProgressStatus::Error: + plan.kind = RenderTransitionKind::Error; + plan.targetProgress = _meaningfulProgress(snapshot, current); + plan.headVisible = snapshot.mode == ProgressMode::Determinate && plan.targetProgress > 0.0f; + plan.errorPulse = motionAllowed; + plan.duration = std::chrono::milliseconds::zero(); + break; + case ProgressStatus::Cancelled: + plan.kind = RenderTransitionKind::Cancelled; + plan.targetProgress = current; + plan.fadeOut = motionAllowed; + plan.duration = plan.fadeOut ? RainbowArcVisualConstants::CancelFadeDuration : std::chrono::milliseconds::zero(); + plan.releaseAfterTransition = true; + break; + case ProgressStatus::Running: + default: + if (snapshot.mode == ProgressMode::Indeterminate) + { + plan.kind = RenderTransitionKind::Indeterminate; + plan.indeterminateMoving = motionAllowed; + plan.rainbowMoving = motionAllowed; + plan.headVisible = true; + plan.targetProgress = current; + } + else + { + plan.targetProgress = _normalized(snapshot.value); + plan.headVisible = plan.targetProgress > 0.0f; + const auto regressed = _hadDeterminateValue && plan.targetProgress + ProgressEpsilon < _lastDeterminateTarget; + plan.kind = regressed ? + RenderTransitionKind::PhaseRegression : + (previous.visible ? RenderTransitionKind::Advance : RenderTransitionKind::ShowDeterminate); + plan.phaseReset = regressed; + plan.duration = regressed ? + RainbowArcVisualConstants::RegressionInterpolationDuration : + RainbowArcVisualConstants::DeterminateInterpolationDuration; + plan.animateProgress = motionAllowed && std::fabs(plan.targetProgress - current) > ProgressEpsilon; + if (!plan.animateProgress) + { + plan.duration = std::chrono::milliseconds::zero(); + } + plan.rainbowMoving = motionAllowed; + _lastDeterminateTarget = plan.targetProgress; + _hadDeterminateValue = true; + } + break; + } + + plan.sparksEligible = motionAllowed && + environment.paneActive && + snapshot.status == ProgressStatus::Running && + _tier == RenderTier::Full; + + _setTransition(current, plan.targetProgress, now, plan.duration); + return plan; + } + + RenderTransitionPlan RefreshEnvironment(const RenderEnvironment& environment, + const RenderTimestamp now) noexcept + { + if (_closed) + { + return _disabledPlan(); + } + + const auto current = CurrentProgress(now); + _environment = environment; + auto plan = _basePlan(_snapshot, current); + plan.kind = RenderTransitionKind::EnvironmentRefresh; + plan.targetProgress = _targetProgress; + plan.visible = _snapshot.visible && _snapshot.mode != ProgressMode::Hidden && environment.CanPresent() && _tier != RenderTier::Disabled; + plan.staticFallback = environment.UsesStaticFallback() || _tier >= RenderTier::StaticGradient; + + const auto motionAllowed = environment.AllowsContinuousAnimation() && _tier < RenderTier::StaticGradient; + plan.rainbowMoving = motionAllowed && _snapshot.status == ProgressStatus::Running; + plan.indeterminateMoving = plan.rainbowMoving && _snapshot.mode == ProgressMode::Indeterminate; + plan.breathe = motionAllowed && _snapshot.status == ProgressStatus::Waiting; + plan.sparksEligible = motionAllowed && + environment.paneActive && + _snapshot.status == ProgressStatus::Running && + _tier == RenderTier::Full; + plan.headVisible = _snapshot.mode == ProgressMode::Indeterminate || _targetProgress > 0.0f; + if (!plan.visible) + { + plan.releaseAfterTransition = true; + } + + // Environment changes pause or resume compositor work; they do not + // replay semantic success/error/cancellation presentations. + _setTransition(plan.staticFallback ? _targetProgress : current, + _targetProgress, + now, + std::chrono::milliseconds::zero()); + _presenting = plan.visible; + return plan; + } + + RenderTransitionPlan Degrade(const RenderTimestamp now) noexcept + { + _tier = NextLowerRenderTier(_tier); + _environment.rendererAvailable = _tier != RenderTier::Disabled; + return RefreshEnvironment(_environment, now); + } + + void Tier(const RenderTier tier) noexcept + { + _tier = tier; + _environment.rendererAvailable = tier != RenderTier::Disabled; + } + + constexpr RenderTier Tier() const noexcept + { + return _tier; + } + + float CurrentProgress(const RenderTimestamp now) const noexcept + { + if (_transitionDuration <= std::chrono::milliseconds::zero() || now <= _transitionStart) + { + return _transitionDuration <= std::chrono::milliseconds::zero() ? _targetProgress : _fromProgress; + } + + const auto elapsed = now - _transitionStart; + if (elapsed >= _transitionDuration) + { + return _targetProgress; + } + + const auto ratio = static_cast(elapsed.count()) / static_cast(_transitionDuration.count()); + return std::clamp(_fromProgress + ((_targetProgress - _fromProgress) * ratio), 0.0f, 1.0f); + } + + constexpr bool Presenting() const noexcept + { + return _presenting; + } + + constexpr bool Closed() const noexcept + { + return _closed; + } + + void Close() noexcept + { + _closed = true; + _presenting = false; + _snapshot = {}; + _fromProgress = 0.0f; + _targetProgress = 0.0f; + _transitionDuration = std::chrono::milliseconds::zero(); + } + + private: + static constexpr float ProgressEpsilon{ 0.0001f }; + + static constexpr float _normalized(const uint8_t value) noexcept + { + return static_cast(std::min(value, 100)) / 100.0f; + } + + float _meaningfulProgress(const ProgressSnapshot& snapshot, const float current) const noexcept + { + if (snapshot.mode != ProgressMode::Determinate) + { + return current; + } + const auto normalized = _normalized(snapshot.value); + if (normalized <= ProgressEpsilon && _hadDeterminateValue) + { + return _lastDeterminateTarget; + } + return normalized; + } + + float _semanticTargetWhileHidden(const ProgressSnapshot& snapshot, const float current) noexcept + { + if (!snapshot.visible || snapshot.mode == ProgressMode::Hidden) + { + // Hidden is an ownership boundary, not merely a temporary + // presentation pause. A later command must not inherit the + // prior command's meaningful value or regression history. + _hadDeterminateValue = false; + _lastDeterminateTarget = 0.0f; + return 0.0f; + } + if (snapshot.status == ProgressStatus::Cancelled || snapshot.mode == ProgressMode::Indeterminate) + { + return current; + } + if (snapshot.status == ProgressStatus::Success) + { + return snapshot.mode == ProgressMode::Determinate ? 1.0f : current; + } + + const auto target = _meaningfulProgress(snapshot, current); + if (snapshot.mode == ProgressMode::Determinate && + (snapshot.status == ProgressStatus::Running || target > ProgressEpsilon)) + { + _lastDeterminateTarget = target; + _hadDeterminateValue = true; + } + return target; + } + + RenderTransitionPlan _basePlan(const ProgressSnapshot& snapshot, const float current) const noexcept + { + RenderTransitionPlan plan; + plan.tier = _tier; + plan.mode = snapshot.mode; + plan.status = snapshot.status; + plan.fromProgress = current; + plan.targetProgress = current; + return plan; + } + + void _setTransition(const float from, + const float target, + const RenderTimestamp now, + const std::chrono::milliseconds duration) noexcept + { + _fromProgress = std::clamp(from, 0.0f, 1.0f); + _targetProgress = std::clamp(target, 0.0f, 1.0f); + _transitionStart = now; + _transitionDuration = duration; + } + + RenderTransitionPlan _disabledPlan() const noexcept + { + RenderTransitionPlan plan; + plan.tier = RenderTier::Disabled; + plan.releaseAfterTransition = true; + return plan; + } + + RenderTier _tier{ RenderTier::Full }; + RenderEnvironment _environment{}; + ProgressSnapshot _snapshot{}; + RenderTimestamp _transitionStart{}; + std::chrono::milliseconds _transitionDuration{}; + float _fromProgress{}; + float _targetProgress{}; + float _lastDeterminateTarget{}; + bool _hadDeterminateValue{}; + bool _presenting{}; + bool _closed{}; + }; + + class SparkBudget final + { + public: + explicit SparkBudget(const uint8_t capacity = static_cast(RainbowArcVisualConstants::SparkCapacityPerWindowOrProcess)) noexcept : + _capacity{ std::min(capacity, static_cast(RainbowArcVisualConstants::SparkCapacityPerWindowOrProcess)) } + { + } + + SparkBudget(const SparkBudget&) = delete; + SparkBudget& operator=(const SparkBudget&) = delete; + + bool TryAcquire(const uint8_t count = 1) noexcept + { + if (count == 0) + { + return true; + } + + auto current = _live.load(std::memory_order_relaxed); + for (;;) + { + if (current > _capacity || count > static_cast(_capacity - current)) + { + return false; + } + if (_live.compare_exchange_weak(current, + static_cast(current + count), + std::memory_order_acq_rel, + std::memory_order_relaxed)) + { + return true; + } + } + } + + void Release(const uint8_t count = 1) noexcept + { + if (count == 0) + { + return; + } + + auto current = _live.load(std::memory_order_relaxed); + for (;;) + { + const auto next = current > count ? static_cast(current - count) : uint8_t{}; + if (_live.compare_exchange_weak(current, + next, + std::memory_order_acq_rel, + std::memory_order_relaxed)) + { + return; + } + } + } + + uint8_t Live() const noexcept + { + return _live.load(std::memory_order_acquire); + } + + constexpr uint8_t Capacity() const noexcept + { + return _capacity; + } + + private: + const uint8_t _capacity; + std::atomic _live{}; + }; + + struct SparkHandle + { + uint8_t slot{ std::numeric_limits::max() }; + uint32_t generation{}; + + constexpr explicit operator bool() const noexcept + { + return slot < RainbowArcVisualConstants::SparkPoolCapacityPerPane; + } + }; + + class SparkPool final + { + public: + explicit SparkPool(SparkBudget& budget) noexcept : + _budget{ &budget } + { + } + + ~SparkPool() + { + ReleaseAll(); + } + + SparkPool(const SparkPool&) = delete; + SparkPool& operator=(const SparkPool&) = delete; + + std::optional Acquire(const RenderTimestamp now, + std::chrono::milliseconds lifetime, + const bool persistent = false) noexcept + { + if (!_budget || _live >= RainbowArcVisualConstants::SparkPoolCapacityPerPane) + { + return std::nullopt; + } + + for (uint8_t index = 0; index < _slots.size(); ++index) + { + auto& slot = _slots[index]; + if (slot.active) + { + continue; + } + if (!_budget->TryAcquire()) + { + return std::nullopt; + } + + lifetime = std::clamp(lifetime, + RainbowArcVisualConstants::MinimumSparkLifetime, + RainbowArcVisualConstants::MaximumSparkLifetime); + slot.active = true; + slot.persistent = persistent; + slot.expiresAt = persistent ? RenderTimestamp::max() : now + lifetime; + ++slot.generation; + ++_live; + return SparkHandle{ index, slot.generation }; + } + return std::nullopt; + } + + bool Release(const SparkHandle handle) noexcept + { + if (!handle || handle.slot >= _slots.size()) + { + return false; + } + + auto& slot = _slots[handle.slot]; + if (!slot.active || slot.generation != handle.generation) + { + return false; + } + + slot.active = false; + slot.persistent = false; + slot.expiresAt = {}; + --_live; + if (_budget) + { + _budget->Release(); + } + return true; + } + + uint8_t ReleaseExpired(const RenderTimestamp now) noexcept + { + uint8_t released{}; + for (uint8_t index = 0; index < _slots.size(); ++index) + { + const auto& slot = _slots[index]; + if (slot.active && !slot.persistent && slot.expiresAt <= now) + { + const auto generation = slot.generation; + released += Release({ index, generation }) ? 1 : 0; + } + } + return released; + } + + void ReleaseAll() noexcept + { + if (!_budget || _live == 0) + { + return; + } + + const auto released = _live; + for (auto& slot : _slots) + { + slot.active = false; + slot.persistent = false; + slot.expiresAt = {}; + } + _live = 0; + _budget->Release(released); + } + + constexpr uint8_t Live() const noexcept + { + return _live; + } + + constexpr bool Full() const noexcept + { + return _live >= RainbowArcVisualConstants::SparkPoolCapacityPerPane; + } + + bool IsActive(const SparkHandle handle) const noexcept + { + return handle && + handle.slot < _slots.size() && + _slots[handle.slot].active && + _slots[handle.slot].generation == handle.generation; + } + + private: + struct Slot + { + RenderTimestamp expiresAt{}; + uint32_t generation{}; + bool active{}; + bool persistent{}; + }; + + SparkBudget* _budget{}; + std::array _slots{}; + uint8_t _live{}; + }; + + constexpr bool RequiresSparkWork(const RenderTransitionPlan& plan, const uint8_t liveSparkCount) noexcept + { + return plan.sparksEligible || plan.finalSparkBurst || liveSparkCount != 0; + } +} From 9ef140fdbc13bf3cdde8fc31ac9ad2bd4b23f37d Mon Sep 17 00:00:00 2001 From: HelloThisWorld Date: Sat, 1 Aug 2026 07:12:50 +0800 Subject: [PATCH 2/5] feat: add bounded CLI progress recognition providers --- .../VisualProgress/ProgressRecognition.h | 2082 +++++++++++++++++ .../VisualProgress/VisualProgressModel.h | 154 +- 2 files changed, 2233 insertions(+), 3 deletions(-) create mode 100644 src/winterm/VisualProgress/ProgressRecognition.h diff --git a/src/winterm/VisualProgress/ProgressRecognition.h b/src/winterm/VisualProgress/ProgressRecognition.h new file mode 100644 index 000000000..658e5b0be --- /dev/null +++ b/src/winterm/VisualProgress/ProgressRecognition.h @@ -0,0 +1,2082 @@ +// Copyright (c) winTerm contributors. +// Licensed under the MIT license. + +#pragma once + +#include "VisualProgressModel.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace winTerm::VisualProgress +{ + // Recognition runs on fresh terminal output only. The caller remains + // responsible for preserving the original chunk unless suppressInput is + // true. Suppression is deliberately stricter than recognition. + struct RecognitionOptions + { + bool replacementEnabled{}; + bool rendererEnabled{}; + bool normalScreen{ true }; + bool parserHealthy{ true }; + }; + + struct RecognitionResult + { + std::optional progress; + bool suppressInput{}; + bool overflow{}; + bool healthy{ true }; + bool accepted{ true }; + }; + + class RecognitionEngine final + { + public: + static constexpr size_t MaxChunkCodeUnits = 32 * 1024; + static constexpr size_t MaxCurrentLineCodeUnits = 2048; + static constexpr size_t MaxAnsiSequenceCodeUnits = 64; + static constexpr size_t RecentProgressCapacity = 8; + static constexpr size_t DockerLayerCapacity = 32; + static constexpr size_t BuildKitStepCapacity = 32; + static constexpr uint64_t PublicationIntervalMilliseconds = 50; + + RecognitionEngine() noexcept = default; + RecognitionEngine(const RecognitionEngine&) = delete; + RecognitionEngine& operator=(const RecognitionEngine&) = delete; + + // This is a non-blocking ingress boundary. If another thread owns the + // state lock, the chunk is not inspected and must be rendered normally. + // Missing one chunk desynchronizes the bounded parser until Reset/Clear. + RecognitionResult Consume(const std::wstring_view chunk, + const uint64_t timestampMilliseconds, + const RecognitionOptions options = {}) noexcept + { + std::unique_lock lock{ _mutex, std::try_to_lock }; + if (!lock.owns_lock()) + { + _desynchronized.store(true, std::memory_order_release); + return { std::nullopt, false, false, false, false }; + } + + RecognitionResult result; + CallState call; + call.cleanStart = _lineLength == 0 && + _ansiState == AnsiState::Ground && + !_pendingHighSurrogate; + call.columnKnownAtStart = _columnKnown; + call.columnZeroAtStart = _atColumnZero; + + if (_desynchronized.exchange(false, std::memory_order_acq_rel)) + { + _healthy = false; + } + + if (chunk.size() > MaxChunkCodeUnits) + { + _healthy = false; + _clearRecord(); + _ansiState = AnsiState::Ground; + _ansiLength = 0; + _pendingHighSurrogate = false; + _previousChunkEndedWithCarriageReturn = false; + result.overflow = true; + result.healthy = false; + return result; + } + + size_t index{}; + if (_previousChunkEndedWithCarriageReturn) + { + if (!chunk.empty() && chunk.front() == L'\n') + { + // The preceding CR was the first half of a split CRLF. + // Publish its structure as a newline record, but never as + // transient or suppressible output. + if (_pendingCarriageReturnProgress) + { + _pendingCarriageReturnProgress->transient = false; + _pendingCarriageReturnProgress->suppressible = false; + _rememberProgress(*_pendingCarriageReturnProgress); + ++call.recognizedRecords; + _pendingCarriageReturnProgress.reset(); + } + _previousChunkEndedWithCarriageReturn = false; + _columnKnown = true; + _atColumnZero = true; + index = 1; + call.cleanStart = false; + call.sawNewline = true; + } + else if (!chunk.empty()) + { + _acceptHeldCarriageReturn(call); + _previousChunkEndedWithCarriageReturn = false; + } + } + + for (; index < chunk.size(); ++index) + { + const auto codeUnit = chunk[index]; + const auto atEnd = index + 1 == chunk.size(); + + if (_pendingHighSurrogate) + { + if (_isLowSurrogate(codeUnit)) + { + if (_ansiState == AnsiState::Escape || _ansiState == AnsiState::Csi) + { + // UTF-16 surrogate pairs are valid OSC payload, + // but cannot occur in an escape introducer or CSI. + _recordMalformed = true; + call.malformed = true; + call.onlySafeContent = false; + _healthy = false; + _ansiState = _ansiState == AnsiState::Csi ? AnsiState::CsiDiscard : AnsiState::Ground; + _ansiLength = 0; + } + else + { + _consumeCodeUnit(_pendingHighSurrogateValue, false, call); + _consumeCodeUnit(codeUnit, atEnd, call); + } + _pendingHighSurrogate = false; + continue; + } + + _pendingHighSurrogate = false; + _recordMalformed = true; + call.malformed = true; + call.onlySafeContent = false; + _healthy = false; + } + + if (_isHighSurrogate(codeUnit)) + { + _pendingHighSurrogate = true; + _pendingHighSurrogateValue = codeUnit; + continue; + } + if (_isLowSurrogate(codeUnit)) + { + _recordMalformed = true; + call.malformed = true; + call.onlySafeContent = false; + _healthy = false; + continue; + } + + _consumeCodeUnit(codeUnit, atEnd, call); + } + + if (_pendingHighSurrogate) + { + // A split pair is healthy but makes this callback ineligible + // for immediate suppression. + call.onlySafeContent = false; + } + + result.progress = _takePublication(timestampMilliseconds); + result.overflow = call.overflow; + result.healthy = _healthy && !call.malformed && !call.overflow; + + const auto completeAtEnd = _lineLength == 0 && + _ansiState == AnsiState::Ground && + !_pendingHighSurrogate; + const auto immediateWholeChunk = call.cleanStart && + completeAtEnd && + call.nonEmptyRecords == 1 && + call.recognizedRecords == 1 && + call.onlySafeContent && + !call.sawNewline && + !call.ambiguousCarriageReturn && + call.suppressionCandidate.has_value(); + + result.suppressInput = immediateWholeChunk && + options.replacementEnabled && + options.rendererEnabled && + options.normalScreen && + options.parserHealthy && + result.healthy && + call.columnKnownAtStart && + call.columnZeroAtStart; + + if (result.suppressInput) + { + // The caller will not apply the chunk to the terminal, so keep + // our conservative cursor model aligned with the terminal. + _columnKnown = call.columnKnownAtStart; + _atColumnZero = call.columnZeroAtStart; + } + + return result; + } + + void Reset() noexcept + { + std::scoped_lock lock{ _mutex }; + _resetUnderLock(); + } + + void Clear() noexcept + { + Reset(); + } + + bool TryReset() noexcept + { + std::unique_lock lock{ _mutex, std::try_to_lock }; + if (!lock.owns_lock()) + { + _desynchronized.store(true, std::memory_order_release); + return false; + } + _resetUnderLock(); + return true; + } + + private: + enum class AnsiState : uint8_t + { + Ground, + Escape, + Csi, + CsiDiscard, + Osc, + OscEscape, + OscDiscard, + OscDiscardEscape, + }; + + enum class RecordEnding : uint8_t + { + CarriageReturn, + Newline, + }; + + struct CallState + { + std::optional suppressionCandidate; + size_t nonEmptyRecords{}; + size_t recognizedRecords{}; + bool cleanStart{}; + bool columnKnownAtStart{}; + bool columnZeroAtStart{}; + bool onlySafeContent{ true }; + bool sawNewline{}; + bool ambiguousCarriageReturn{}; + bool malformed{}; + bool overflow{}; + }; + + struct Match + { + ProviderProgress progress; + bool matched{}; + bool preserveOnly{}; + }; + + struct LayerState + { + uint64_t key{}; + bool used{}; + bool complete{}; + }; + + struct StepState + { + uint32_t id{}; + bool used{}; + bool complete{}; + }; + + struct Quantity + { + uint64_t milliBytes{}; + bool valid{}; + }; + + static constexpr bool _isHighSurrogate(const wchar_t value) noexcept + { + return value >= 0xD800 && value <= 0xDBFF; + } + + static constexpr bool _isLowSurrogate(const wchar_t value) noexcept + { + return value >= 0xDC00 && value <= 0xDFFF; + } + + static constexpr wchar_t _asciiLower(const wchar_t value) noexcept + { + return value >= L'A' && value <= L'Z' ? value + (L'a' - L'A') : value; + } + + static bool _equalsInsensitive(const std::wstring_view left, const std::wstring_view right) noexcept + { + if (left.size() != right.size()) + { + return false; + } + for (size_t i = 0; i < left.size(); ++i) + { + if (_asciiLower(left[i]) != _asciiLower(right[i])) + { + return false; + } + } + return true; + } + + static bool _startsWithInsensitive(const std::wstring_view value, const std::wstring_view prefix) noexcept + { + return value.size() >= prefix.size() && _equalsInsensitive(value.substr(0, prefix.size()), prefix); + } + + static bool _containsInsensitive(const std::wstring_view value, const std::wstring_view needle) noexcept + { + if (needle.empty()) + { + return true; + } + if (needle.size() > value.size()) + { + return false; + } + for (size_t i = 0; i + needle.size() <= value.size(); ++i) + { + if (_equalsInsensitive(value.substr(i, needle.size()), needle)) + { + return true; + } + } + return false; + } + + static bool _containsTokenInsensitive(const std::wstring_view value, const std::wstring_view token) noexcept + { + if (token.empty() || token.size() > value.size()) + { + return false; + } + const auto isWord = [](const wchar_t ch) noexcept { + return (ch >= L'a' && ch <= L'z') || + (ch >= L'A' && ch <= L'Z') || + (ch >= L'0' && ch <= L'9') || + ch == L'_'; + }; + for (size_t i = 0; i + token.size() <= value.size(); ++i) + { + if ((i != 0 && isWord(value[i - 1])) || + (i + token.size() != value.size() && isWord(value[i + token.size()]))) + { + continue; + } + if (_equalsInsensitive(value.substr(i, token.size()), token)) + { + return true; + } + } + return false; + } + + static std::wstring_view _trim(const std::wstring_view value) noexcept + { + size_t first{}; + while (first < value.size() && (value[first] == L' ' || value[first] == L'\t')) + { + ++first; + } + size_t last = value.size(); + while (last > first && (value[last - 1] == L' ' || value[last - 1] == L'\t')) + { + --last; + } + return value.substr(first, last - first); + } + + static bool _parseUnsigned(const std::wstring_view value, + const size_t first, + const size_t last, + uint64_t& parsed) noexcept + { + if (first >= last || last > value.size()) + { + return false; + } + uint64_t result{}; + for (size_t i = first; i < last; ++i) + { + const auto ch = value[i]; + if (ch < L'0' || ch > L'9') + { + return false; + } + const auto digit = static_cast(ch - L'0'); + if (result > (std::numeric_limits::max() - digit) / 10) + { + return false; + } + result = result * 10 + digit; + } + parsed = result; + return true; + } + + static std::optional _findPercent(const std::wstring_view value) noexcept + { + for (size_t percent = 1; percent < value.size(); ++percent) + { + if (value[percent] != L'%') + { + continue; + } + auto first = percent; + while (first > 0 && value[first - 1] >= L'0' && value[first - 1] <= L'9') + { + --first; + } + if (first > 0 && + (value[first - 1] == L'+' || value[first - 1] == L'-' || value[first - 1] == L'.')) + { + continue; + } + uint64_t parsed{}; + if (_parseUnsigned(value, first, percent, parsed) && parsed <= 100) + { + return static_cast(parsed); + } + } + return std::nullopt; + } + + static std::optional _findIntegerFraction(const std::wstring_view value) noexcept + { + for (size_t slash = 1; slash + 1 < value.size(); ++slash) + { + if (value[slash] != L'/') + { + continue; + } + auto leftFirst = slash; + while (leftFirst > 0 && value[leftFirst - 1] >= L'0' && value[leftFirst - 1] <= L'9') + { + --leftFirst; + } + auto rightLast = slash + 1; + while (rightLast < value.size() && value[rightLast] >= L'0' && value[rightLast] <= L'9') + { + ++rightLast; + } + uint64_t current{}; + uint64_t total{}; + if (_parseUnsigned(value, leftFirst, slash, current) && + _parseUnsigned(value, slash + 1, rightLast, total) && + total > 0 && current <= total) + { + return static_cast((static_cast(current) * 100.0L) / + static_cast(total)); + } + } + return std::nullopt; + } + + static uint64_t _unitMultiplier(const std::wstring_view unit) noexcept + { + if (unit.empty() || _equalsInsensitive(unit, L"b")) + { + return 1; + } + if (_equalsInsensitive(unit, L"kb") || _equalsInsensitive(unit, L"kib")) + { + return 1024ull; + } + if (_equalsInsensitive(unit, L"mb") || _equalsInsensitive(unit, L"mib")) + { + return 1024ull * 1024ull; + } + if (_equalsInsensitive(unit, L"gb") || _equalsInsensitive(unit, L"gib")) + { + return 1024ull * 1024ull * 1024ull; + } + if (_equalsInsensitive(unit, L"tb") || _equalsInsensitive(unit, L"tib")) + { + return 1024ull * 1024ull * 1024ull * 1024ull; + } + return 0; + } + + static Quantity _parseQuantity(const std::wstring_view value, + const size_t numberFirst, + const size_t numberLast, + const size_t unitFirst, + const size_t unitLast) noexcept + { + if (numberFirst >= numberLast || numberLast > value.size() || unitLast > value.size()) + { + return {}; + } + + uint64_t whole{}; + uint64_t fraction{}; + uint64_t fractionScale{ 1 }; + bool decimalSeen{}; + bool digitSeen{}; + for (size_t i = numberFirst; i < numberLast; ++i) + { + const auto ch = value[i]; + if (ch == L'.' && !decimalSeen) + { + decimalSeen = true; + continue; + } + if (ch < L'0' || ch > L'9') + { + return {}; + } + digitSeen = true; + const auto digit = static_cast(ch - L'0'); + if (!decimalSeen) + { + if (whole > (std::numeric_limits::max() - digit) / 10) + { + return {}; + } + whole = whole * 10 + digit; + } + else if (fractionScale < 1000) + { + fraction = fraction * 10 + digit; + fractionScale *= 10; + } + } + if (!digitSeen) + { + return {}; + } + + const auto multiplier = _unitMultiplier(value.substr(unitFirst, unitLast - unitFirst)); + if (multiplier == 0 || whole > std::numeric_limits::max() / multiplier / 1000) + { + return {}; + } + const auto wholeMilliBytes = whole * multiplier * 1000; + const auto fractionalMilliBytes = fractionScale > 1 ? + (fraction * multiplier * 1000) / fractionScale : + 0; + if (wholeMilliBytes > std::numeric_limits::max() - fractionalMilliBytes) + { + return {}; + } + return { wholeMilliBytes + fractionalMilliBytes, true }; + } + + static std::optional _findQuantityFraction(const std::wstring_view value) noexcept + { + for (size_t slash = 1; slash + 1 < value.size(); ++slash) + { + if (value[slash] != L'/') + { + continue; + } + + auto leftUnitFirst = slash; + while (leftUnitFirst > 0 && ((value[leftUnitFirst - 1] >= L'A' && value[leftUnitFirst - 1] <= L'Z') || + (value[leftUnitFirst - 1] >= L'a' && value[leftUnitFirst - 1] <= L'z'))) + { + --leftUnitFirst; + } + auto leftNumberFirst = leftUnitFirst; + while (leftNumberFirst > 0 && ((value[leftNumberFirst - 1] >= L'0' && value[leftNumberFirst - 1] <= L'9') || + value[leftNumberFirst - 1] == L'.')) + { + --leftNumberFirst; + } + + auto rightNumberLast = slash + 1; + while (rightNumberLast < value.size() && ((value[rightNumberLast] >= L'0' && value[rightNumberLast] <= L'9') || + value[rightNumberLast] == L'.')) + { + ++rightNumberLast; + } + auto rightUnitLast = rightNumberLast; + while (rightUnitLast < value.size() && ((value[rightUnitLast] >= L'A' && value[rightUnitLast] <= L'Z') || + (value[rightUnitLast] >= L'a' && value[rightUnitLast] <= L'z'))) + { + ++rightUnitLast; + } + + if (leftUnitFirst == slash || rightUnitLast == rightNumberLast) + { + continue; + } + + const auto current = _parseQuantity(value, leftNumberFirst, leftUnitFirst, leftUnitFirst, slash); + const auto total = _parseQuantity(value, slash + 1, rightNumberLast, rightNumberLast, rightUnitLast); + if (current.valid && total.valid && total.milliBytes > 0 && current.milliBytes <= total.milliBytes) + { + return static_cast((static_cast(current.milliBytes) * 100.0L) / + static_cast(total.milliBytes)); + } + } + return std::nullopt; + } + + static std::optional _realProgress(const std::wstring_view value) noexcept + { + if (const auto percent = _findPercent(value)) + { + return percent; + } + if (const auto quantity = _findQuantityFraction(value)) + { + return quantity; + } + return _findIntegerFraction(value); + } + + static bool _hasCurlMeterColumns(const std::wstring_view value, size_t cursor) noexcept + { + size_t numericColumns{ 1 }; + while (cursor < value.size() && numericColumns < 5) + { + while (cursor < value.size() && (value[cursor] == L' ' || value[cursor] == L'\t')) + { + ++cursor; + } + if (cursor == value.size() || value[cursor] < L'0' || value[cursor] > L'9') + { + return false; + } + while (cursor < value.size() && value[cursor] >= L'0' && value[cursor] <= L'9') + { + ++cursor; + } + ++numericColumns; + } + return numericColumns >= 5; + } + + static bool _isPreserveOnly(const std::wstring_view value) noexcept + { + static constexpr std::array terms{ + L"warning", L"warn", L"audit", L"error", L"fatal", L"failed", L"failure", L"traceback", L"exception", L"stack trace", L"password", L"authentication", L"permission denied", L"conflict", L"prompt", L"build success", L"build failure", L"build successful", L"successfully installed", L"saved to", L"npm err!", L"done.", L"confirm", L"continue?", L"[y/n]", L"yes/no", L"passphrase", L"username" + }; + for (const auto term : terms) + { + if (_containsInsensitive(value, term)) + { + return true; + } + } + return false; + } + + static bool _isInteractivePrompt(const std::wstring_view value) noexcept + { + static constexpr std::array terms{ + L"confirm", L"continue?", L"[y/n]", L"yes/no", L"password", L"passphrase", L"username" + }; + for (const auto term : terms) + { + if (_containsInsensitive(value, term)) + { + return true; + } + } + return false; + } + + static ProviderProgress _makeProgress(const ProgressProvider provider, + const ProgressMode mode, + const ProgressStatus status, + const uint8_t value, + const ProviderConfidence confidence, + const uint16_t stage) noexcept + { + ProviderProgress progress; + progress.provider = provider; + progress.mode = mode; + progress.status = status; + progress.value = value; + progress.confidence = confidence; + progress.visible = true; + progress.stage = stage; + return progress; + } + + static Match _runningMatch(const ProgressProvider provider, + const std::wstring_view line, + const ProviderConfidence confidence, + const uint16_t stage) noexcept + { + if (const auto value = _realProgress(line)) + { + return { _makeProgress(provider, ProgressMode::Determinate, ProgressStatus::Running, *value, confidence, stage), true, false }; + } + return { _makeProgress(provider, ProgressMode::Indeterminate, ProgressStatus::Running, 0, confidence, stage), true, false }; + } + + static uint64_t _hashTokenBeforeColon(const std::wstring_view line) noexcept + { + const auto colon = line.find(L':'); + if (colon == std::wstring_view::npos || colon == 0 || colon > 64) + { + return 0; + } + uint64_t hash{ 1469598103934665603ull }; + for (size_t i = 0; i < colon; ++i) + { + const auto ch = line[i]; + if (ch == L' ' || ch == L'\t') + { + return 0; + } + hash ^= static_cast(ch); + hash *= 1099511628211ull; + } + return hash == 0 ? 1 : hash; + } + + std::optional _updateDockerLayer(const std::wstring_view line, + const bool complete, + CallState& call) noexcept + { + const auto key = _hashTokenBeforeColon(line); + if (key == 0) + { + return std::nullopt; + } + + LayerState* selected{}; + for (auto& layer : _dockerLayers) + { + if (layer.used && layer.key == key) + { + selected = &layer; + break; + } + if (!layer.used && !selected) + { + selected = &layer; + } + } + if (!selected) + { + call.overflow = true; + return std::nullopt; + } + selected->used = true; + selected->key = key; + selected->complete = selected->complete || complete; + + size_t used{}; + size_t completed{}; + for (const auto& layer : _dockerLayers) + { + if (layer.used) + { + ++used; + completed += layer.complete ? 1 : 0; + } + } + return used == 0 ? std::nullopt : + std::optional{ static_cast((completed * 100) / used) }; + } + + Match _matchDockerPull(const std::wstring_view line, CallState& call) noexcept + { + const auto preserve = _isPreserveOnly(line); + const auto layerLine = _hashTokenBeforeColon(line) != 0; + if (_containsInsensitive(line, L"error response from daemon") || + _startsWithInsensitive(line, L"docker: error")) + { + return { _makeProgress(ProgressProvider::DockerPull, ProgressMode::Determinate, ProgressStatus::Error, 0, ProviderConfidence::High, 8), true, true }; + } + if (layerLine && _containsInsensitive(line, L"pulling fs layer")) + { + _updateDockerLayer(line, false, call); + return _runningMatch(ProgressProvider::DockerPull, line, ProviderConfidence::High, 1); + } + if (layerLine && _containsInsensitive(line, L": waiting")) + { + _updateDockerLayer(line, false, call); + auto match = _runningMatch(ProgressProvider::DockerPull, line, ProviderConfidence::High, 2); + match.progress.status = ProgressStatus::Waiting; + return match; + } + if (layerLine && _containsInsensitive(line, L": downloading")) + { + _updateDockerLayer(line, false, call); + return _runningMatch(ProgressProvider::DockerPull, line, ProviderConfidence::High, 3); + } + if (layerLine && _containsInsensitive(line, L"verifying checksum")) + { + return _runningMatch(ProgressProvider::DockerPull, line, ProviderConfidence::High, 4); + } + if (layerLine && _containsInsensitive(line, L"download complete")) + { + return _runningMatch(ProgressProvider::DockerPull, line, ProviderConfidence::High, 5); + } + if (layerLine && _containsInsensitive(line, L": extracting")) + { + return _runningMatch(ProgressProvider::DockerPull, line, ProviderConfidence::High, 6); + } + if (layerLine && _containsInsensitive(line, L"pull complete")) + { + const auto aggregate = _updateDockerLayer(line, true, call); + return { _makeProgress(ProgressProvider::DockerPull, + aggregate ? ProgressMode::Determinate : ProgressMode::Indeterminate, + ProgressStatus::Running, + aggregate.value_or(0), + ProviderConfidence::High, + 7), + true, + preserve }; + } + if (_containsInsensitive(line, L"downloaded newer image") || + _containsInsensitive(line, L"image is up to date")) + { + return { _makeProgress(ProgressProvider::DockerPull, ProgressMode::Determinate, ProgressStatus::Success, 100, ProviderConfidence::High, 8), true, true }; + } + return {}; + } + + static std::optional _buildKitStepId(const std::wstring_view line) noexcept + { + const auto trimmed = _trim(line); + if (trimmed.size() < 2 || trimmed.front() != L'#') + { + return std::nullopt; + } + size_t last = 1; + while (last < trimmed.size() && trimmed[last] >= L'0' && trimmed[last] <= L'9') + { + ++last; + } + uint64_t parsed{}; + if (!_parseUnsigned(trimmed, 1, last, parsed) || parsed > std::numeric_limits::max()) + { + return std::nullopt; + } + return static_cast(parsed); + } + + void _updateBuildKitStep(const uint32_t id, const bool complete, CallState& call) noexcept + { + StepState* selected{}; + for (auto& step : _buildKitSteps) + { + if (step.used && step.id == id) + { + selected = &step; + break; + } + if (!step.used && !selected) + { + selected = &step; + } + } + if (!selected) + { + call.overflow = true; + return; + } + selected->used = true; + selected->id = id; + selected->complete = selected->complete || complete; + } + + Match _matchBuildKit(const std::wstring_view line, CallState& call) noexcept + { + const auto id = _buildKitStepId(line); + if (!id) + { + return {}; + } + const auto isError = _containsInsensitive(line, L" error") || _containsInsensitive(line, L"error:"); + const auto isDone = _containsInsensitive(line, L" done") || _containsInsensitive(line, L" cached"); + _updateBuildKitStep(*id, isDone, call); + if (isError) + { + return { _makeProgress(ProgressProvider::DockerBuildKit, ProgressMode::Determinate, ProgressStatus::Error, 0, ProviderConfidence::High, 8), true, true }; + } + if (isDone) + { + return { _makeProgress(ProgressProvider::DockerBuildKit, ProgressMode::Determinate, ProgressStatus::Running, 100, ProviderConfidence::High, 7), true, true }; + } + uint16_t stage{ 1 }; + if (_containsInsensitive(line, L"transferring context")) + stage = 2; + else if (_containsInsensitive(line, L"loading metadata")) + stage = 3; + else if (_containsInsensitive(line, L"exporting layers")) + stage = 4; + else if (_containsInsensitive(line, L"writing image")) + stage = 5; + auto match = _runningMatch(ProgressProvider::DockerBuildKit, line, ProviderConfidence::High, stage); + match.preserveOnly = true; + return match; + } + + Match _matchPip(const std::wstring_view line) const noexcept + { + const auto trimmed = _trim(line); + const auto transferShape = (_containsInsensitive(line, L"kb/s") || + _containsInsensitive(line, L"mb/s") || + _containsInsensitive(line, L"gb/s") || + _containsInsensitive(line, L"eta")) && + (_findPercent(line).has_value() || + _findQuantityFraction(line).has_value() || + _findIntegerFraction(line).has_value()); + const auto pipSignature = _containsInsensitive(line, L"pip ") || + _startsWithInsensitive(trimmed, L"collecting ") || + _containsInsensitive(line, L"installing collected packages"); + // A bare "Downloading" transfer line is not owned by pip. Only a + // prior pip claim, an explicit pip signature, or Python's wheel + // archive format is strong enough to make replacement eligible. + const auto pipArchive = _containsInsensitive(line, L".whl"); + const auto pipContext = _claimedProvider == ProgressProvider::Pip || pipSignature || pipArchive; + if (pipContext && + (_startsWithInsensitive(trimmed, L"error:") || _containsInsensitive(line, L"subprocess-exited-with-error"))) + { + return { _makeProgress(ProgressProvider::Pip, ProgressMode::Determinate, ProgressStatus::Error, 0, ProviderConfidence::High, 6), true, true }; + } + if (!pipContext || (!transferShape && !pipSignature)) + { + return {}; + } + const auto confidence = transferShape ? ProviderConfidence::High : ProviderConfidence::Medium; + return _runningMatch(ProgressProvider::Pip, line, confidence, _containsInsensitive(line, L"installing") ? 2 : 1); + } + + Match _matchGit(const std::wstring_view line) const noexcept + { + static constexpr std::array phases{ + L"counting objects:", L"compressing objects:", L"receiving objects:", L"resolving deltas:", L"updating files:" + }; + const auto trimmed = _trim(line); + if ((_claimedProvider == ProgressProvider::Git || _startsWithInsensitive(trimmed, L"fatal:")) && + (_startsWithInsensitive(trimmed, L"fatal:") || _containsInsensitive(line, L"authentication failed"))) + { + return { _makeProgress(ProgressProvider::Git, ProgressMode::Determinate, ProgressStatus::Error, 0, ProviderConfidence::High, 7), true, true }; + } + + auto candidate = trimmed; + const auto remote = _startsWithInsensitive(candidate, L"remote:"); + if (remote) + { + candidate = _trim(candidate.substr(7)); + } + for (uint16_t i = 0; i < phases.size(); ++i) + { + if (_startsWithInsensitive(candidate, phases[i])) + { + auto match = _runningMatch(ProgressProvider::Git, candidate, ProviderConfidence::High, static_cast(i + 1)); + match.preserveOnly = remote || _isPreserveOnly(candidate); + return match; + } + } + return {}; + } + + Match _matchCurl(const std::wstring_view line) noexcept + { + if (_containsInsensitive(line, L"% total") && _containsInsensitive(line, L"% received")) + { + _curlHeaderSeen = true; + return { _makeProgress(ProgressProvider::Curl, ProgressMode::Indeterminate, ProgressStatus::Running, 0, ProviderConfidence::High, 1), true, true }; + } + if (_startsWithInsensitive(_trim(line), L"curl: (")) + { + _curlHeaderSeen = false; + return { _makeProgress(ProgressProvider::Curl, ProgressMode::Determinate, ProgressStatus::Error, 0, ProviderConfidence::High, 3), true, true }; + } + if (!_curlHeaderSeen) + { + return {}; + } + + const auto trimmed = _trim(line); + size_t last{}; + while (last < trimmed.size() && trimmed[last] >= L'0' && trimmed[last] <= L'9') + { + ++last; + } + uint64_t percent{}; + if (last == 0 || !_hasCurlMeterColumns(trimmed, last) || + !_parseUnsigned(trimmed, 0, last, percent) || percent > 100) + { + return {}; + } + if (percent == 100) + { + _curlHeaderSeen = false; + } + return { _makeProgress(ProgressProvider::Curl, + ProgressMode::Determinate, + percent == 100 ? ProgressStatus::Success : ProgressStatus::Running, + static_cast(percent), + ProviderConfidence::High, + 2), + true, + false }; + } + + Match _matchWget(const std::wstring_view line) noexcept + { + const auto trimmed = _trim(line); + const auto explicitAnchor = _containsInsensitive(line, L"wget ") || + _startsWithInsensitive(trimmed, L"wget:") || + _startsWithInsensitive(trimmed, L"saving to:"); + _wgetAnchorSeen = _wgetAnchorSeen || explicitAnchor; + if ((_claimedProvider == ProgressProvider::Wget || _wgetAnchorSeen) && + (_containsInsensitive(line, L"unable to resolve") || + _containsInsensitive(line, L"certificate") || + _containsInsensitive(line, L"server returned error"))) + { + _wgetAnchorSeen = false; + return { _makeProgress(ProgressProvider::Wget, ProgressMode::Determinate, ProgressStatus::Error, 0, ProviderConfidence::High, 3), true, true }; + } + const auto bracket = line.find(L"%["); + const auto bracketMeter = bracket != std::wstring_view::npos && + line.find(L']', bracket + 2) != std::wstring_view::npos; + const auto transferShape = _findPercent(line).has_value() && + (_containsInsensitive(line, L"eta") || + _containsInsensitive(line, L"kb/s") || + _containsInsensitive(line, L"mb/s")); + const auto wgetContext = _claimedProvider == ProgressProvider::Wget || + _wgetAnchorSeen; + // A bracket-shaped meter by itself is not provider ownership. + // Require a strong wget anchor before this built-in provider can + // classify the record; otherwise the generic overlay-only path + // remains available and the terminal text is preserved. + if (!transferShape || !bracketMeter || !wgetContext) + { + return {}; + } + auto match = _runningMatch(ProgressProvider::Wget, line, ProviderConfidence::High, 2); + if (match.progress.mode == ProgressMode::Determinate && match.progress.value == 100) + { + match.progress.status = ProgressStatus::Success; + _wgetAnchorSeen = false; + } + match.preserveOnly = _isPreserveOnly(line); + return match; + } + + Match _matchPackageManager(const std::wstring_view line, const ProgressProvider provider) const noexcept + { + std::wstring_view anchor; + switch (provider) + { + case ProgressProvider::Npm: + anchor = L"npm"; + break; + case ProgressProvider::Pnpm: + anchor = L"pnpm"; + break; + case ProgressProvider::Yarn: + anchor = L"yarn"; + break; + default: + return {}; + } + const auto claimed = _claimedProvider == provider; + if (!claimed && !_containsTokenInsensitive(line, anchor)) + { + return {}; + } + if (_containsInsensitive(line, L"err!") || + _containsInsensitive(line, L"failed") || + _containsInsensitive(line, L"error")) + { + return { _makeProgress(provider, ProgressMode::Determinate, ProgressStatus::Error, 0, ProviderConfidence::High, 8), true, true }; + } + + uint16_t stage{}; + if (_containsInsensitive(line, L"resolv")) + stage = 1; + else if (_containsInsensitive(line, L"fetch") || _containsInsensitive(line, L"download")) + stage = 2; + else if (_containsInsensitive(line, L"link")) + stage = 3; + else if (_containsInsensitive(line, L"build")) + stage = 4; + else if (_containsInsensitive(line, L"postinstall")) + stage = 5; + else if (_containsInsensitive(line, L"completed") || _containsInsensitive(line, L"done")) + stage = 6; + if (stage == 0) + { + return {}; + } + auto match = _runningMatch(provider, line, claimed ? ProviderConfidence::High : ProviderConfidence::Medium, stage); + if (stage == 6) + { + match.progress.mode = ProgressMode::Determinate; + match.progress.status = ProgressStatus::Success; + match.progress.value = 100; + } + match.preserveOnly = true; + return match; + } + + Match _matchNvm(const std::wstring_view line) const noexcept + { + const auto anchor = _containsInsensitive(line, L"nvm") || + _containsInsensitive(line, L"downloading node.js") || + _containsInsensitive(line, L"downloading npm") || + _containsInsensitive(line, L"now using node"); + if (!anchor && _claimedProvider != ProgressProvider::Nvm) + { + return {}; + } + if (_containsInsensitive(line, L"failed") || _containsInsensitive(line, L"error")) + { + return { _makeProgress(ProgressProvider::Nvm, ProgressMode::Determinate, ProgressStatus::Error, 0, ProviderConfidence::High, 7), true, true }; + } + uint16_t stage{}; + if (_containsInsensitive(line, L"downloading node")) + stage = 1; + else if (_containsInsensitive(line, L"downloading npm")) + stage = 2; + else if (_containsInsensitive(line, L"extract")) + stage = 3; + else if (_containsInsensitive(line, L"install")) + stage = 4; + else if (_containsInsensitive(line, L"now using") || _containsInsensitive(line, L"switch")) + stage = 5; + if (stage == 0) + { + return {}; + } + auto match = _runningMatch(ProgressProvider::Nvm, line, ProviderConfidence::High, stage); + if (_containsInsensitive(line, L"installation complete") || _containsInsensitive(line, L"now using")) + { + match.progress.mode = ProgressMode::Determinate; + match.progress.status = ProgressStatus::Success; + match.progress.value = 100; + } + match.preserveOnly = true; + return match; + } + + Match _matchMaven(const std::wstring_view line) const noexcept + { + const auto trimmed = _trim(line); + const auto taggedInfo = _startsWithInsensitive(trimmed, L"[info]"); + const auto taggedError = _startsWithInsensitive(trimmed, L"[error]"); + const auto mavenAnchor = taggedInfo || taggedError || _claimedProvider == ProgressProvider::Maven; + if (!mavenAnchor && _claimedProvider != ProgressProvider::Maven) + { + return {}; + } + if (_containsInsensitive(line, L"build failure") || taggedError) + { + return { _makeProgress(ProgressProvider::Maven, ProgressMode::Determinate, ProgressStatus::Error, 0, ProviderConfidence::High, 8), true, true }; + } + if (_containsInsensitive(line, L"build success")) + { + return { _makeProgress(ProgressProvider::Maven, ProgressMode::Determinate, ProgressStatus::Success, 100, ProviderConfidence::High, 7), true, true }; + } + uint16_t stage{}; + if (_containsInsensitive(line, L"downloading from") || + _containsInsensitive(line, L"downloaded from") || + _containsInsensitive(line, L"progress (")) + stage = 1; + else if (_containsInsensitive(line, L"compile")) + stage = 2; + else if (_containsInsensitive(line, L"test")) + stage = 3; + else if (_containsInsensitive(line, L"package")) + stage = 4; + else if (_containsInsensitive(line, L"install")) + stage = 5; + if (stage == 0) + { + return {}; + } + auto match = _runningMatch(ProgressProvider::Maven, line, ProviderConfidence::High, stage); + match.preserveOnly = true; + return match; + } + + Match _matchGradle(const std::wstring_view line) const noexcept + { + const auto anchor = _containsInsensitive(line, L"executing") || + _startsWithInsensitive(_trim(line), L"> task") || + _containsInsensitive(line, L"gradle") || + _containsInsensitive(line, L"build successful") || + _containsInsensitive(line, L"build failed"); + if (!anchor && _claimedProvider != ProgressProvider::Gradle) + { + return {}; + } + if (_containsInsensitive(line, L"build failed") || _containsInsensitive(line, L"failure:")) + { + return { _makeProgress(ProgressProvider::Gradle, ProgressMode::Determinate, ProgressStatus::Error, 0, ProviderConfidence::High, 5), true, true }; + } + if (_containsInsensitive(line, L"build successful")) + { + return { _makeProgress(ProgressProvider::Gradle, ProgressMode::Determinate, ProgressStatus::Success, 100, ProviderConfidence::High, 4), true, true }; + } + uint16_t stage = _containsInsensitive(line, L"download") ? 2 : + _startsWithInsensitive(_trim(line), L"> task") ? 3 : + 1; + auto match = _runningMatch(ProgressProvider::Gradle, line, ProviderConfidence::High, stage); + match.preserveOnly = true; + return match; + } + + Match _matchGeneric(const std::wstring_view line) noexcept + { + const auto trimmed = _trim(line); + if (trimmed.empty() || _isPreserveOnly(trimmed)) + { + _genericMatchStreak = 0; + return {}; + } + + size_t first{}; + if (trimmed.size() > 1 && + (trimmed.front() == L'|' || trimmed.front() == L'/' || + trimmed.front() == L'-' || trimmed.front() == L'\\')) + { + const auto signedNumber = trimmed.front() == L'-' && + trimmed[1] >= L'0' && trimmed[1] <= L'9'; + if (!signedNumber) + { + first = 1; + while (first < trimmed.size() && trimmed[first] == L' ') + { + ++first; + } + } + } + + bool anchored{}; + size_t digits = first; + while (digits < trimmed.size() && trimmed[digits] >= L'0' && trimmed[digits] <= L'9') + { + ++digits; + } + anchored = digits > first && digits < trimmed.size() && trimmed[digits] == L'%'; + if (!anchored) + { + const auto slash = trimmed.find(L'/', first); + anchored = slash != std::wstring_view::npos && slash > first && + (_findIntegerFraction(trimmed.substr(first)).has_value() || + _findQuantityFraction(trimmed.substr(first)).has_value()); + } + const auto realProgress = _realProgress(trimmed); + if (!anchored || !realProgress) + { + _genericMatchStreak = 0; + return {}; + } + + _genericMatchStreak = static_cast(_genericMatchStreak < 2 ? _genericMatchStreak + 1 : 2); + auto match = Match{ _makeProgress(ProgressProvider::Generic, + ProgressMode::Determinate, + ProgressStatus::Running, + *realProgress, + _genericMatchStreak >= 2 ? ProviderConfidence::Medium : ProviderConfidence::Low, + 1), + true, + false }; + match.progress.suppressible = false; + return match; + } + + Match _recognize(const std::wstring_view line, CallState& call) noexcept + { + // Interactive text is an output barrier, even when it happens to + // contain a percentage or transfer-rate shape. + if (_isInteractivePrompt(line)) + { + _genericMatchStreak = 0; + return {}; + } + + Match match; + switch (_claimedProvider) + { + case ProgressProvider::DockerPull: + match = _matchDockerPull(line, call); + break; + case ProgressProvider::DockerBuildKit: + match = _matchBuildKit(line, call); + break; + case ProgressProvider::Pip: + match = _matchPip(line); + break; + case ProgressProvider::Git: + match = _matchGit(line); + break; + case ProgressProvider::Curl: + match = _matchCurl(line); + break; + case ProgressProvider::Wget: + match = _matchWget(line); + break; + case ProgressProvider::Npm: + case ProgressProvider::Pnpm: + case ProgressProvider::Yarn: + match = _matchPackageManager(line, _claimedProvider); + break; + case ProgressProvider::Nvm: + match = _matchNvm(line); + break; + case ProgressProvider::Maven: + match = _matchMaven(line); + break; + case ProgressProvider::Gradle: + match = _matchGradle(line); + break; + default: + break; + } + if (match.matched) + { + return match; + } + + if ((match = _matchBuildKit(line, call)).matched || + (match = _matchDockerPull(line, call)).matched || + (match = _matchGit(line)).matched || + (match = _matchCurl(line)).matched || + (match = _matchWget(line)).matched || + (match = _matchNvm(line)).matched || + (match = _matchPip(line)).matched || + (match = _matchMaven(line)).matched || + (match = _matchGradle(line)).matched || + (match = _matchPackageManager(line, ProgressProvider::Pnpm)).matched || + (match = _matchPackageManager(line, ProgressProvider::Yarn)).matched || + (match = _matchPackageManager(line, ProgressProvider::Npm)).matched) + { + return match; + } + return _matchGeneric(line); + } + + static bool _sameProgress(const ProviderProgress& left, const ProviderProgress& right) noexcept + { + return left.provider == right.provider && + left.mode == right.mode && + left.status == right.status && + left.value == right.value && + left.confidence == right.confidence && + left.visible == right.visible && + left.transient == right.transient && + left.suppressible == right.suppressible && + left.stage == right.stage; + } + + void _rememberProgress(ProviderProgress progress) noexcept + { + if (_lastSeen && _sameProgress(*_lastSeen, progress)) + { + return; + } + progress.sequence = ++_sequence; + _lastSeen = progress; + _pendingPublication = progress; + _recentProgress[_recentProgressNext] = progress; + _recentProgressNext = (_recentProgressNext + 1) % _recentProgress.size(); + if (_recentProgressCount < _recentProgress.size()) + { + ++_recentProgressCount; + } + } + + void _acceptHeldCarriageReturn(CallState& call) noexcept + { + if (_pendingCarriageReturnProgress) + { + _rememberProgress(*_pendingCarriageReturnProgress); + ++call.recognizedRecords; + _pendingCarriageReturnProgress.reset(); + } + } + + std::optional _takePublication(const uint64_t timestampMilliseconds) noexcept + { + if (!_pendingPublication) + { + return std::nullopt; + } + + const auto terminalState = _pendingPublication->status == ProgressStatus::Success || + _pendingPublication->status == ProgressStatus::Error || + _pendingPublication->status == ProgressStatus::Cancelled; + const auto due = !_hasPublished || + timestampMilliseconds < _lastPublishedTimestamp || + timestampMilliseconds - _lastPublishedTimestamp >= PublicationIntervalMilliseconds; + if (!due && !terminalState) + { + return std::nullopt; + } + + auto latest = _pendingPublication; + _pendingPublication.reset(); + _lastPublishedTimestamp = timestampMilliseconds; + _hasPublished = true; + return latest; + } + + void _finalizeRecord(const RecordEnding ending, + const bool ambiguousCarriageReturn, + CallState& call) noexcept + { + const auto line = _trim(std::wstring_view{ _line.data(), _lineLength }); + if (!line.empty()) + { + ++call.nonEmptyRecords; + // Once a record exceeds a hard bound or contains malformed + // UTF-16, its retained prefix is not a valid recognition + // candidate. Preserve it and resume only at the next record. + auto match = _recordOverflow || _recordMalformed ? Match{} : _recognize(line, call); + if (match.matched) + { + auto& progress = match.progress; + const auto preserveOnly = match.preserveOnly || _isPreserveOnly(line); + progress.transient = ending == RecordEnding::CarriageReturn || _recordHadEraseLine; + progress.suppressible = progress.transient && + progress.mode == ProgressMode::Determinate && + progress.confidence == ProviderConfidence::High && + !preserveOnly && + !_recordUnsafe && + !_recordMalformed && + !_recordOverflow && + (progress.provider == ProgressProvider::Pip || + progress.provider == ProgressProvider::Git || + progress.provider == ProgressProvider::Curl || + progress.provider == ProgressProvider::Wget); + + const auto terminalState = progress.status == ProgressStatus::Success || + progress.status == ProgressStatus::Error || + progress.status == ProgressStatus::Cancelled; + const auto failedTerminalState = progress.status == ProgressStatus::Error || + progress.status == ProgressStatus::Cancelled; + if (failedTerminalState) + { + // Preserve the failure publication below, but do not + // let ownership or bounded provider tables leak into a + // later command after Error/Cancelled. + _clearProviderContext(); + } + else if (progress.provider != ProgressProvider::Generic && !terminalState) + { + _claimedProvider = progress.provider; + } + else + { + _claimedProvider = ProgressProvider::None; + } + + if (ambiguousCarriageReturn) + { + _pendingCarriageReturnProgress = progress; + call.ambiguousCarriageReturn = true; + } + else + { + _rememberProgress(progress); + ++call.recognizedRecords; + } + + if (progress.suppressible && !ambiguousCarriageReturn) + { + call.suppressionCandidate = progress; + } + else + { + call.onlySafeContent = false; + } + } + else + { + call.onlySafeContent = false; + _genericMatchStreak = 0; + _claimedProvider = ProgressProvider::None; + } + } + + if (_recordUnsafe || _recordMalformed || _recordOverflow) + { + call.onlySafeContent = false; + } + if (_recordMalformed) + { + call.malformed = true; + } + if (_recordOverflow) + { + call.overflow = true; + } + if (ending == RecordEnding::Newline) + { + call.sawNewline = true; + call.onlySafeContent = false; + } + _clearRecord(); + } + + void _appendCodeUnit(const wchar_t codeUnit, CallState& call) noexcept + { + if (_lineLength >= _line.size()) + { + _recordOverflow = true; + call.overflow = true; + call.onlySafeContent = false; + return; + } + _line[_lineLength++] = codeUnit; + if (codeUnit >= L' ') + { + _atColumnZero = false; + } + } + + void _beginAnsi(const AnsiState state, const wchar_t introducer, CallState& call) noexcept + { + _ansiState = state; + _ansiLength = 0; + _ansi[_ansiLength++] = introducer; + if (state == AnsiState::Osc) + { + _recordUnsafe = true; + call.onlySafeContent = false; + } + } + + void _appendAnsi(const wchar_t codeUnit, CallState& call) noexcept + { + if (_ansiLength >= _ansi.size()) + { + call.overflow = true; + call.onlySafeContent = false; + _recordOverflow = true; + _ansiState = _ansiState == AnsiState::Osc || _ansiState == AnsiState::OscEscape ? + AnsiState::OscDiscard : + AnsiState::CsiDiscard; + return; + } + _ansi[_ansiLength++] = codeUnit; + } + + void _finishCsi(CallState& call) noexcept + { + if (_ansiLength == 0) + { + _recordMalformed = true; + call.malformed = true; + call.onlySafeContent = false; + _healthy = false; + _ansiState = AnsiState::Ground; + return; + } + const auto final = _ansi[_ansiLength - 1]; + if (final == L'm') + { + // SGR changes presentation state. It remains recognizable, + // but hiding the callback would change subsequent rendering. + _recordUnsafe = true; + call.onlySafeContent = false; + } + else if (final == L'K') + { + const auto parameterFirst = _ansi.front() == 0x1b ? 2u : 1u; + const auto parameterCount = _ansiLength - parameterFirst - 1; + const auto supported = parameterCount == 0 || + (parameterCount == 1 && + _ansi[parameterFirst] >= L'0' && + _ansi[parameterFirst] <= L'2'); + if (supported) + { + _recordHadEraseLine = true; + } + else + { + _recordUnsafe = true; + call.onlySafeContent = false; + } + } + else + { + // Valid but cursor-affecting or otherwise unmodelled CSI is + // overlay-only for the entire record. + _recordUnsafe = true; + call.onlySafeContent = false; + _columnKnown = false; + _atColumnZero = false; + } + _ansiState = AnsiState::Ground; + _ansiLength = 0; + } + + void _consumeCodeUnit(const wchar_t codeUnit, const bool atEnd, CallState& call) noexcept + { + switch (_ansiState) + { + case AnsiState::Ground: + if (codeUnit == 0x1b) + { + _beginAnsi(AnsiState::Escape, codeUnit, call); + } + else if (codeUnit == 0x9b) + { + _beginAnsi(AnsiState::Csi, codeUnit, call); + } + else if (codeUnit == 0x9d) + { + _beginAnsi(AnsiState::Osc, codeUnit, call); + } + else if (codeUnit == L'\r') + { + _finalizeRecord(RecordEnding::CarriageReturn, atEnd, call); + _columnKnown = true; + _atColumnZero = true; + _previousChunkEndedWithCarriageReturn = atEnd; + } + else if (codeUnit == L'\n') + { + _finalizeRecord(RecordEnding::Newline, false, call); + // LF does not necessarily imply carriage return in every + // terminal mode. Retain zero only when it was already known. + if (!_atColumnZero) + { + _columnKnown = false; + } + } + else if (codeUnit < L' ' || codeUnit == 0x7f) + { + _recordUnsafe = true; + call.onlySafeContent = false; + if (codeUnit == L'\b' || codeUnit == L'\t') + { + _columnKnown = false; + _atColumnZero = false; + } + } + else + { + _appendCodeUnit(codeUnit, call); + } + break; + + case AnsiState::Escape: + _appendAnsi(codeUnit, call); + if (codeUnit == L'[') + { + _ansiState = AnsiState::Csi; + } + else if (codeUnit == L']') + { + _ansiState = AnsiState::Osc; + _recordUnsafe = true; + call.onlySafeContent = false; + } + else + { + _recordUnsafe = true; + call.onlySafeContent = false; + _columnKnown = false; + _atColumnZero = false; + _ansiState = AnsiState::Ground; + _ansiLength = 0; + } + break; + + case AnsiState::Csi: + _appendAnsi(codeUnit, call); + if (_ansiState == AnsiState::CsiDiscard) + { + break; + } + if (codeUnit >= 0x40 && codeUnit <= 0x7e) + { + _finishCsi(call); + } + else if (codeUnit < 0x20 || codeUnit > 0x3f) + { + _recordMalformed = true; + call.malformed = true; + call.onlySafeContent = false; + _healthy = false; + _ansiState = AnsiState::Ground; + _ansiLength = 0; + } + break; + + case AnsiState::CsiDiscard: + if (codeUnit >= 0x40 && codeUnit <= 0x7e) + { + _ansiState = AnsiState::Ground; + _ansiLength = 0; + } + break; + + case AnsiState::Osc: + _appendAnsi(codeUnit, call); + if (_ansiState == AnsiState::OscDiscard) + { + break; + } + if (codeUnit == 0x07) + { + _ansiState = AnsiState::Ground; + _ansiLength = 0; + } + else if (codeUnit == 0x1b) + { + _ansiState = AnsiState::OscEscape; + } + break; + + case AnsiState::OscEscape: + _appendAnsi(codeUnit, call); + if (_ansiState == AnsiState::OscDiscard) + { + break; + } + if (codeUnit == L'\\') + { + _ansiState = AnsiState::Ground; + _ansiLength = 0; + } + else + { + _ansiState = AnsiState::Osc; + } + break; + + case AnsiState::OscDiscard: + if (codeUnit == 0x07) + { + _ansiState = AnsiState::Ground; + _ansiLength = 0; + } + else if (codeUnit == 0x1b) + { + _ansiState = AnsiState::OscDiscardEscape; + } + break; + + case AnsiState::OscDiscardEscape: + _ansiState = codeUnit == L'\\' ? AnsiState::Ground : AnsiState::OscDiscard; + if (_ansiState == AnsiState::Ground) + { + _ansiLength = 0; + } + break; + } + } + + void _clearRecord() noexcept + { + _lineLength = 0; + _recordHadEraseLine = false; + _recordUnsafe = false; + _recordMalformed = false; + _recordOverflow = false; + } + + void _clearProviderContext() noexcept + { + _claimedProvider = ProgressProvider::None; + _curlHeaderSeen = false; + _wgetAnchorSeen = false; + _genericMatchStreak = 0; + _dockerLayers = {}; + _buildKitSteps = {}; + } + + void _resetUnderLock() noexcept + { + _clearRecord(); + _ansiState = AnsiState::Ground; + _ansiLength = 0; + _pendingHighSurrogate = false; + _previousChunkEndedWithCarriageReturn = false; + _pendingCarriageReturnProgress.reset(); + _healthy = true; + // A reset can follow skipped, malformed, or alternate-screen + // output. The cursor is unknown until rendered output proves it. + _columnKnown = false; + _atColumnZero = false; + _clearProviderContext(); + _recentProgress = {}; + _recentProgressNext = 0; + _recentProgressCount = 0; + _lastSeen.reset(); + _pendingPublication.reset(); + _lastPublishedTimestamp = 0; + _hasPublished = false; + _sequence = 0; + _desynchronized.store(false, std::memory_order_release); + } + + std::mutex _mutex; + std::atomic _desynchronized{ false }; + + std::array _line{}; + size_t _lineLength{}; + std::array _ansi{}; + size_t _ansiLength{}; + AnsiState _ansiState{ AnsiState::Ground }; + bool _recordHadEraseLine{}; + bool _recordUnsafe{}; + bool _recordMalformed{}; + bool _recordOverflow{}; + bool _pendingHighSurrogate{}; + wchar_t _pendingHighSurrogateValue{}; + bool _previousChunkEndedWithCarriageReturn{}; + std::optional _pendingCarriageReturnProgress; + bool _healthy{ true }; + bool _columnKnown{}; + bool _atColumnZero{}; + + ProgressProvider _claimedProvider{ ProgressProvider::None }; + bool _curlHeaderSeen{}; + bool _wgetAnchorSeen{}; + uint8_t _genericMatchStreak{}; + std::array _dockerLayers{}; + std::array _buildKitSteps{}; + std::array _recentProgress{}; + size_t _recentProgressNext{}; + size_t _recentProgressCount{}; + + std::optional _lastSeen; + std::optional _pendingPublication; + uint64_t _lastPublishedTimestamp{}; + bool _hasPublished{}; + uint32_t _sequence{}; + }; + + // A bounded diagnostic/test adapter for callers that have UTF-8 rather + // than the production UTF-16 TerminalOutput stream. It retains only the + // scalar decoder state (at most three outstanding continuation bytes) and + // stages at most one RecognitionEngine chunk. Decode errors and bound + // violations reset recognition state and always fail open. + class Utf8RecognitionAdapter final + { + public: + static constexpr size_t MaxChunkBytes = RecognitionEngine::MaxChunkCodeUnits * 4; + + explicit Utf8RecognitionAdapter(RecognitionEngine& engine) noexcept : + _engine{ engine } + { + } + + Utf8RecognitionAdapter(const Utf8RecognitionAdapter&) = delete; + Utf8RecognitionAdapter& operator=(const Utf8RecognitionAdapter&) = delete; + + RecognitionResult Consume(const std::string_view bytes, + const uint64_t timestampMilliseconds, + const RecognitionOptions options = {}) noexcept + { + if (_failed) + { + return _failedResult(false); + } + if (bytes.size() > MaxChunkBytes) + { + return _failOpen(true); + } + + const auto scalarSpansChunks = _continuationsRemaining != 0; + size_t decodedLength{}; + for (const auto value : bytes) + { + const auto byte = static_cast(static_cast(value)); + if (_continuationsRemaining == 0) + { + if (byte <= 0x7f) + { + if (!_appendScalar(byte, decodedLength)) + { + return _failOpen(true); + } + } + else if (byte >= 0xc2 && byte <= 0xdf) + { + _scalar = byte & 0x1f; + _minimumScalar = 0x80; + _continuationsRemaining = 1; + } + else if (byte >= 0xe0 && byte <= 0xef) + { + _scalar = byte & 0x0f; + _minimumScalar = 0x800; + _continuationsRemaining = 2; + } + else if (byte >= 0xf0 && byte <= 0xf4) + { + _scalar = byte & 0x07; + _minimumScalar = 0x10000; + _continuationsRemaining = 3; + } + else + { + return _failOpen(false); + } + continue; + } + + if ((byte & 0xc0) != 0x80) + { + return _failOpen(false); + } + + _scalar = (_scalar << 6) | (byte & 0x3f); + --_continuationsRemaining; + if (_continuationsRemaining == 0) + { + const auto malformed = _scalar < _minimumScalar || + _scalar > 0x10ffff || + (_scalar >= 0xd800 && _scalar <= 0xdfff); + if (malformed) + { + return _failOpen(false); + } + if (!_appendScalar(_scalar, decodedLength)) + { + return _failOpen(true); + } + _scalar = 0; + _minimumScalar = 0; + } + } + + if (decodedLength == 0) + { + // Empty input and a partial scalar are both healthy so far. + // Finish() distinguishes a truncated scalar at end-of-stream. + return {}; + } + + auto safeOptions = options; + const auto scalarStillIncomplete = _continuationsRemaining != 0; + if (scalarSpansChunks || scalarStillIncomplete) + { + // A scalar whose bytes cross callback boundaries can be + // recognized, but never qualifies for whole-callback hiding. + safeOptions.replacementEnabled = false; + } + + auto result = _engine.Consume(std::wstring_view{ _decoded.data(), decodedLength }, + timestampMilliseconds, + safeOptions); + if (!result.accepted) + { + _failed = true; + _resetDecoder(); + } + if (scalarSpansChunks || scalarStillIncomplete) + { + result.suppressInput = false; + } + return result; + } + + // Marks the end of a UTF-8 stream. An unfinished scalar is malformed + // and therefore invalidates recognition while preserving terminal data. + RecognitionResult Finish() noexcept + { + if (_failed) + { + return _failedResult(false); + } + if (_continuationsRemaining == 0) + { + return {}; + } + return _failOpen(false); + } + + void Reset() noexcept + { + _engine.Reset(); + _resetDecoder(); + _failed = false; + } + + void Clear() noexcept + { + Reset(); + } + + bool TryReset() noexcept + { + if (!_engine.TryReset()) + { + return false; + } + _resetDecoder(); + _failed = false; + return true; + } + + private: + bool _appendScalar(const uint32_t scalar, size_t& length) noexcept + { + if (scalar <= 0xffff) + { + if (length >= _decoded.size()) + { + return false; + } + _decoded[length++] = static_cast(scalar); + return true; + } + + if (length > _decoded.size() - 2) + { + return false; + } + const auto supplementary = scalar - 0x10000; + _decoded[length++] = static_cast(0xd800 + (supplementary >> 10)); + _decoded[length++] = static_cast(0xdc00 + (supplementary & 0x3ff)); + return true; + } + + RecognitionResult _failOpen(const bool overflow) noexcept + { + _failed = true; + _resetDecoder(); + static_cast(_engine.TryReset()); + + return _failedResult(overflow); + } + + static RecognitionResult _failedResult(const bool overflow) noexcept + { + RecognitionResult result; + result.overflow = overflow; + result.healthy = false; + result.accepted = false; + return result; + } + + void _resetDecoder() noexcept + { + _scalar = 0; + _minimumScalar = 0; + _continuationsRemaining = 0; + } + + RecognitionEngine& _engine; + std::array _decoded{}; + uint32_t _scalar{}; + uint32_t _minimumScalar{}; + uint8_t _continuationsRemaining{}; + bool _failed{}; + }; +} diff --git a/src/winterm/VisualProgress/VisualProgressModel.h b/src/winterm/VisualProgress/VisualProgressModel.h index 1e861c03b..896f87b2f 100644 --- a/src/winterm/VisualProgress/VisualProgressModel.h +++ b/src/winterm/VisualProgress/VisualProgressModel.h @@ -31,9 +31,86 @@ namespace winTerm::VisualProgress { None, Taskbar, + Provider, ShellIntegration, }; + enum class ProgressProvider : uint8_t + { + None, + DockerPull, + DockerBuildKit, + Pip, + Git, + Curl, + Wget, + Npm, + Pnpm, + Yarn, + Nvm, + Maven, + Gradle, + Generic, + }; + + enum class ProviderConfidence : uint8_t + { + None, + Low, + Medium, + High, + }; + + static_assert(static_cast(ProgressProvider::Generic) < 16); + static_assert(static_cast(ProgressMode::Indeterminate) < 4); + static_assert(static_cast(ProgressStatus::Cancelled) < 8); + + struct ProviderProgress + { + ProgressProvider provider{ ProgressProvider::None }; + ProgressMode mode{ ProgressMode::Hidden }; + ProgressStatus status{ ProgressStatus::Cancelled }; + uint8_t value{}; + ProviderConfidence confidence{ ProviderConfidence::None }; + bool visible{}; + bool transient{}; + bool suppressible{}; + uint16_t stage{}; + uint32_t sequence{}; + }; + + // Provider state crosses the TerminalControl ABI as one atomic value. The + // payload contains structural progress metadata only--never terminal text. + inline constexpr uint64_t PackProviderProgress(const ProviderProgress& progress) noexcept + { + return (static_cast(progress.mode) & 0x3u) | + ((static_cast(progress.status) & 0x7u) << 2u) | + ((static_cast(progress.value) & 0x7fu) << 5u) | + ((static_cast(progress.provider) & 0xfu) << 12u) | + ((static_cast(progress.confidence) & 0x3u) << 16u) | + (static_cast(progress.transient) << 18u) | + (static_cast(progress.suppressible) << 19u) | + (static_cast(progress.visible) << 20u) | + ((static_cast(progress.stage) & 0xfffu) << 21u) | + ((static_cast(progress.sequence) & 0x7fffffffu) << 33u); + } + + inline constexpr ProviderProgress UnpackProviderProgress(const uint64_t packed) noexcept + { + return { + static_cast((packed >> 12u) & 0xfu), + static_cast(packed & 0x3u), + static_cast((packed >> 2u) & 0x7u), + static_cast((packed >> 5u) & 0x7fu), + static_cast((packed >> 16u) & 0x3u), + ((packed >> 20u) & 0x1u) != 0, + ((packed >> 18u) & 0x1u) != 0, + ((packed >> 19u) & 0x1u) != 0, + static_cast((packed >> 21u) & 0xfffu), + static_cast((packed >> 33u) & 0x7fffffffu), + }; + } + // Values intentionally mirror the semantic state exposed by TerminalCore. enum class ShellLifecycleState : uint8_t { @@ -52,6 +129,11 @@ namespace winTerm::VisualProgress bool visible{}; ProgressSource source{ ProgressSource::None }; uint64_t sequence{}; + ProgressProvider provider{ ProgressProvider::None }; + ProviderConfidence confidence{ ProviderConfidence::None }; + bool transient{}; + bool suppressible{}; + uint16_t stage{}; bool SamePresentation(const ProgressSnapshot& other) const noexcept { @@ -59,7 +141,12 @@ namespace winTerm::VisualProgress status == other.status && value == other.value && visible == other.visible && - source == other.source; + source == other.source && + provider == other.provider && + confidence == other.confidence && + transient == other.transient && + suppressible == other.suppressible && + stage == other.stage; } }; @@ -83,6 +170,8 @@ namespace winTerm::VisualProgress if (!enabled) { _explicitActive = false; + _explicitSnapshot = {}; + _providerSnapshot.reset(); _shellSnapshot.reset(); return _emit(HiddenSnapshot(ProgressStatus::Cancelled)); } @@ -102,7 +191,8 @@ namespace winTerm::VisualProgress { case 0: // Clear _explicitActive = false; - return _emit(_shellSnapshot.value_or(HiddenSnapshot())); + _explicitSnapshot = {}; + return _emit(_fallbackSnapshot()); case 1: // Set _explicitActive = true; _explicitSnapshot = { ProgressMode::Determinate, ProgressStatus::Running, clamped, true, ProgressSource::Taskbar, 0 }; @@ -125,6 +215,48 @@ namespace winTerm::VisualProgress return _emit(_explicitSnapshot); } + std::optional ApplyProvider(const ProviderProgress& progress) noexcept + { + std::unique_lock lock{ _mutex, std::try_to_lock }; + if (!lock.owns_lock() || !_enabled || _closed) + { + return std::nullopt; + } + + if (!progress.visible || progress.mode == ProgressMode::Hidden || progress.provider == ProgressProvider::None) + { + _providerSnapshot.reset(); + return _explicitActive ? std::nullopt : _emit(_fallbackSnapshot()); + } + + _providerSnapshot = ProgressSnapshot{ + progress.mode, + progress.status, + static_cast(std::min(progress.value, 100)), + true, + ProgressSource::Provider, + 0, + progress.provider, + progress.confidence, + progress.transient, + progress.suppressible, + progress.stage, + }; + return _explicitActive ? std::nullopt : _emit(*_providerSnapshot); + } + + std::optional ResetProvider() noexcept + { + std::unique_lock lock{ _mutex, std::try_to_lock }; + if (!lock.owns_lock() || !_enabled || _closed) + { + return std::nullopt; + } + + _providerSnapshot.reset(); + return _explicitActive ? std::nullopt : _emit(_shellSnapshot.value_or(HiddenSnapshot())); + } + std::optional ApplyShellLifecycle(const ShellLifecycleState state, const int64_t exitCode) noexcept { std::unique_lock lock{ _mutex, std::try_to_lock }; @@ -136,6 +268,7 @@ namespace winTerm::VisualProgress switch (state) { case ShellLifecycleState::Prompt: + _providerSnapshot.reset(); _shellSnapshot.reset(); break; case ShellLifecycleState::CommandStart: @@ -143,6 +276,7 @@ namespace winTerm::VisualProgress _shellSnapshot = ProgressSnapshot{ ProgressMode::Indeterminate, ProgressStatus::Running, 0, true, ProgressSource::ShellIntegration, 0 }; break; case ShellLifecycleState::CommandFinished: + _providerSnapshot.reset(); _shellSnapshot = ProgressSnapshot{ ProgressMode::Determinate, exitCode > 0 ? ProgressStatus::Error : ProgressStatus::Success, @@ -157,13 +291,15 @@ namespace winTerm::VisualProgress return std::nullopt; } - return _explicitActive ? std::nullopt : _emit(_shellSnapshot.value_or(HiddenSnapshot())); + return _explicitActive ? std::nullopt : _emit(_fallbackSnapshot()); } std::optional Reset() noexcept { std::scoped_lock lock{ _mutex }; _explicitActive = false; + _explicitSnapshot = {}; + _providerSnapshot.reset(); _shellSnapshot.reset(); return _emit(HiddenSnapshot()); } @@ -178,6 +314,8 @@ namespace winTerm::VisualProgress _closed = true; _enabled = false; _explicitActive = false; + _explicitSnapshot = {}; + _providerSnapshot.reset(); _shellSnapshot.reset(); return _emit(HiddenSnapshot(ProgressStatus::Cancelled)); } @@ -194,6 +332,15 @@ namespace winTerm::VisualProgress return { ProgressMode::Hidden, status, 0, false, ProgressSource::None, 0 }; } + ProgressSnapshot _fallbackSnapshot() const noexcept + { + if (_providerSnapshot) + { + return *_providerSnapshot; + } + return _shellSnapshot.value_or(HiddenSnapshot()); + } + uint8_t _meaningfulValue(const uint8_t value) const noexcept { if (value == 0 && _explicitSnapshot.mode == ProgressMode::Determinate && _explicitSnapshot.value > 0) @@ -221,6 +368,7 @@ namespace winTerm::VisualProgress uint64_t _sequence{}; ProgressSnapshot _current{}; ProgressSnapshot _explicitSnapshot{}; + std::optional _providerSnapshot; std::optional _shellSnapshot; }; From 5c90183c19ebe60272fd31247358f9d41b1db576 Mon Sep 17 00:00:00 2001 From: HelloThisWorld Date: Sat, 1 Aug 2026 07:13:08 +0800 Subject: [PATCH 3/5] feat: integrate Visual Progress Phase 2 --- src/cascadia/TerminalApp/Pane.cpp | 205 ++++++---- src/cascadia/TerminalApp/Pane.h | 20 +- .../TerminalApp/TerminalPaneContent.cpp | 5 + .../TerminalApp/TerminalPaneContent.h | 4 + .../TerminalApp/TerminalPaneContent.idl | 2 + src/cascadia/TerminalControl/ControlCore.cpp | 372 +++++++++++++++++- src/cascadia/TerminalControl/ControlCore.h | 19 + src/cascadia/TerminalControl/ControlCore.idl | 2 + src/cascadia/TerminalControl/ICoreState.idl | 1 + src/cascadia/TerminalControl/TermControl.cpp | 22 ++ src/cascadia/TerminalControl/TermControl.h | 4 + src/cascadia/TerminalControl/TermControl.idl | 2 + src/cascadia/TerminalCore/Terminal.cpp | 5 + src/cascadia/TerminalCore/Terminal.hpp | 3 + .../GlobalAppSettings.idl | 1 + .../TerminalSettingsModel/MTSMSettings.h | 3 +- 16 files changed, 587 insertions(+), 83 deletions(-) diff --git a/src/cascadia/TerminalApp/Pane.cpp b/src/cascadia/TerminalApp/Pane.cpp index f4c08b7e5..89a2d278c 100644 --- a/src/cascadia/TerminalApp/Pane.cpp +++ b/src/cascadia/TerminalApp/Pane.cpp @@ -5,6 +5,8 @@ #include "Pane.h" #include "../../winterm/Design/DesignTokens.h" +#include "../../winterm/VisualProgress/RainbowArcRenderer.h" +#include "../../winterm/VisualProgress/RainbowArcVisualConstants.h" #include #include @@ -1484,7 +1486,10 @@ void Pane::UpdateVisuals() } if (_visualProgressOverlay) { - _ApplyVisualProgressSnapshot(_visualProgressState.Current()); + // Focus, theme, visibility, and activation changes update renderer + // eligibility only. Reapplying the same semantic snapshot here would + // replay one-shot success/error transitions. + _RefreshVisualProgressRenderer(); } } @@ -1553,9 +1558,13 @@ void Pane::UpdateSettings(const CascadiaSettings& settings) { const auto globals = settings.GlobalSettings(); const auto emergencyOverride = wil::TryGetEnvironmentVariableW(L"WINTERM_DISABLE_VISUAL_PROGRESS"); + _visualProgressReplaceRecognizedOutput.store( + globals.VisualProgressReplaceRecognizedOutput(), + std::memory_order_release); _SetVisualProgressEnabled(winTerm::VisualProgress::IsFeatureEnabled( globals.VisualProgressEnabled(), emergencyOverride)); + _ConfigureVisualProgressRecognition(); _paneResizeSettings.enableSnapping = globals.PaneResizeSnapping(); switch (globals.PaneResizeSnapPoints()) @@ -2046,6 +2055,9 @@ IPaneContent Pane::_takePaneContent() _paneTitleChangedRevoker.revoke(); _paneTaskbarProgressChangedRevoker.revoke(); _paneShellIntegrationChangedRevoker.revoke(); + _paneVisualProgressProviderChangedRevoker.revoke(); + _visualProgressRendererReady.store(false, std::memory_order_release); + _ConfigureVisualProgressRecognition(); _paneReadOnlyChangedRevoker.revoke(); _visualProgressState.Reset(); _visualProgressMailbox.Close(); @@ -2088,6 +2100,10 @@ void Pane::_setPaneContent(IPaneContent content) _paneShellIntegrationChangedRevoker = terminalContent.ShellIntegrationChanged( winrt::auto_revoke, [this](auto&&, auto&&) { _UpdateVisualProgressFromShellIntegration(); }); + _paneVisualProgressProviderChangedRevoker = terminalContent.VisualProgressProviderChanged( + winrt::auto_revoke, + [this](auto&&, auto&&) { _UpdateVisualProgressFromProvider(); }); + _ConfigureVisualProgressRecognition(); } _paneReadOnlyChangedRevoker = _content.ReadOnlyChanged( winrt::auto_revoke, @@ -2225,6 +2241,10 @@ void Pane::_SetVisualProgressEnabled(const bool enabled) _visualProgressEnabled.store(enabled, std::memory_order_release); if (!enabled) { + // Revoke parser/replacement eligibility before tearing down the + // decorative renderer so no in-flight output can be hidden without + // an available presentation. + _ConfigureVisualProgressRecognition(); _visualProgressState.SetEnabled(false); _visualProgressMailbox.Close(); _DestroyVisualProgressOverlay(); @@ -2239,7 +2259,9 @@ void Pane::_SetVisualProgressEnabled(const bool enabled) _CreateVisualProgressOverlay(); _UpdateVisualProgressFromShellIntegration(); _UpdateVisualProgressFromTaskbar(); + _UpdateVisualProgressFromProvider(); } + _ConfigureVisualProgressRecognition(); } void Pane::_CreateVisualProgressOverlay() @@ -2252,39 +2274,45 @@ void Pane::_CreateVisualProgressOverlay() try { _visualProgressOverlay = Controls::Grid{}; - _visualProgressFillLayout = Controls::Grid{}; - _visualProgressTrack = Controls::Border{}; - _visualProgressFill = Controls::Border{}; - _visualProgressLeadingColumn = Controls::ColumnDefinition{}; - _visualProgressFillColumn = Controls::ColumnDefinition{}; - _visualProgressTrailingColumn = Controls::ColumnDefinition{}; + _visualProgressCompositionHost = Controls::Grid{}; - _visualProgressOverlay.Height(6.0); - _visualProgressOverlay.Margin(ThicknessHelper::FromLengths(10.0, 0.0, 10.0, 8.0)); + _visualProgressOverlay.Height(winTerm::VisualProgress::RainbowArcVisualConstants::OverlayHostHeight); _visualProgressOverlay.VerticalAlignment(VerticalAlignment::Bottom); _visualProgressOverlay.HorizontalAlignment(HorizontalAlignment::Stretch); _visualProgressOverlay.IsHitTestVisible(false); - _visualProgressOverlay.Visibility(Visibility::Collapsed); + _visualProgressOverlay.Visibility(Visibility::Visible); Controls::Grid::SetRow(_visualProgressOverlay, 1); Automation::AutomationProperties::SetAccessibilityView( _visualProgressOverlay, Automation::Peers::AccessibilityView::Raw); - const auto radius = CornerRadiusHelper::FromUniformRadius(winTerm::Design::RadiusTokens::CompactControl); - _visualProgressTrack.CornerRadius(radius); - _visualProgressTrack.IsHitTestVisible(false); - _visualProgressFill.CornerRadius(radius); - _visualProgressFill.IsHitTestVisible(false); + _visualProgressCompositionHost.HorizontalAlignment(HorizontalAlignment::Stretch); + _visualProgressCompositionHost.VerticalAlignment(VerticalAlignment::Stretch); + _visualProgressCompositionHost.IsHitTestVisible(false); + Automation::AutomationProperties::SetAccessibilityView( + _visualProgressCompositionHost, + Automation::Peers::AccessibilityView::Raw); - _visualProgressFillLayout.ColumnDefinitions().Append(_visualProgressLeadingColumn); - _visualProgressFillLayout.ColumnDefinitions().Append(_visualProgressFillColumn); - _visualProgressFillLayout.ColumnDefinitions().Append(_visualProgressTrailingColumn); - Controls::Grid::SetColumn(_visualProgressFill, 1); - _visualProgressFillLayout.Children().Append(_visualProgressFill); - _visualProgressOverlay.Children().Append(_visualProgressTrack); - _visualProgressOverlay.Children().Append(_visualProgressFillLayout); + _visualProgressOverlay.Children().Append(_visualProgressCompositionHost); _leafLayout.Children().Append(_visualProgressOverlay); - _ApplyVisualProgressSnapshot(_visualProgressState.Current()); + + _visualProgressRenderer = winTerm::VisualProgress::RainbowArcRenderer::TryCreate(_visualProgressCompositionHost); + const auto rendererReady = _visualProgressRenderer && !_visualProgressRenderer->Faulted(); + _visualProgressRendererReady.store(rendererReady, std::memory_order_release); + if (!rendererReady) + { + _DisableVisualProgressOnUI(); + return; + } + + _visualProgressRenderer->SetPaneActive(_lastActive); + _visualProgressRenderer->Apply(_visualProgressState.Current()); + if (_visualProgressRenderer->Faulted()) + { + _DisableVisualProgressOnUI(); + return; + } + _ConfigureVisualProgressRecognition(); } catch (...) { @@ -2295,6 +2323,13 @@ void Pane::_CreateVisualProgressOverlay() void Pane::_DestroyVisualProgressOverlay() noexcept { + _visualProgressRendererReady.store(false, std::memory_order_release); + if (_visualProgressRenderer) + { + _visualProgressRenderer->Close(); + _visualProgressRenderer.reset(); + } + try { if (_leafLayout && _visualProgressOverlay) @@ -2312,12 +2347,7 @@ void Pane::_DestroyVisualProgressOverlay() noexcept } _visualProgressOverlay = nullptr; - _visualProgressFillLayout = nullptr; - _visualProgressTrack = nullptr; - _visualProgressFill = nullptr; - _visualProgressLeadingColumn = nullptr; - _visualProgressFillColumn = nullptr; - _visualProgressTrailingColumn = nullptr; + _visualProgressCompositionHost = nullptr; } void Pane::_UpdateVisualProgressFromTaskbar() @@ -2348,6 +2378,42 @@ void Pane::_UpdateVisualProgressFromShellIntegration() } } +void Pane::_UpdateVisualProgressFromProvider() +{ + if (!_visualProgressEnabled.load(std::memory_order_acquire) || _visualProgressFaulted.load(std::memory_order_acquire)) + { + return; + } + if (const auto terminalContent = _content.try_as()) + { + const auto provider = winTerm::VisualProgress::UnpackProviderProgress(terminalContent.VisualProgressProviderState()); + if (const auto snapshot = _visualProgressState.ApplyProvider(provider)) + { + _QueueVisualProgressUpdate(*snapshot); + } + } +} + +void Pane::_ConfigureVisualProgressRecognition() noexcept +{ + try + { + if (const auto terminalContent = _content.try_as()) + { + const auto enabled = _visualProgressEnabled.load(std::memory_order_acquire) && + !_visualProgressFaulted.load(std::memory_order_acquire) && + _visualProgressRendererReady.load(std::memory_order_acquire); + terminalContent.GetTermControl().ConfigureVisualProgressRecognition( + enabled, + enabled && _visualProgressReplaceRecognizedOutput.load(std::memory_order_acquire)); + } + } + catch (...) + { + LOG_CAUGHT_EXCEPTION(); + } +} + void Pane::_QueueVisualProgressUpdate(const winTerm::VisualProgress::ProgressSnapshot& snapshot) { if (!_visualProgressEnabled.load(std::memory_order_acquire) || _visualProgressFaulted.load(std::memory_order_acquire) || !_visualProgressMailbox.Publish(snapshot)) @@ -2372,6 +2438,7 @@ void Pane::_ScheduleVisualProgressUpdate() _visualProgressUpdateQueued.store(false, std::memory_order_release); _visualProgressFaulted.store(true, std::memory_order_release); _visualProgressMailbox.Close(); + _ConfigureVisualProgressRecognition(); LOG_HR(E_FAIL); return; } @@ -2399,6 +2466,7 @@ void Pane::_ScheduleVisualProgressUpdate() _visualProgressUpdateQueued.store(false, std::memory_order_release); _visualProgressFaulted.store(true, std::memory_order_release); _visualProgressMailbox.Close(); + _ConfigureVisualProgressRecognition(); } } @@ -2406,36 +2474,45 @@ void Pane::_ApplyVisualProgressSnapshot(const winTerm::VisualProgress::ProgressS { try { - if (!_visualProgressOverlay || !_visualProgressFill || !_visualProgressTrack || - !_visualProgressLeadingColumn || !_visualProgressFillColumn || !_visualProgressTrailingColumn) + if (!_visualProgressOverlay || !_visualProgressCompositionHost || !_visualProgressRenderer) { + _DisableVisualProgressOnUI(); return; } - _visualProgressTrack.Background( - _themeResources.progressTrackBrush ? - _themeResources.progressTrackBrush : - TokenBrush(winTerm::Design::ColorTokens::ProgressTrack)); - _visualProgressFill.Background(_VisualProgressBrush(snapshot.status)); - _visualProgressOverlay.Visibility(snapshot.visible ? Visibility::Visible : Visibility::Collapsed); - if (!snapshot.visible) + _visualProgressRenderer->SetPaneActive(_lastActive); + _visualProgressRenderer->RefreshEnvironment(); + _visualProgressRenderer->Apply(snapshot); + if (_visualProgressRenderer->Faulted()) + { + _visualProgressRendererReady.store(false, std::memory_order_release); + _ConfigureVisualProgressRecognition(); + _DisableVisualProgressOnUI(); + return; + } + } + catch (...) + { + LOG_CAUGHT_EXCEPTION(); + _DisableVisualProgressOnUI(); + } +} + +void Pane::_RefreshVisualProgressRenderer() noexcept +{ + try + { + if (!_visualProgressRenderer) { return; } - double leading{}; - double fill{ static_cast(snapshot.value) }; - double trailing{ 100.0 - fill }; - if (snapshot.mode == winTerm::VisualProgress::ProgressMode::Indeterminate) + _visualProgressRenderer->SetPaneActive(_lastActive); + _visualProgressRenderer->RefreshEnvironment(); + if (_visualProgressRenderer->Faulted()) { - leading = 30.0; - fill = 40.0; - trailing = 30.0; + _DisableVisualProgressOnUI(); } - _visualProgressLeadingColumn.Width(GridLengthHelper::FromValueAndType(leading, GridUnitType::Star)); - _visualProgressFillColumn.Width(GridLengthHelper::FromValueAndType(fill, GridUnitType::Star)); - _visualProgressTrailingColumn.Width(GridLengthHelper::FromValueAndType(trailing, GridUnitType::Star)); - _visualProgressFill.Visibility(fill > 0.0 ? Visibility::Visible : Visibility::Collapsed); } catch (...) { @@ -2448,36 +2525,13 @@ void Pane::_DisableVisualProgressOnUI() noexcept { _visualProgressFaulted.store(true, std::memory_order_release); _visualProgressEnabled.store(false, std::memory_order_release); + _visualProgressRendererReady.store(false, std::memory_order_release); _visualProgressState.SetEnabled(false); _visualProgressMailbox.Close(); + _ConfigureVisualProgressRecognition(); _DestroyVisualProgressOverlay(); } -SolidColorBrush Pane::_VisualProgressBrush(const winTerm::VisualProgress::ProgressStatus status) const -{ - switch (status) - { - case winTerm::VisualProgress::ProgressStatus::Waiting: - return _themeResources.progressWaitingBrush ? - _themeResources.progressWaitingBrush : - TokenBrush(winTerm::Design::ColorTokens::ProgressWaiting); - case winTerm::VisualProgress::ProgressStatus::Success: - return _themeResources.progressSuccessBrush ? - _themeResources.progressSuccessBrush : - TokenBrush(winTerm::Design::ColorTokens::ProgressSuccess); - case winTerm::VisualProgress::ProgressStatus::Error: - return _themeResources.progressErrorBrush ? - _themeResources.progressErrorBrush : - TokenBrush(winTerm::Design::ColorTokens::ProgressError); - case winTerm::VisualProgress::ProgressStatus::Running: - case winTerm::VisualProgress::ProgressStatus::Cancelled: - default: - return _themeResources.progressRunningBrush ? - _themeResources.progressRunningBrush : - TokenBrush(winTerm::Design::ColorTokens::AccentMint); - } -} - void Pane::_UpdatePaneHeader() { if (!_paneHeader || !_paneTitle || !_paneIcon || !_paneStatus) @@ -3605,6 +3659,9 @@ std::pair, std::shared_ptr> Pane::_Split(SplitDirect // Move our control, guid, isDefTermSession into the first one. _firstChild = std::make_shared(_takePaneContent()); _firstChild->_broadcastEnabled = _broadcastEnabled; + _firstChild->_visualProgressReplaceRecognizedOutput.store( + _visualProgressReplaceRecognizedOutput.load(std::memory_order_acquire), + std::memory_order_release); _firstChild->_SetVisualProgressEnabled(_visualProgressEnabled.load(std::memory_order_acquire)); } diff --git a/src/cascadia/TerminalApp/Pane.h b/src/cascadia/TerminalApp/Pane.h index 5e3f130ee..4af57e9b5 100644 --- a/src/cascadia/TerminalApp/Pane.h +++ b/src/cascadia/TerminalApp/Pane.h @@ -75,6 +75,11 @@ struct PaneResources winrt::Windows::UI::Xaml::Media::SolidColorBrush progressSuccessBrush{ nullptr }; winrt::Windows::UI::Xaml::Media::SolidColorBrush progressErrorBrush{ nullptr }; }; + +namespace winTerm::VisualProgress +{ + class RainbowArcRenderer; +} class Pane : public std::enable_shared_from_this { @@ -277,12 +282,7 @@ class Pane : public std::enable_shared_from_this winrt::Windows::UI::Xaml::Controls::Border _snapIndicator{ nullptr }; winrt::Windows::UI::Xaml::Controls::TextBlock _snapIndicatorText{ nullptr }; winrt::Windows::UI::Xaml::Controls::Grid _visualProgressOverlay{ nullptr }; - winrt::Windows::UI::Xaml::Controls::Grid _visualProgressFillLayout{ nullptr }; - winrt::Windows::UI::Xaml::Controls::Border _visualProgressTrack{ nullptr }; - winrt::Windows::UI::Xaml::Controls::Border _visualProgressFill{ nullptr }; - winrt::Windows::UI::Xaml::Controls::ColumnDefinition _visualProgressLeadingColumn{ nullptr }; - winrt::Windows::UI::Xaml::Controls::ColumnDefinition _visualProgressFillColumn{ nullptr }; - winrt::Windows::UI::Xaml::Controls::ColumnDefinition _visualProgressTrailingColumn{ nullptr }; + winrt::Windows::UI::Xaml::Controls::Grid _visualProgressCompositionHost{ nullptr }; PaneResources _themeResources; @@ -307,6 +307,7 @@ class Pane : public std::enable_shared_from_this winrt::TerminalApp::IPaneContent::TitleChanged_revoker _paneTitleChangedRevoker; winrt::TerminalApp::IPaneContent::TaskbarProgressChanged_revoker _paneTaskbarProgressChangedRevoker; winrt::TerminalApp::TerminalPaneContent::ShellIntegrationChanged_revoker _paneShellIntegrationChangedRevoker; + winrt::TerminalApp::TerminalPaneContent::VisualProgressProviderChanged_revoker _paneVisualProgressProviderChangedRevoker; winrt::TerminalApp::IPaneContent::ReadOnlyChanged_revoker _paneReadOnlyChangedRevoker; Borders _borders{ Borders::None }; @@ -323,10 +324,13 @@ class Pane : public std::enable_shared_from_this std::unique_ptr _resizeTransaction; bool _dividerPointerOver{ false }; std::atomic _visualProgressEnabled{ false }; + std::atomic _visualProgressReplaceRecognizedOutput{ false }; std::atomic _visualProgressFaulted{ false }; + std::atomic _visualProgressRendererReady{ false }; std::atomic _visualProgressUpdateQueued{ false }; winTerm::VisualProgress::ProgressStateMachine _visualProgressState; winTerm::VisualProgress::ProgressUpdateMailbox _visualProgressMailbox; + std::shared_ptr _visualProgressRenderer; bool _IsLeaf() const noexcept; bool _HasFocusedChild() const noexcept; @@ -339,11 +343,13 @@ class Pane : public std::enable_shared_from_this void _DestroyVisualProgressOverlay() noexcept; void _UpdateVisualProgressFromTaskbar(); void _UpdateVisualProgressFromShellIntegration(); + void _UpdateVisualProgressFromProvider(); + void _ConfigureVisualProgressRecognition() noexcept; void _QueueVisualProgressUpdate(const winTerm::VisualProgress::ProgressSnapshot& snapshot); void _ScheduleVisualProgressUpdate(); + void _RefreshVisualProgressRenderer() noexcept; void _ApplyVisualProgressSnapshot(const winTerm::VisualProgress::ProgressSnapshot& snapshot) noexcept; void _DisableVisualProgressOnUI() noexcept; - winrt::Windows::UI::Xaml::Media::SolidColorBrush _VisualProgressBrush(winTerm::VisualProgress::ProgressStatus status) const; void _UpdatePaneHeader(); winrt::hstring _PaneHeaderTitle() const; winrt::hstring _PaneHeaderAccessibleTitle() const; diff --git a/src/cascadia/TerminalApp/TerminalPaneContent.cpp b/src/cascadia/TerminalApp/TerminalPaneContent.cpp index 4a3d6701b..b927146ae 100644 --- a/src/cascadia/TerminalApp/TerminalPaneContent.cpp +++ b/src/cascadia/TerminalApp/TerminalPaneContent.cpp @@ -41,6 +41,7 @@ namespace winrt::TerminalApp::implementation _controlEvents._TabColorChanged = _control.TabColorChanged(winrt::auto_revoke, { get_weak(), &TerminalPaneContent::_controlTabColorChanged }); _controlEvents._SetTaskbarProgress = _control.SetTaskbarProgress(winrt::auto_revoke, { get_weak(), &TerminalPaneContent::_controlSetTaskbarProgress }); _controlEvents._ShellIntegrationChanged = _control.ShellIntegrationChanged(winrt::auto_revoke, { get_weak(), &TerminalPaneContent::_controlShellIntegrationChanged }); + _controlEvents._VisualProgressProviderChanged = _control.VisualProgressProviderChanged(winrt::auto_revoke, { get_weak(), &TerminalPaneContent::_controlVisualProgressProviderChanged }); _controlEvents._ReadOnlyChanged = _control.ReadOnlyChanged(winrt::auto_revoke, { get_weak(), &TerminalPaneContent::_controlReadOnlyChanged }); _controlEvents._FocusFollowMouseRequested = _control.FocusFollowMouseRequested(winrt::auto_revoke, { get_weak(), &TerminalPaneContent::_controlFocusFollowMouseRequested }); } @@ -177,6 +178,10 @@ namespace winrt::TerminalApp::implementation void TerminalPaneContent::_controlShellIntegrationChanged(const IInspectable&, const IInspectable&) { ShellIntegrationChanged.raise(*this, nullptr); + } + void TerminalPaneContent::_controlVisualProgressProviderChanged(const IInspectable&, const IInspectable&) + { + VisualProgressProviderChanged.raise(*this, nullptr); } void TerminalPaneContent::_controlReadOnlyChanged(const IInspectable&, const IInspectable&) { diff --git a/src/cascadia/TerminalApp/TerminalPaneContent.h b/src/cascadia/TerminalApp/TerminalPaneContent.h index 995815a26..387c4799d 100644 --- a/src/cascadia/TerminalApp/TerminalPaneContent.h +++ b/src/cascadia/TerminalApp/TerminalPaneContent.h @@ -47,6 +47,7 @@ namespace winrt::TerminalApp::implementation uint64_t TaskbarProgress() { return _control.TaskbarProgress(); } uint64_t ShellIntegrationState() { return _control.ShellIntegrationState(); } int64_t ShellIntegrationExitCode() { return _control.ShellIntegrationExitCode(); } + uint64_t VisualProgressProviderState() { return _control.VisualProgressProviderState(); } bool ReadOnly() { return _control.ReadOnly(); } winrt::hstring Icon() const; Windows::Foundation::IReference TabColor() const noexcept; @@ -57,6 +58,7 @@ namespace winrt::TerminalApp::implementation til::typed_event RestartTerminalRequested; til::typed_event ShellIntegrationChanged; + til::typed_event VisualProgressProviderChanged; // See BasicPaneEvents for most generic event definitions @@ -81,6 +83,7 @@ namespace winrt::TerminalApp::implementation winrt::Microsoft::Terminal::Control::TermControl::TabColorChanged_revoker _TabColorChanged; winrt::Microsoft::Terminal::Control::TermControl::SetTaskbarProgress_revoker _SetTaskbarProgress; winrt::Microsoft::Terminal::Control::TermControl::ShellIntegrationChanged_revoker _ShellIntegrationChanged; + winrt::Microsoft::Terminal::Control::TermControl::VisualProgressProviderChanged_revoker _VisualProgressProviderChanged; winrt::Microsoft::Terminal::Control::TermControl::ReadOnlyChanged_revoker _ReadOnlyChanged; winrt::Microsoft::Terminal::Control::TermControl::FocusFollowMouseRequested_revoker _FocusFollowMouseRequested; @@ -99,6 +102,7 @@ namespace winrt::TerminalApp::implementation void _controlTabColorChanged(const winrt::Windows::Foundation::IInspectable& sender, const winrt::Windows::Foundation::IInspectable& args); void _controlSetTaskbarProgress(const winrt::Windows::Foundation::IInspectable& sender, const winrt::Windows::Foundation::IInspectable& args); void _controlShellIntegrationChanged(const winrt::Windows::Foundation::IInspectable& sender, const winrt::Windows::Foundation::IInspectable& args); + void _controlVisualProgressProviderChanged(const winrt::Windows::Foundation::IInspectable& sender, const winrt::Windows::Foundation::IInspectable& args); void _controlReadOnlyChanged(const winrt::Windows::Foundation::IInspectable& sender, const winrt::Windows::Foundation::IInspectable& args); void _controlFocusFollowMouseRequested(const winrt::Windows::Foundation::IInspectable& sender, const winrt::Windows::Foundation::IInspectable& args); diff --git a/src/cascadia/TerminalApp/TerminalPaneContent.idl b/src/cascadia/TerminalApp/TerminalPaneContent.idl index a4985242d..08c9017fa 100644 --- a/src/cascadia/TerminalApp/TerminalPaneContent.idl +++ b/src/cascadia/TerminalApp/TerminalPaneContent.idl @@ -12,7 +12,9 @@ namespace TerminalApp UInt64 ShellIntegrationState { get; }; Int64 ShellIntegrationExitCode { get; }; + UInt64 VisualProgressProviderState { get; }; event Windows.Foundation.TypedEventHandler ShellIntegrationChanged; + event Windows.Foundation.TypedEventHandler VisualProgressProviderChanged; void MarkAsDefterm(); diff --git a/src/cascadia/TerminalControl/ControlCore.cpp b/src/cascadia/TerminalControl/ControlCore.cpp index 6e1789fec..17f400500 100644 --- a/src/cascadia/TerminalControl/ControlCore.cpp +++ b/src/cascadia/TerminalControl/ControlCore.cpp @@ -12,6 +12,7 @@ #include #include "EventArgs.h" +#include "../../winterm/VisualProgress/ProgressRecognition.h" #include "../../renderer/atlas/AtlasEngine.h" #include "../../renderer/base/renderer.hpp" #include "../../renderer/uia/UiaRenderer.hpp" @@ -260,6 +261,21 @@ namespace winrt::Microsoft::Terminal::Control::implementation { _connectionOutputEventRevoker.revoke(); _connectionStateChangedRevoker.revoke(); + uint64_t previousProvider{}; + { + // Closing the connection is also an immediate fail-open boundary. + // Serialize it with the final suppression decision, but release the + // short gate before raising events or waiting in Connection::Close. + std::scoped_lock suppressionGate{ _visualProgressSuppressionMutex }; + _visualProgressRecognitionGeneration.fetch_add(1, std::memory_order_acq_rel); + _visualProgressRecognitionResetRequested.store(true, std::memory_order_release); + previousProvider = _visualProgressProviderState.exchange(0, std::memory_order_acq_rel); + _visualProgressAlternateScreen.store(false, std::memory_order_release); + } + if (previousProvider != 0) + { + VisualProgressProviderChanged.raise(*this, nullptr); + } // One of the tasks for `ITerminalConnection::Close()` is to block until all pending // callback calls have completed. This solves the race-condition issue mentioned above. @@ -267,6 +283,18 @@ namespace winrt::Microsoft::Terminal::Control::implementation { _connection.Close(); _connection = nullptr; + } + + // Close() above joins pending output callbacks. With no callback able + // to touch the recognizer, clear its bounded buffers synchronously so + // a later connection never inherits partial provider state. + { + std::scoped_lock lock{ _visualProgressRecognitionMutex }; + if (_visualProgressRecognition) + { + _visualProgressRecognition->Reset(); + } + _visualProgressRecognitionResetRequested.store(false, std::memory_order_release); } } @@ -1595,6 +1623,11 @@ namespace winrt::Microsoft::Terminal::Control::implementation return _terminal->GetShellIntegrationExitCode(); } + const uint64_t ControlCore::VisualProgressProviderState() const noexcept + { + return _visualProgressProviderState.load(std::memory_order_acquire); + } + int ControlCore::ScrollOffset() { const auto lock = _terminal->LockForReading(); @@ -1693,6 +1726,17 @@ namespace winrt::Microsoft::Terminal::Control::implementation void ControlCore::_terminalShellIntegrationChanged() { + // OSC 133 prompt is the per-pane ownership boundary. Defer parser + // cleanup to the next output callback so this terminal callback never + // waits on the recognition mutex while the terminal lock is held. + const auto shellState = static_cast(_terminal->GetShellIntegrationState()); + if (shellState == winTerm::VisualProgress::ShellLifecycleState::Prompt || + shellState == winTerm::VisualProgress::ShellLifecycleState::CommandFinished) + { + _visualProgressRecognitionGeneration.fetch_add(1, std::memory_order_acq_rel); + _visualProgressRecognitionResetRequested.store(true, std::memory_order_release); + _visualProgressProviderState.store(0, std::memory_order_release); + } ShellIntegrationChanged.raise(*this, nullptr); } @@ -1874,6 +1918,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation } _closeConnection(); + ConfigureVisualProgressRecognition(false, false); } void ControlCore::PersistTo(HANDLE handle) const @@ -2277,6 +2322,75 @@ namespace winrt::Microsoft::Terminal::Control::implementation _isReadOnly = readOnlyState; } + void ControlCore::ConfigureVisualProgressRecognition(const bool enabled, const bool replaceRecognizedOutput) + { + if (!enabled) + { + uint64_t previousProvider{}; + { + // This short gate makes Configure(false) an immediate + // suppression boundary without waiting for a parser callback + // that may currently be inside Terminal::Write. + std::scoped_lock suppressionGate{ _visualProgressSuppressionMutex }; + _visualProgressReplacementEnabled.store(false, std::memory_order_release); + _visualProgressRecognitionEnabled.store(false, std::memory_order_release); + _visualProgressRecognitionGeneration.fetch_add(1, std::memory_order_acq_rel); + _visualProgressRecognitionResetRequested.store(true, std::memory_order_release); + _visualProgressAlternateScreen.store(false, std::memory_order_release); + previousProvider = _visualProgressProviderState.exchange(0, std::memory_order_acq_rel); + } + + // Teardown must not stall pane close/detach. If an output callback + // owns the parser, it observes enabled=false and destroys the + // bounded engine before releasing the lock. + std::unique_lock recognitionLock{ _visualProgressRecognitionMutex, std::try_to_lock }; + if (recognitionLock.owns_lock()) + { + _visualProgressRecognition.reset(); + _visualProgressRecognitionResetRequested.store(false, std::memory_order_release); + } + if (previousProvider != 0) + { + VisualProgressProviderChanged.raise(*this, nullptr); + } + return; + } + + bool recognitionAvailable{}; + { + std::scoped_lock lock{ _visualProgressRecognitionMutex }; + if (!_visualProgressRecognition) + { + try + { + _visualProgressRecognition = std::make_unique(); + } + catch (...) + { + LOG_CAUGHT_EXCEPTION(); + } + } + recognitionAvailable = static_cast(_visualProgressRecognition); + + // Publish configuration only after the optional recognizer has + // been created, and arbitrate with the final suppression decision. + std::scoped_lock suppressionGate{ _visualProgressSuppressionMutex }; + _visualProgressReplacementEnabled.store(recognitionAvailable && replaceRecognizedOutput, std::memory_order_release); + _visualProgressRecognitionEnabled.store(recognitionAvailable, std::memory_order_release); + } + + if (!recognitionAvailable) + { + _visualProgressRecognitionGeneration.fetch_add(1, std::memory_order_acq_rel); + _visualProgressRecognitionResetRequested.store(true, std::memory_order_release); + _visualProgressAlternateScreen.store(false, std::memory_order_release); + if (_visualProgressProviderState.exchange(0, std::memory_order_acq_rel) != 0) + { + VisualProgressProviderChanged.raise(*this, nullptr); + } + } + } + void ControlCore::_raiseReadOnlyWarning() { auto noticeArgs = winrt::make(NoticeLevel::Info, RS_(L"TermControlReadOnly")); @@ -2286,9 +2400,265 @@ namespace winrt::Microsoft::Terminal::Control::implementation { try { + const auto output = winrt_array_to_wstring_view(str); + winTerm::VisualProgress::RecognitionResult recognition; + bool inspected{}; + bool ingressUnavailable{}; + uint64_t recognitionGeneration{}; + bool raiseProviderChanged{}; + std::unique_lock recognitionLock{ _visualProgressRecognitionMutex, std::defer_lock }; + + const auto invalidateRecognition = [&]() noexcept { + // Invalidation participates in the same linearization point as + // replacement. Never wait here: losing the gate means the raw + // output wins and we retry after Terminal::Write. + std::unique_lock suppressionGate{ _visualProgressSuppressionMutex, std::try_to_lock }; + if (!suppressionGate.owns_lock()) + { + return false; + } + + const auto generation = _visualProgressRecognitionGeneration.fetch_add(1, std::memory_order_acq_rel) + 1; + _visualProgressRecognitionResetRequested.store(true, std::memory_order_release); + + // Clear only a provider value published before this + // invalidation. A concurrently published newer generation is + // owned by that callback and must not be erased here. + for (uint8_t attempt = 0; attempt < 2; ++attempt) + { + auto packed = _visualProgressProviderState.load(std::memory_order_acquire); + if (packed == 0 || _visualProgressProviderGeneration.load(std::memory_order_acquire) >= generation) + { + break; + } + if (_visualProgressProviderState.compare_exchange_strong( + packed, + 0, + std::memory_order_acq_rel, + std::memory_order_acquire)) + { + raiseProviderChanged = true; + break; + } + } + return true; + }; + + if (_visualProgressRecognitionEnabled.load(std::memory_order_acquire)) + { + try + { + if (recognitionLock.try_lock() && _visualProgressRecognition) + { + const auto resetRequested = _visualProgressRecognitionResetRequested.exchange(false, std::memory_order_acq_rel); + if (!resetRequested || _visualProgressRecognition->TryReset()) + { + recognitionGeneration = _visualProgressRecognitionGeneration.load(std::memory_order_acquire); + const winTerm::VisualProgress::RecognitionOptions options{ + .replacementEnabled = _visualProgressReplacementEnabled.load(std::memory_order_acquire), + .rendererEnabled = true, + .normalScreen = !_visualProgressAlternateScreen.load(std::memory_order_acquire), + .parserHealthy = true, + }; + recognition = _visualProgressRecognition->Consume(output, GetTickCount64(), options); + if (recognition.progress) + { + // Assign order while ingress is serialized, + // not later when raw terminal writes may have + // allowed a newer callback to finish first. + recognition.progress->sequence = + (_visualProgressProviderSequence.fetch_add(1, std::memory_order_acq_rel) % 0x7fffffffu) + 1u; + } + inspected = true; + } + else + { + _visualProgressRecognitionResetRequested.store(true, std::memory_order_release); + ingressUnavailable = true; + } + } + else + { + // Dropping inspection is always safe: the original + // bytes still reach the terminal and parser state + // resets before the next attempt. + _visualProgressRecognitionResetRequested.store(true, std::memory_order_release); + ingressUnavailable = true; + } + } + catch (...) + { + // Recognition is optional. No recognition failure may + // bypass the mandatory raw terminal write below. + LOG_CAUGHT_EXCEPTION(); + _visualProgressRecognitionResetRequested.store(true, std::memory_order_release); + ingressUnavailable = true; + } + } + + const auto unsafeIngress = ingressUnavailable || + (inspected && (!recognition.accepted || !recognition.healthy || recognition.overflow)); + if (unsafeIngress) + { + // Invalidate before the mandatory write so every overlapping + // inspected callback fails its final generation gate. If a + // configuration boundary owns the gate, raw output still wins + // and the post-write attempt establishes the parser boundary. + static_cast(invalidateRecognition()); + } + + bool wasAlternateScreen{}; + bool isAlternateScreen{}; { const auto lock = _terminal->LockForWriting(); - _terminal->Write(winrt_array_to_wstring_view(str)); + wasAlternateScreen = _terminal->IsInAlternateScreenBuffer(); + bool maySuppress{}; + { + // This gate is deliberately try-only for output. A + // concurrent settings/detach boundary always wins and + // makes this callback write the original bytes. + std::unique_lock suppressionGate{ _visualProgressSuppressionMutex, std::try_to_lock }; + maySuppress = suppressionGate.owns_lock() && + inspected && + recognition.accepted && + recognition.healthy && + recognition.suppressInput && + _visualProgressRecognitionEnabled.load(std::memory_order_acquire) && + _visualProgressReplacementEnabled.load(std::memory_order_acquire) && + !_visualProgressRecognitionResetRequested.load(std::memory_order_acquire) && + _visualProgressRecognitionGeneration.load(std::memory_order_acquire) == recognitionGeneration && + !wasAlternateScreen; + } + if (!maySuppress) + { + // Recognition state has already consumed this callback. + // Release its arbitration lock before the mandatory raw + // terminal write, which can invoke reentrant callbacks or + // block for reasons unrelated to decorative progress. + if (recognitionLock.owns_lock()) + { + recognitionLock.unlock(); + } + _terminal->Write(output); + } + isAlternateScreen = _terminal->IsInAlternateScreenBuffer(); + + // A callback that lost the nonblocking ingress race can + // overlap a parser owner on either side of its raw write. + // Invalidate before releasing the terminal lock so no newer + // callback can reach its suppression decision first. This is + // still try-only: disable/close already establish the boundary, + // while enable leaves reset requested for its parser owner. + if (unsafeIngress && !recognitionLock.owns_lock()) + { + if (!invalidateRecognition()) + { + _visualProgressRecognitionResetRequested.store(true, std::memory_order_release); + } + } + if (wasAlternateScreen || isAlternateScreen) + { + if (!invalidateRecognition()) + { + _visualProgressRecognitionResetRequested.store(true, std::memory_order_release); + } + } + } + + _visualProgressAlternateScreen.store(isAlternateScreen, std::memory_order_release); + + const auto resetSinceInspection = inspected && + (_visualProgressRecognitionResetRequested.load(std::memory_order_acquire) || + _visualProgressRecognitionGeneration.load(std::memory_order_acquire) != recognitionGeneration); + const auto discardRecognition = unsafeIngress || resetSinceInspection || wasAlternateScreen || isAlternateScreen; + + // Raw writes release parser arbitration before Terminal::Write. + // Reacquire only long enough to publish; on contention, dropping + // the decorative update is the fail-open result. + if (!discardRecognition && inspected && recognition.progress && !recognitionLock.owns_lock()) + { + static_cast(recognitionLock.try_lock()); + } + if (!discardRecognition && inspected && recognition.progress && + recognitionLock.owns_lock() && + _visualProgressRecognitionEnabled.load(std::memory_order_acquire) && + _visualProgressRecognitionGeneration.load(std::memory_order_acquire) == recognitionGeneration) + { + auto progress = *recognition.progress; + const auto packed = winTerm::VisualProgress::PackProviderProgress(progress); + + const auto currentPacked = _visualProgressProviderState.load(std::memory_order_acquire); + if (_visualProgressProviderGeneration.load(std::memory_order_acquire) == recognitionGeneration && + currentPacked != 0 && + winTerm::VisualProgress::UnpackProviderProgress(currentPacked).sequence >= progress.sequence) + { + // A later callback already won publication while this raw + // write was in progress. + recognition.progress.reset(); + } + + if (recognition.progress) + { + // Generation is stored before the value. An invalidator + // that observes the new value therefore also observes its + // owner. + _visualProgressProviderGeneration.store(recognitionGeneration, std::memory_order_release); + const auto previous = _visualProgressProviderState.exchange(packed, std::memory_order_acq_rel); + if (_visualProgressRecognitionGeneration.load(std::memory_order_acquire) != recognitionGeneration || + _visualProgressRecognitionResetRequested.load(std::memory_order_acquire) || + !_visualProgressRecognitionEnabled.load(std::memory_order_acquire)) + { + // Remove only the value this callback published. + // Prompt, close, detach, or a contending callback may + // already own a newer generation. + auto expected = packed; + if (_visualProgressProviderGeneration.load(std::memory_order_acquire) == recognitionGeneration && + _visualProgressProviderState.compare_exchange_strong( + expected, + 0, + std::memory_order_acq_rel, + std::memory_order_acquire)) + { + raiseProviderChanged = true; + } + } + else if (previous != packed) + { + raiseProviderChanged = true; + } + } + } + + if (!recognitionLock.owns_lock() && + _visualProgressRecognitionResetRequested.load(std::memory_order_acquire)) + { + static_cast(recognitionLock.try_lock()); + } + if (recognitionLock.owns_lock()) + { + // Prompt/command completion can request reset from inside + // Terminal::Write. Clear bounded content before releasing the + // arbitration lock even if no later output arrives. + if (!_visualProgressRecognitionEnabled.load(std::memory_order_acquire)) + { + _visualProgressRecognition.reset(); + _visualProgressRecognitionResetRequested.store(false, std::memory_order_release); + } + else if (_visualProgressRecognitionResetRequested.exchange(false, std::memory_order_acq_rel) && + _visualProgressRecognition && + !_visualProgressRecognition->TryReset()) + { + _visualProgressRecognitionResetRequested.store(true, std::memory_order_release); + } + recognitionLock.unlock(); + } + + if (raiseProviderChanged) + { + // Raising after both terminal and recognition locks keeps UI + // work out of the parser/write critical path and avoids + // reentrant Configure deadlocks. + VisualProgressProviderChanged.raise(*this, nullptr); } if (!_pendingResponses.empty()) diff --git a/src/cascadia/TerminalControl/ControlCore.h b/src/cascadia/TerminalControl/ControlCore.h index 922f9783d..6fe96cdbc 100644 --- a/src/cascadia/TerminalControl/ControlCore.h +++ b/src/cascadia/TerminalControl/ControlCore.h @@ -39,6 +39,11 @@ namespace ControlUnitTests class ControlCoreTests; class ControlInteractivityTests; }; + +namespace winTerm::VisualProgress +{ + class RecognitionEngine; +} #define RUNTIME_SETTING(type, name, setting) \ private: \ @@ -165,6 +170,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation const size_t TaskbarProgress() const noexcept; const size_t ShellIntegrationState() const noexcept; const int64_t ShellIntegrationExitCode() const noexcept; + const uint64_t VisualProgressProviderState() const noexcept; hstring Title(); Windows::Foundation::IReference TabColor() noexcept; @@ -244,6 +250,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation bool IsInReadOnlyMode() const; void ToggleReadOnlyMode(); void SetReadOnlyMode(const bool readOnlyState); + void ConfigureVisualProgressRecognition(bool enabled, bool replaceRecognizedOutput); hstring ReadEntireBuffer() const; Control::CommandHistoryContext CommandHistory() const; @@ -284,6 +291,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation til::typed_event ScrollPositionChanged; til::typed_event<> TaskbarProgressChanged; til::typed_event<> ShellIntegrationChanged; + til::typed_event<> VisualProgressProviderChanged; til::typed_event<> ConnectionStateChanged; til::typed_event<> HoveredHyperlinkChanged; til::typed_event RendererEnteredErrorState; @@ -429,6 +437,17 @@ namespace winrt::Microsoft::Terminal::Control::implementation std::atomic _initializedTerminal{ false }; bool _isReadOnly{ false }; bool _closing{ false }; + std::mutex _visualProgressRecognitionMutex; + std::mutex _visualProgressSuppressionMutex; + std::unique_ptr _visualProgressRecognition; + std::atomic _visualProgressProviderState{}; + std::atomic _visualProgressProviderGeneration{}; + std::atomic _visualProgressProviderSequence{}; + std::atomic _visualProgressRecognitionEnabled{}; + std::atomic _visualProgressReplacementEnabled{}; + std::atomic _visualProgressRecognitionResetRequested{}; + std::atomic _visualProgressRecognitionGeneration{}; + std::atomic _visualProgressAlternateScreen{}; struct StashedColorScheme { diff --git a/src/cascadia/TerminalControl/ControlCore.idl b/src/cascadia/TerminalControl/ControlCore.idl index c0abfab23..78f43dabe 100644 --- a/src/cascadia/TerminalControl/ControlCore.idl +++ b/src/cascadia/TerminalControl/ControlCore.idl @@ -150,6 +150,7 @@ namespace Microsoft.Terminal.Control void ToggleShaderEffects(); void ToggleReadOnlyMode(); void SetReadOnlyMode(Boolean readOnlyState); + void ConfigureVisualProgressRecognition(Boolean enabled, Boolean replaceRecognizedOutput); Microsoft.Terminal.Core.Point CursorPosition { get; }; Boolean ForceCursorVisible; @@ -196,6 +197,7 @@ namespace Microsoft.Terminal.Control event Windows.Foundation.TypedEventHandler BackgroundColorChanged; event Windows.Foundation.TypedEventHandler TaskbarProgressChanged; event Windows.Foundation.TypedEventHandler ShellIntegrationChanged; + event Windows.Foundation.TypedEventHandler VisualProgressProviderChanged; event Windows.Foundation.TypedEventHandler RendererEnteredErrorState; event Windows.Foundation.TypedEventHandler ShowWindowChanged; event Windows.Foundation.TypedEventHandler SearchMissingCommand; diff --git a/src/cascadia/TerminalControl/ICoreState.idl b/src/cascadia/TerminalControl/ICoreState.idl index 2c28bdd53..dfa7bf062 100644 --- a/src/cascadia/TerminalControl/ICoreState.idl +++ b/src/cascadia/TerminalControl/ICoreState.idl @@ -35,6 +35,7 @@ namespace Microsoft.Terminal.Control UInt64 TaskbarProgress { get; }; UInt64 ShellIntegrationState { get; }; Int64 ShellIntegrationExitCode { get; }; + UInt64 VisualProgressProviderState { get; }; String WorkingDirectory { get; }; diff --git a/src/cascadia/TerminalControl/TermControl.cpp b/src/cascadia/TerminalControl/TermControl.cpp index 485bd67dc..8d54d65fa 100644 --- a/src/cascadia/TerminalControl/TermControl.cpp +++ b/src/cascadia/TerminalControl/TermControl.cpp @@ -323,6 +323,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation _revokers.TabColorChanged = _core.TabColorChanged(winrt::auto_revoke, { get_weak(), &TermControl::_bubbleTabColorChanged }); _revokers.TaskbarProgressChanged = _core.TaskbarProgressChanged(winrt::auto_revoke, { get_weak(), &TermControl::_bubbleSetTaskbarProgress }); _revokers.ShellIntegrationChanged = _core.ShellIntegrationChanged(winrt::auto_revoke, { get_weak(), &TermControl::_bubbleShellIntegrationChanged }); + _revokers.VisualProgressProviderChanged = _core.VisualProgressProviderChanged(winrt::auto_revoke, { get_weak(), &TermControl::_bubbleVisualProgressProviderChanged }); _revokers.ConnectionStateChanged = _core.ConnectionStateChanged(winrt::auto_revoke, { get_weak(), &TermControl::_bubbleConnectionStateChanged }); _revokers.ShowWindowChanged = _core.ShowWindowChanged(winrt::auto_revoke, { get_weak(), &TermControl::_bubbleShowWindowChanged }); _revokers.CloseTerminalRequested = _core.CloseTerminalRequested(winrt::auto_revoke, { get_weak(), &TermControl::_bubbleCloseTerminalRequested }); @@ -2673,6 +2674,17 @@ namespace winrt::Microsoft::Terminal::Control::implementation void TermControl::Detach() { + try + { + // Revoke replacement before disconnecting the event path to the + // pane renderer. The destination pane reenables recognition only + // after its renderer is ready. + _core.ConfigureVisualProgressRecognition(false, false); + } + catch (...) + { + LOG_CAUGHT_EXCEPTION(); + } _revokers = {}; Control::ControlInteractivity old{ nullptr }; @@ -2948,6 +2960,11 @@ namespace winrt::Microsoft::Terminal::Control::implementation void TermControl::WindowVisibilityChanged(const bool showOrHide) { _core.WindowVisibilityChanged(showOrHide); + } + + void TermControl::ConfigureVisualProgressRecognition(const bool enabled, const bool replaceRecognizedOutput) + { + _core.ConfigureVisualProgressRecognition(enabled, replaceRecognizedOutput); } // Method Description: @@ -3378,6 +3395,11 @@ namespace winrt::Microsoft::Terminal::Control::implementation return _core.ShellIntegrationExitCode(); } + const uint64_t TermControl::VisualProgressProviderState() const noexcept + { + return _core.VisualProgressProviderState(); + } + void TermControl::BellLightOn() { // Initialize the animation if it does not exist diff --git a/src/cascadia/TerminalControl/TermControl.h b/src/cascadia/TerminalControl/TermControl.h index 0497999f4..d2de4b299 100644 --- a/src/cascadia/TerminalControl/TermControl.h +++ b/src/cascadia/TerminalControl/TermControl.h @@ -82,6 +82,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation double QuickFixButtonCollapsedWidth(); void WindowVisibilityChanged(const bool showOrHide); + void ConfigureVisualProgressRecognition(bool enabled, bool replaceRecognizedOutput); void ColorSelection(Control::SelectionColor fg, Control::SelectionColor bg, Core::MatchMode matchMode); @@ -90,6 +91,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation const uint64_t TaskbarProgress() const noexcept; const uint64_t ShellIntegrationState() const noexcept; const int64_t ShellIntegrationExitCode() const noexcept; + const uint64_t VisualProgressProviderState() const noexcept; hstring Title(); Windows::Foundation::IReference TabColor() noexcept; @@ -228,6 +230,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation BUBBLED_FORWARDED_TYPED_EVENT(TabColorChanged, IInspectable, IInspectable); BUBBLED_FORWARDED_TYPED_EVENT(SetTaskbarProgress, IInspectable, IInspectable); BUBBLED_FORWARDED_TYPED_EVENT(ShellIntegrationChanged, IInspectable, IInspectable); + BUBBLED_FORWARDED_TYPED_EVENT(VisualProgressProviderChanged, IInspectable, IInspectable); BUBBLED_FORWARDED_TYPED_EVENT(ConnectionStateChanged, IInspectable, IInspectable); BUBBLED_FORWARDED_TYPED_EVENT(ShowWindowChanged, IInspectable, Control::ShowWindowArgs); BUBBLED_FORWARDED_TYPED_EVENT(CloseTerminalRequested, IInspectable, IInspectable); @@ -468,6 +471,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation Control::ControlCore::TabColorChanged_revoker TabColorChanged; Control::ControlCore::TaskbarProgressChanged_revoker TaskbarProgressChanged; Control::ControlCore::ShellIntegrationChanged_revoker ShellIntegrationChanged; + Control::ControlCore::VisualProgressProviderChanged_revoker VisualProgressProviderChanged; Control::ControlCore::ConnectionStateChanged_revoker ConnectionStateChanged; Control::ControlCore::ShowWindowChanged_revoker ShowWindowChanged; Control::ControlCore::CloseTerminalRequested_revoker CloseTerminalRequested; diff --git a/src/cascadia/TerminalControl/TermControl.idl b/src/cascadia/TerminalControl/TermControl.idl index 3ce9d1cf7..ceb177b82 100644 --- a/src/cascadia/TerminalControl/TermControl.idl +++ b/src/cascadia/TerminalControl/TermControl.idl @@ -68,6 +68,7 @@ namespace Microsoft.Terminal.Control event Windows.Foundation.TypedEventHandler OpenHyperlink; event Windows.Foundation.TypedEventHandler SetTaskbarProgress; event Windows.Foundation.TypedEventHandler ShellIntegrationChanged; + event Windows.Foundation.TypedEventHandler VisualProgressProviderChanged; event Windows.Foundation.TypedEventHandler RaiseNotice; event Windows.Foundation.TypedEventHandler WarningBell; event Windows.Foundation.TypedEventHandler HidePointerCursor; @@ -116,6 +117,7 @@ namespace Microsoft.Terminal.Control Single SnapDimensionToGrid(Boolean widthOrHeight, Single dimension); void WindowVisibilityChanged(Boolean showOrHide); + void ConfigureVisualProgressRecognition(Boolean enabled, Boolean replaceRecognizedOutput); void ScrollViewport(Int32 viewTop); diff --git a/src/cascadia/TerminalCore/Terminal.cpp b/src/cascadia/TerminalCore/Terminal.cpp index e8274c625..148471ecc 100644 --- a/src/cascadia/TerminalCore/Terminal.cpp +++ b/src/cascadia/TerminalCore/Terminal.cpp @@ -1281,6 +1281,11 @@ const int64_t Microsoft::Terminal::Core::Terminal::GetShellIntegrationExitCode() return _shellIntegrationExitCode; } +bool Microsoft::Terminal::Core::Terminal::IsInAlternateScreenBuffer() const noexcept +{ + return _inAltBuffer(); +} + void Microsoft::Terminal::Core::Terminal::CompletionsChangedCallback(std::function pfn) noexcept { _pfnCompletionsChanged.swap(pfn); diff --git a/src/cascadia/TerminalCore/Terminal.hpp b/src/cascadia/TerminalCore/Terminal.hpp index 6c5114b4e..296199eeb 100644 --- a/src/cascadia/TerminalCore/Terminal.hpp +++ b/src/cascadia/TerminalCore/Terminal.hpp @@ -248,6 +248,9 @@ class Microsoft::Terminal::Core::Terminal final : const size_t GetTaskbarProgress() const noexcept; const size_t GetShellIntegrationState() const noexcept; const int64_t GetShellIntegrationExitCode() const noexcept; + // Callers must already hold the terminal lock. This read-only boundary is + // used only to fail open before optional transient-output replacement. + bool IsInAlternateScreenBuffer() const noexcept; void ColorSelection(const TextAttribute& attr, winrt::Microsoft::Terminal::Core::MatchMode matchMode); void PreviewText(std::wstring_view input); diff --git a/src/cascadia/TerminalSettingsModel/GlobalAppSettings.idl b/src/cascadia/TerminalSettingsModel/GlobalAppSettings.idl index 4209e150d..8f8686ce7 100644 --- a/src/cascadia/TerminalSettingsModel/GlobalAppSettings.idl +++ b/src/cascadia/TerminalSettingsModel/GlobalAppSettings.idl @@ -132,6 +132,7 @@ namespace Microsoft.Terminal.Settings.Model INHERITABLE_SETTING(Boolean, ShowPaneProfileIcon); INHERITABLE_SETTING(Boolean, ShowPaneActiveStatus); INHERITABLE_SETTING(Boolean, VisualProgressEnabled); + INHERITABLE_SETTING(Boolean, VisualProgressReplaceRecognizedOutput); Windows.Foundation.Collections.IMapView ColorSchemes(); void AddColorScheme(ColorScheme scheme); diff --git a/src/cascadia/TerminalSettingsModel/MTSMSettings.h b/src/cascadia/TerminalSettingsModel/MTSMSettings.h index 2f7aa0324..016301377 100644 --- a/src/cascadia/TerminalSettingsModel/MTSMSettings.h +++ b/src/cascadia/TerminalSettingsModel/MTSMSettings.h @@ -89,7 +89,8 @@ Author(s): X(bool, ShowPaneHeaders, "winterm.applicationUI.showPaneHeaders", true) \ X(bool, ShowPaneProfileIcon, "winterm.applicationUI.showPaneProfileIcon", true) \ X(bool, ShowPaneActiveStatus, "winterm.applicationUI.showPaneActiveStatus", true) \ - X(bool, VisualProgressEnabled, "visualProgress.enabled", false) + X(bool, VisualProgressEnabled, "visualProgress.enabled", false) \ + X(bool, VisualProgressReplaceRecognizedOutput, "visualProgress.replaceRecognizedOutput", false) // Also add these settings to: // * Profile.idl From c6bb3226e1cb7125e5e8917d6d4de028908eae3a Mon Sep 17 00:00:00 2001 From: HelloThisWorld Date: Sat, 1 Aug 2026 07:13:19 +0800 Subject: [PATCH 4/5] test: add Visual Progress Phase 2 coverage --- .../winterm/invoke-visual-progress-smoke.ps1 | 195 ++- scripts/winterm/test-visual-progress.ps1 | 574 +++++++- .../WinTermVisualProgressTests.cpp | 1218 +++++++++++++++++ 3 files changed, 1930 insertions(+), 57 deletions(-) diff --git a/scripts/winterm/invoke-visual-progress-smoke.ps1 b/scripts/winterm/invoke-visual-progress-smoke.ps1 index 317308381..0404b757a 100644 --- a/scripts/winterm/invoke-visual-progress-smoke.ps1 +++ b/scripts/winterm/invoke-visual-progress-smoke.ps1 @@ -5,12 +5,24 @@ param( [Parameter()] [ValidateRange(0, 10000)] - [int]$DelayMilliseconds = 800 + [int]$DelayMilliseconds = 800, + + [Parameter()] + [ValidateRange(0, 10000)] + [int]$SoakIterations = 0 ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest +function Wait-DemoStep +{ + if ($DelayMilliseconds -gt 0) + { + Start-Sleep -Milliseconds $DelayMilliseconds + } +} + function Send-Osc { param( @@ -19,22 +31,185 @@ function Send-Osc ) [Console]::Write("$([char]27)]$Payload$([char]7)") - if ($DelayMilliseconds -gt 0) + Wait-DemoStep +} + +function Write-TransientFrame +{ + param( + [Parameter(Mandatory)] + [string]$Provider, + + [Parameter(Mandatory)] + [string]$Frame, + + [Parameter()] + [switch]$Quiet + ) + + if (-not $Quiet) { - Start-Sleep -Milliseconds $DelayMilliseconds + Write-Host "CLI provider fixture: $Provider (synthetic carriage-return frame)" + } + + # Keep the transient record and its erase-line terminator in one bounded + # write so replacement-preview testing exercises the immediate decision + # path rather than depending on PTY callback coalescing. + [Console]::Write("$Frame`r$([char]27)[2K") + Wait-DemoStep +} + +function Invoke-ProviderFixtures +{ + $fixtures = @( + [pscustomobject]@{ + Provider = 'Docker Pull' + Frame = 'demo-layer: Downloading 512B/1.0kB' + Summary = 'demo-layer: Pull complete (synthetic summary; must remain visible)' + }, + [pscustomobject]@{ + Provider = 'Docker BuildKit' + Frame = '#7 [3/8] RUN synthetic-build-step' + Summary = '#7 DONE 0.1s (synthetic summary; must remain visible)' + }, + [pscustomobject]@{ + Provider = 'pip' + Frame = 'sample_package.whl 50% 512kB/1.0MB 2.0MB/s eta 0:00:01' + Summary = 'Successfully downloaded sample-package (synthetic summary; must remain visible)' + }, + [pscustomobject]@{ + Provider = 'Git' + Frame = 'Receiving objects: 50% (50/100), 512.00 KiB | 1.00 MiB/s' + Summary = 'Receiving objects: 100% (100/100), done. (synthetic summary; must remain visible)' + }, + [pscustomobject]@{ + Provider = 'curl' + Frame = ' 50 1024k 50 512k 0 0 1024k 0 0:00:01 0:00:00 0:00:01 1024k' + Summary = 'curl synthetic transfer complete (ordinary output; must remain visible)' + }, + [pscustomobject]@{ + Provider = 'wget' + Frame = 'sample.bin 50%[========> ] 512K 1.00MB/s eta 1s' + Summary = 'sample.bin saved (synthetic summary; must remain visible)' + }, + [pscustomobject]@{ + Provider = 'npm' + Frame = 'npm resolving dependencies 5/10' + Summary = 'added 10 packages in 1s (synthetic summary; must remain visible)' + }, + [pscustomobject]@{ + Provider = 'pnpm' + Frame = 'pnpm Progress: resolved 10, reused 5, downloaded 3, added 2' + Summary = 'Packages: +10 (synthetic summary; must remain visible)' + }, + [pscustomobject]@{ + Provider = 'yarn' + Frame = 'yarn Fetching packages... 5/10' + Summary = 'Done in 1.00s. (synthetic summary; must remain visible)' + }, + [pscustomobject]@{ + Provider = 'nvm' + Frame = 'nvm Downloading node.js 50% (512/1024 kB)' + Summary = 'nvm installation complete (synthetic summary; must remain visible)' + }, + [pscustomobject]@{ + Provider = 'Maven' + Frame = '[INFO] Progress (1): 512/1024 kB' + Summary = '[INFO] BUILD SUCCESS (synthetic summary; must remain visible)' + }, + [pscustomobject]@{ + Provider = 'Gradle' + Frame = '<==========----> 75% EXECUTING [1s]' + Summary = 'BUILD SUCCESSFUL in 1s (synthetic summary; must remain visible)' + }, + [pscustomobject]@{ + Provider = 'Generic fallback' + Frame = '42% (42/100) 1.0 MB/s ETA 00:01' + Summary = 'ordinary log after generic progress (must remain visible)' + } + ) + + foreach ($fixture in $fixtures) + { + if ($fixture.Provider -eq 'curl') + { + Write-Host ' % Total % Received % Xferd Average Speed Time Time Time Current' + Wait-DemoStep + } + elseif ($fixture.Provider -eq 'wget') + { + Write-Host "Saving to: 'sample.bin'" + Wait-DemoStep + } + + Write-TransientFrame -Provider $fixture.Provider -Frame $fixture.Frame + Write-Host $fixture.Summary + Wait-DemoStep } } +function Invoke-BoundedSoak +{ + if ($SoakIterations -eq 0) + { + return + } + + Write-Host "Visual Progress optional bounded soak: $SoakIterations iterations" + for ($iteration = 0; $iteration -lt $SoakIterations; $iteration++) + { + $value = $iteration % 101 + Send-Osc "9;4;1;$value" + + if ((($iteration + 1) % 100) -eq 0) + { + Write-TransientFrame -Provider 'Generic soak sample' -Frame "$value% ($value/100) 1.0 MB/s ETA 00:01" -Quiet + } + } + + Send-Osc '9;4;0' + Write-Host 'Visual Progress optional bounded soak complete; explicit progress cleared.' +} + try { - Write-Host 'Visual Progress fixture: determinate 50%' - Send-Osc '9;4;1;50' + Write-Host @' +Visual Progress manual checks: +- For active/background behavior, run this script in two split panes and move focus between them. +- Only the active pane should emit sparks; inactive panes should keep independent, simplified progress. +- Minimize, switch tabs, or deactivate the window to verify animation pauses or simplifies. +- Rerun after disabling Windows animations to verify the static Reduced Motion fallback. +- Rerun with a Windows contrast theme to verify the solid High Contrast fallback. +'@ + + foreach ($payload in @('9;4;1;0', '9;4;1;1', '9;4;1;50', '9;4;1;99', '9;4;1;100')) + { + $value = $payload.Split(';')[-1] + Write-Host "Visual Progress fixture: determinate $value%" + Send-Osc $payload + } + + Write-Host 'Visual Progress fixture: real phase regression from 80% to 20%' + Send-Osc '9;4;1;80' + Send-Osc '9;4;1;20' + Write-Host 'Visual Progress fixture: paused at 65%' Send-Osc '9;4;4;65' - Write-Host 'Visual Progress fixture: error retaining 65%' - Send-Osc '9;4;2;65' + Write-Host 'Visual Progress fixture: indeterminate' Send-Osc '9;4;3' + + Write-Host 'Visual Progress fixture: error retaining 65%' + Send-Osc '9;4;2;65' + + Write-Host 'Visual Progress fixture: explicit cancellation and clear' + Send-Osc '9;4;0' + + Write-Host 'Visual Progress fixture: semantic command cancellation at next prompt' + Send-Osc '133;B' + Send-Osc '133;C' + Send-Osc '133;A' + Write-Host 'Visual Progress fixture: clear explicit progress' Send-Osc '9;4;0' @@ -48,9 +223,15 @@ try Write-Host 'Visual Progress fixture: semantic failed command finish' Send-Osc '133;B' + Send-Osc '133;C' Send-Osc '133;D;1' Send-Osc '133;A' Send-Osc '9;4;0' + + Invoke-ProviderFixtures + Invoke-BoundedSoak + + Write-Host 'Visual Progress smoke fixture complete. No files or external commands were used.' -ForegroundColor Green } catch { diff --git a/scripts/winterm/test-visual-progress.ps1 b/scripts/winterm/test-visual-progress.ps1 index 226b7a7de..70bb79592 100644 --- a/scripts/winterm/test-visual-progress.ps1 +++ b/scripts/winterm/test-visual-progress.ps1 @@ -40,84 +40,464 @@ function Assert-Contains } } +function Assert-NotContains +{ + param( + [Parameter(Mandatory)] + [string]$Content, + + [Parameter(Mandatory)] + [string]$Value, + + [Parameter(Mandatory)] + [string]$Description + ) + + if ($Content.Contains($Value)) + { + throw "$Description unexpectedly contains '$Value'." + } +} + +function Assert-Matches +{ + param( + [Parameter(Mandatory)] + [string]$Content, + + [Parameter(Mandatory)] + [string]$Pattern, + + [Parameter(Mandatory)] + [string]$Description + ) + + if ($Content -notmatch $Pattern) + { + throw "$Description does not match '$Pattern'." + } +} + +function Assert-NotMatches +{ + param( + [Parameter(Mandatory)] + [string]$Content, + + [Parameter(Mandatory)] + [string]$Pattern, + + [Parameter(Mandatory)] + [string]$Description + ) + + if ($Content -match $Pattern) + { + throw "$Description matches forbidden pattern '$Pattern'." + } +} + +function Assert-Before +{ + param( + [Parameter(Mandatory)] + [string]$Content, + + [Parameter(Mandatory)] + [string]$Before, + + [Parameter(Mandatory)] + [string]$After, + + [Parameter(Mandatory)] + [string]$Description + ) + + $beforeIndex = $Content.IndexOf($Before, [System.StringComparison]::Ordinal) + $afterIndex = $Content.IndexOf($After, [System.StringComparison]::Ordinal) + if ($beforeIndex -lt 0 -or $afterIndex -lt 0 -or $beforeIndex -ge $afterIndex) + { + throw "$Description must place '$Before' before '$After'." + } +} + +function Get-RequiredContent +{ + param( + [Parameter(Mandatory)] + [string]$Root, + + [Parameter(Mandatory)] + [string]$RelativePath + ) + + $path = Join-Path $Root $RelativePath + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) + { + throw "Visual Progress Phase 2 boundary '$RelativePath' is missing." + } + return Get-Content -LiteralPath $path -Raw +} + try { $root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path - $requiredFiles = @( - 'src\winterm\VisualProgress\VisualProgressModel.h', - 'src\cascadia\UnitTests_SettingsModel\WinTermVisualProgressTests.cpp', - 'src\cascadia\TerminalApp\Pane.cpp', - 'src\cascadia\TerminalApp\Pane.h', - 'src\cascadia\TerminalApp\App.xaml', - 'src\cascadia\TerminalSettingsModel\MTSMSettings.h', - 'src\cascadia\TerminalSettingsModel\GlobalAppSettings.idl', - 'src\terminal\adapter\adaptDispatch.cpp', - 'docs\development\visual-progress-phase1.md', - 'scripts\winterm\invoke-visual-progress-smoke.ps1' - ) - foreach ($relativePath in $requiredFiles) + $paths = [ordered]@{ + Model = 'src\winterm\VisualProgress\VisualProgressModel.h' + Constants = 'src\winterm\VisualProgress\RainbowArcVisualConstants.h' + RenderModel = 'src\winterm\VisualProgress\VisualProgressRenderModel.h' + Renderer = 'src\winterm\VisualProgress\RainbowArcRenderer.h' + Recognition = 'src\winterm\VisualProgress\ProgressRecognition.h' + Tests = 'src\cascadia\UnitTests_SettingsModel\WinTermVisualProgressTests.cpp' + TestProject = 'src\cascadia\UnitTests_SettingsModel\SettingsModel.UnitTests.vcxproj' + PaneCpp = 'src\cascadia\TerminalApp\Pane.cpp' + PaneH = 'src\cascadia\TerminalApp\Pane.h' + AppXaml = 'src\cascadia\TerminalApp\App.xaml' + TerminalPaneContentCpp = 'src\cascadia\TerminalApp\TerminalPaneContent.cpp' + TerminalPaneContentH = 'src\cascadia\TerminalApp\TerminalPaneContent.h' + TerminalPaneContentIdl = 'src\cascadia\TerminalApp\TerminalPaneContent.idl' + ControlCoreCpp = 'src\cascadia\TerminalControl\ControlCore.cpp' + ControlCoreH = 'src\cascadia\TerminalControl\ControlCore.h' + ControlCoreIdl = 'src\cascadia\TerminalControl\ControlCore.idl' + ICoreStateIdl = 'src\cascadia\TerminalControl\ICoreState.idl' + TermControlCpp = 'src\cascadia\TerminalControl\TermControl.cpp' + TermControlH = 'src\cascadia\TerminalControl\TermControl.h' + TermControlIdl = 'src\cascadia\TerminalControl\TermControl.idl' + TerminalCpp = 'src\cascadia\TerminalCore\Terminal.cpp' + TerminalHpp = 'src\cascadia\TerminalCore\Terminal.hpp' + Settings = 'src\cascadia\TerminalSettingsModel\MTSMSettings.h' + SettingsIdl = 'src\cascadia\TerminalSettingsModel\GlobalAppSettings.idl' + Dispatch = 'src\terminal\adapter\adaptDispatch.cpp' + Phase1Doc = 'docs\development\visual-progress-phase1.md' + Phase2Doc = 'docs\development\visual-progress-phase2.md' + Smoke = 'scripts\winterm\invoke-visual-progress-smoke.ps1' + } + + $source = @{} + foreach ($entry in $paths.GetEnumerator()) { - if (-not (Test-Path -LiteralPath (Join-Path $root $relativePath) -PathType Leaf)) - { - throw "Visual Progress boundary '$relativePath' is missing." - } + $source[$entry.Key] = Get-RequiredContent -Root $root -RelativePath $entry.Value } - $model = Get-Content -LiteralPath (Join-Path $root $requiredFiles[0]) -Raw + $model = $source.Model foreach ($required in @( 'ProgressMode', 'ProgressStatus', 'ProgressSource', + 'Provider,', + 'ProgressProvider', + 'ProviderConfidence', + 'ProviderProgress', + 'PackProviderProgress', + 'UnpackProviderProgress', 'ProgressStateMachine', + 'ApplyProvider', + 'ProgressSource::Provider', + '_providerSnapshot', + '_fallbackSnapshot', + 'SamePresentation', 'ProgressUpdateMailbox', + 'std::try_to_lock', 'std::min(value, 100)', - 'SamePresentation', 'ShellLifecycleState::CommandStart', 'ShellLifecycleState::CommandFinished', 'emergencyOverride != L"1"' )) { - Assert-Contains $model $required 'Normalized progress model' + Assert-Contains $model $required 'Extended normalized progress model' + } + Assert-NotMatches $model 'std::wstring(?!_view)|std::string(?!_view)|winrt::hstring' 'Normalized progress state text-retention boundary' + + $providers = @( + 'DockerPull', + 'DockerBuildKit', + 'Pip', + 'Git', + 'Curl', + 'Wget', + 'Npm', + 'Pnpm', + 'Yarn', + 'Nvm', + 'Maven', + 'Gradle', + 'Generic' + ) + foreach ($provider in $providers) + { + Assert-Contains $model $provider "Normalized provider enum ($provider)" + Assert-Contains $source.Recognition "ProgressProvider::$provider" "Built-in recognition provider ($provider)" + Assert-Contains $source.Tests "ProgressProvider::$provider" "Compiled provider fixture ($provider)" + } + + $constants = $source.Constants + foreach ($required in @( + 'TrackHeight{ 6.0f }', + 'HorizontalInset{ 10.0f }', + 'BottomInset{ 8.0f }', + 'TrackCornerRadius{ TrackHeight / 2.0f }', + 'WhiteCoreWidth{ 2.5f }', + 'WarmCoreWidth{ 5.0f }', + 'HeadTrailWidth{ 8.0f }', + 'InnerGlowWidth{ 12.0f }', + 'OuterBloomWidth{ 26.0f }', + 'OverlayHostHeight{ BottomInset + TrackHeight + OuterBloomHeight }', + 'RainbowCycleDuration{ 2000 }', + 'IndeterminateCycleDuration{ 1800 }', + 'DeterminateInterpolationDuration{ 220 }', + 'RegressionInterpolationDuration{ 240 }', + 'WaitingBreatheDuration{ 1600 }', + 'SuccessSweepDuration{ 320 }', + 'SuccessFadeDuration{ 650 }', + 'ErrorPulseDuration{ 220 }', + 'CancelFadeDuration{ 180 }', + 'MinimumSparkLifetime{ 120 }', + 'MaximumSparkLifetime{ 260 }', + 'SparkPoolCapacityPerPane{ 8 }', + 'SparkCapacityPerWindowOrProcess{ 24 }' + )) + { + Assert-Contains $constants $required 'Centralized Rainbow Arc visual constants' + } + foreach ($color in @('RainbowRed', 'RainbowOrange', 'RainbowYellow', 'RainbowGreen', 'RainbowCyan', 'RainbowBlue', 'RainbowViolet', 'RainbowMagenta')) + { + Assert-Contains $constants $color 'Centralized continuous rainbow palette' + } + + $renderModel = $source.RenderModel + foreach ($required in @( + 'enum class RenderTier', + 'Full,', + 'NoSparks,', + 'StaticGradient,', + 'Solid,', + 'Disabled,', + 'NextLowerRenderTier', + 'struct RenderEnvironment', + 'animationsEnabled', + 'highContrast', + 'paneActive', + 'windowVisible', + 'windowFocused', + 'UsesStaticFallback', + 'AllowsContinuousAnimation', + 'using RenderTimestamp = std::chrono::milliseconds', + 'struct RenderTransitionPlan', + 'phaseReset', + 'indeterminateMoving', + 'successSweep', + 'finalSparkBurst', + 'errorPulse', + 'releaseAfterTransition', + 'class VisualProgressRenderState', + 'class SparkBudget', + 'class SparkPool', + 'std::array', + 'RequiresSparkWork' + )) + { + Assert-Contains $renderModel $required 'Deterministic renderer and resource model' + } + + $renderer = $source.Renderer + foreach ($required in @( + 'class RainbowArcRenderer final', + 'TryCreate', + '_initializeSolidFallback', + '_initializeCompositionBase', + '_initializeGradientStage', + '_initializeHeadStage', + '_initializeSparkStage', + 'CreateRoundedRectangleGeometry', + 'CreateInsetClip', + '_rainbowFillVisual', + '_rainbowBrush = _createRainbowBrush()', + '_rainbowBrush.StartAnimation(L"Offset"', + '_whiteCore', + '_warmCore', + '_headTrail', + '_innerGlow', + '_outerBloom', + 'std::array', + '_sparkPool.Acquire', + '_sparkPool.Release', + 'CreateScopedBatch', + '_applyWithDegradation', + 'UsesStaticFallback', + '_stopAllAnimations', + '_releaseAllSparks', + 'void Close() noexcept', + 'inline static SparkBudget _sharedSparkBudget' + )) + { + Assert-Contains $renderer $required 'Dedicated Rainbow Arc composition renderer' + } + if ([regex]::Matches($renderer, '_createRainbowBrush\(').Count -ne 2) + { + throw 'The Rainbow Arc gradient brush must have one initializer call and one cached-brush factory definition.' } - if ($model -match '(?i)timer|animation|particle|spark|glow|bloom|scrollback|command text|environment variables') + + $paneVisualStart = $source.PaneCpp.IndexOf('void Pane::_SetVisualProgressEnabled', [System.StringComparison]::Ordinal) + $paneVisualEnd = $source.PaneCpp.IndexOf('void Pane::_UpdatePaneHeader', $paneVisualStart, [System.StringComparison]::Ordinal) + if ($paneVisualStart -lt 0 -or $paneVisualEnd -le $paneVisualStart) { - throw 'The normalized Visual Progress core contains a Phase 2 effect, polling primitive, or sensitive data field.' + throw 'The Pane Visual Progress integration boundary could not be isolated.' } + $paneVisualProgress = $source.PaneCpp.Substring($paneVisualStart, $paneVisualEnd - $paneVisualStart) + $visualImplementation = "$renderer`n$renderModel`n$constants`n$paneVisualProgress" + foreach ($forbidden in @('DispatcherTimer', 'CompositionTarget::Rendering', 'Storyboard')) + { + Assert-NotContains $visualImplementation $forbidden 'Visual Progress CPU frame-loop and per-pane timer boundary' + } + foreach ($forbidden in @('std::thread', 'std::jthread', 'std::async', 'CreateThread', 'ThreadPool')) + { + Assert-NotContains "$renderer`n$renderModel`n$($source.Recognition)" $forbidden 'Visual Progress worker boundary' + } + Assert-NotContains $visualImplementation 'GaussianBlur' 'Localized glow boundary' + Assert-NotMatches "$renderer`n$renderModel" 'std::vector\s*<\s*(?:Spark|SparkVisual|Slot)|std::deque\s*<\s*(?:Spark|SparkVisual|Slot)' 'Fixed particle-container boundary' + Assert-NotMatches $renderer '\bnew\s+(?:Spark|SparkVisual)|make_(?:unique|shared)\s*<\s*(?:Spark|SparkVisual)' 'Per-burst particle-allocation boundary' + foreach ($codePoint in 0x2580..0x259f) + { + Assert-NotContains $visualImplementation ([string][char]$codePoint) 'Continuous geometry block-glyph boundary' + } + Assert-NotContains $visualImplementation ([string][char]0x2501) 'Continuous geometry heavy-line glyph boundary' - $pane = Get-Content -LiteralPath (Join-Path $root $requiredFiles[2]) -Raw + $pane = $source.PaneCpp foreach ($required in @( 'WINTERM_DISABLE_VISUAL_PROGRESS', - '_content.TaskbarState()', - '_content.TaskbarProgress()', - 'ShellIntegrationChanged', - '_visualProgressOverlay.Height(6.0)', - 'ThicknessHelper::FromLengths(10.0, 0.0, 10.0, 8.0)', + 'RainbowArcVisualConstants::OverlayHostHeight', '_visualProgressOverlay.IsHitTestVisible(false)', 'Controls::Grid::SetRow(_visualProgressOverlay, 1)', + 'AccessibilityView::Raw', + 'RainbowArcRenderer::TryCreate(_visualProgressCompositionHost)', + 'const auto rendererReady = _visualProgressRenderer && !_visualProgressRenderer->Faulted()', + '_visualProgressRendererReady.store(rendererReady', + '_visualProgressRenderer->SetPaneActive(_lastActive)', + '_visualProgressRenderer->RefreshEnvironment()', + '_visualProgressRenderer->Apply(snapshot)', + '_visualProgressRenderer->Close()', + '_visualProgressMailbox.TakeLatest()', 'std::weak_ptr', 'CoreDispatcherPriority::Low', - '_visualProgressMailbox.TakeLatest()', - '_paneTaskbarProgressChangedRevoker.revoke()', - '_paneShellIntegrationChangedRevoker.revoke()', + '_paneVisualProgressProviderChangedRevoker.revoke()', '_visualProgressState.Reset()', '_visualProgressMailbox.Close()', - 'LOG_CAUGHT_EXCEPTION()', '_DestroyVisualProgressOverlay()' )) { - Assert-Contains $pane $required 'Per-pane Visual Progress integration' + Assert-Contains $pane $required 'Per-pane Rainbow Arc integration' + } + Assert-Matches $pane '(?s)const auto enabled = _visualProgressEnabled\.load\([^;]+&&\s*!_visualProgressFaulted\.load\([^;]+&&\s*_visualProgressRendererReady\.load\([^;]+;\s*terminalContent\.GetTermControl\(\)\.ConfigureVisualProgressRecognition\(\s*enabled,\s*enabled &&' 'Actual renderer-readiness suppression gate' + Assert-NotContains $pane '_visualProgressOverlay.Height(6.0)' 'Centralized overlay geometry boundary' + Assert-NotContains $pane '_visualProgressOverlay.RowDefinitions' 'Viewport-preserving overlay boundary' + + $recognition = $source.Recognition + foreach ($required in @( + 'struct RecognitionOptions', + 'replacementEnabled', + 'rendererEnabled', + 'normalScreen', + 'parserHealthy', + 'struct RecognitionResult', + 'suppressInput', + 'overflow', + 'healthy', + 'accepted', + 'class RecognitionEngine final', + 'RecognitionResult Consume', + 'std::try_to_lock', + 'if (!lock.owns_lock())', + 'MaxChunkCodeUnits = 32 * 1024', + 'MaxCurrentLineCodeUnits = 2048', + 'MaxAnsiSequenceCodeUnits = 64', + 'RecentProgressCapacity = 8', + 'DockerLayerCapacity = 32', + 'BuildKitStepCapacity = 32', + 'PublicationIntervalMilliseconds = 50', + 'std::array', + 'std::array', + 'std::array', + 'std::array', + 'progress.confidence == ProviderConfidence::High', + 'progress.provider == ProgressProvider::Pip', + 'progress.provider == ProgressProvider::Git', + 'progress.provider == ProgressProvider::Curl', + 'progress.provider == ProgressProvider::Wget', + 'progress.provider != ProgressProvider::Generic', + 'class Utf8RecognitionAdapter final', + 'MaxChunkBytes', + 'bool TryReset() noexcept' + )) + { + Assert-Contains $recognition $required 'Bounded fail-open recognition framework' + } + Assert-Matches $recognition '(?s)result\.suppressInput = immediateWholeChunk\s*&&\s*options\.replacementEnabled\s*&&\s*options\.rendererEnabled\s*&&\s*options\.normalScreen\s*&&\s*options\.parserHealthy\s*&&\s*result\.healthy' 'Immediate safe-suppression gates' + Assert-NotContains $recognition '#include ' 'Bounded recognizer regular-expression boundary' + Assert-NotContains $recognition 'std::regex' 'Bounded recognizer regular-expression boundary' + Assert-NotMatches $recognition '(?i)TraceLogging|OutputDebugString|printf\s*\(|wprintf\s*\(|std::c(?:out|err)|LOG_[A-Z_]*\s*\(' 'Recognition privacy and output-logging boundary' + + $controlCore = $source.ControlCoreCpp + foreach ($required in @( + '../../winterm/VisualProgress/ProgressRecognition.h', + 'ConfigureVisualProgressRecognition', + 'std::make_unique()', + 'std::unique_lock recognitionLock{ _visualProgressRecognitionMutex, std::try_to_lock }', + '_visualProgressRecognition->Consume(output', + '_terminal->Write(output)', + '_terminal->IsInAlternateScreenBuffer()', + '!wasAlternateScreen', + 'wasAlternateScreen || isAlternateScreen', + '_visualProgressRecognitionGeneration', + '== recognitionGeneration', + '_visualProgressRecognitionResetRequested', + 'const auto resetSinceInspection', + 'const auto unsafeIngress', + 'const auto discardRecognition', + 'recognition.accepted', + 'recognition.healthy', + 'recognition.overflow', + 'PackProviderProgress', + 'VisualProgressProviderChanged.raise' + )) + { + Assert-Contains $controlCore $required 'Terminal-output recognition fail-open integration' } - if ($pane -match '(?i)DispatcherTimer|CompositionAnimation|VisualProgress.*Storyboard') + Assert-Before $controlCore '_visualProgressRecognition->Consume(output' '_terminal->Write(output)' 'Immediate recognition decision boundary' + $handlerStart = $controlCore.IndexOf('void ControlCore::_connectionOutputHandler', [System.StringComparison]::Ordinal) + $handlerEnd = $controlCore.IndexOf('::Microsoft::Console::Render::Renderer* ControlCore::GetRenderer', $handlerStart, [System.StringComparison]::Ordinal) + if ($handlerStart -lt 0 -or $handlerEnd -le $handlerStart) { - throw 'The Phase 1 pane overlay must remain static and timer-free.' + throw 'The terminal-output recognition handler boundary could not be isolated.' } + $outputHandler = $controlCore.Substring($handlerStart, $handlerEnd - $handlerStart) + Assert-NotMatches $outputHandler '(?i)TraceLogging|OutputDebugString|printf\s*\(|wprintf\s*\(|std::c(?:out|err)|Log(?:Terminal|Output|Command)' 'Terminal-output privacy boundary' - $settings = Get-Content -LiteralPath (Join-Path $root $requiredFiles[5]) -Raw - Assert-Contains $settings 'VisualProgressEnabled, "visualProgress.enabled", false' 'Visual Progress setting' - $settingsIdl = Get-Content -LiteralPath (Join-Path $root $requiredFiles[6]) -Raw - Assert-Contains $settingsIdl 'INHERITABLE_SETTING(Boolean, VisualProgressEnabled)' 'Visual Progress setting projection' + Assert-Contains $source.TerminalHpp 'bool IsInAlternateScreenBuffer() const noexcept' 'Read-only alternate-screen API' + Assert-Contains $source.TerminalCpp 'bool Microsoft::Terminal::Core::Terminal::IsInAlternateScreenBuffer() const noexcept' 'Read-only alternate-screen implementation' + Assert-Contains $source.TerminalCpp 'return _inAltBuffer();' 'Read-only alternate-screen state' + + foreach ($required in @( + 'UInt64 VisualProgressProviderState { get; };' + )) + { + Assert-Contains $source.ICoreStateIdl $required 'Structural provider-state ABI' + Assert-Contains $source.TerminalPaneContentIdl $required 'Pane provider-state ABI' + } + foreach ($idl in @($source.ControlCoreIdl, $source.TermControlIdl)) + { + Assert-Contains $idl 'ConfigureVisualProgressRecognition(Boolean enabled, Boolean replaceRecognizedOutput)' 'Recognition configuration ABI' + Assert-Contains $idl 'VisualProgressProviderChanged' 'Provider-state event ABI' + } + Assert-Contains $source.TerminalPaneContentIdl 'VisualProgressProviderChanged' 'Pane provider-state event ABI' + Assert-Contains $source.TermControlCpp '_core.ConfigureVisualProgressRecognition(enabled, replaceRecognizedOutput)' 'TermControl recognition forwarding' + Assert-Contains $source.TermControlCpp 'return _core.VisualProgressProviderState()' 'TermControl provider-state forwarding' + Assert-Contains $source.TerminalPaneContentCpp 'VisualProgressProviderChanged.raise' 'Pane provider-state event bubbling' + + Assert-Contains $source.Settings 'VisualProgressEnabled, "visualProgress.enabled", false' 'Visual Progress default-off setting' + Assert-Contains $source.Settings 'VisualProgressReplaceRecognizedOutput, "visualProgress.replaceRecognizedOutput", false' 'Replacement preview default-off setting' + Assert-Contains $source.SettingsIdl 'INHERITABLE_SETTING(Boolean, VisualProgressEnabled)' 'Visual Progress setting projection' + Assert-Contains $source.SettingsIdl 'INHERITABLE_SETTING(Boolean, VisualProgressReplaceRecognizedOutput)' 'Replacement preview setting projection' - $dispatch = Get-Content -LiteralPath (Join-Path $root $requiredFiles[7]) -Raw foreach ($required in @( 'ShellIntegrationMarkKind::Prompt', 'ShellIntegrationMarkKind::CommandStart', @@ -125,34 +505,128 @@ try 'ShellIntegrationMarkKind::CommandFinished' )) { - Assert-Contains $dispatch $required 'Semantic shell lifecycle boundary' + Assert-Contains $source.Dispatch $required 'Semantic shell lifecycle boundary' } - $tests = Get-Content -LiteralPath (Join-Path $root $requiredFiles[1]) -Raw + $tests = $source.Tests foreach ($required in @( 'MapEveryTaskbarState', 'ClampDeterminateValues', 'SuppressDuplicateState', + 'ProviderProgressPrecedesShellLifecycle', + 'StandardProgressPrecedesProviderAndFallsBack', + 'ProviderStatePackingContainsOnlyStructuralFields', + 'RecognitionClassifiesProvidersAndGenericFallback', + 'RecognitionHandlesFragmentationAndMalformedInput', + 'RecognitionSuppressesOnlySafeWholeChunks', + 'RecognitionBoundsStateAndCoalescesUpdates', + 'RendererPlansRealValuesRegressionAndIndeterminateMode', + 'RendererPlansStatusAndAccessibilityFallbacks', + 'RendererFailureAndCloseRemainPaneLocal', + 'SparkPoolsEnforcePaneAndGlobalCaps', + 'BackgroundAndHiddenPanesDoNotRequestSparkWork', 'EmergencyOverridePrecedesSetting', + 'DisabledFeatureIgnoresEvents', 'MultiplePanesRemainIndependent', 'CloseAndDetachCleanupStopsUpdates', 'SplitOrDetachResetClearsReusableState', 'FeatureReloadDisablesAndReenablesCleanly', 'MailboxCoalescesRapidUpdatesAndReleasesOnClose', - 'SettingSerializesAndMissingSettingDefaultsOff' + 'SettingSerializesAndMissingSettingDefaultsOff', + 'uint8_t{ 0 }, uint8_t{ 1 }, uint8_t{ 50 }, uint8_t{ 99 }, uint8_t{ 100 }', + 'Utf8RecognitionAdapter', + 'MaxChunkCodeUnits + 1', + 'MaxCurrentLineCodeUnits + 1', + 'MaxAnsiSequenceCodeUnits + 1', + 'rendererEnabled = false', + 'normalScreen = false', + 'parserHealthy = false', + 'i < 10000', + 'uint8_t{ 8 }', + 'uint8_t{ 24 }', + 'visualProgress.replaceRecognizedOutput' )) { - Assert-Contains $tests $required 'Visual Progress compiled test coverage' + Assert-Contains $tests $required 'Visual Progress Phase 2 compiled test coverage' } + Assert-Contains $source.TestProject '' 'Compiled Visual Progress test registration' - $project = Get-Content -LiteralPath (Join-Path $root 'src\cascadia\UnitTests_SettingsModel\SettingsModel.UnitTests.vcxproj') -Raw - Assert-Contains $project '' 'Compiled Visual Progress test registration' + $smoke = $source.Smoke + foreach ($required in @( + "'9;4;1;0'", + "'9;4;1;1'", + "'9;4;1;50'", + "'9;4;1;99'", + "'9;4;1;100'", + "'9;4;1;80'", + "'9;4;1;20'", + "'9;4;4;65'", + "'9;4;3'", + "'9;4;2;65'", + "'9;4;0'", + "'133;D;0'", + "'133;D;1'", + 'Docker Pull', + 'Docker BuildKit', + "Provider = 'pip'", + "Provider = 'Git'", + "Provider = 'curl'", + "Provider = 'wget'", + 'Saving to:', + 'sample.bin 50%[', + "Provider = 'npm'", + "Provider = 'pnpm'", + "Provider = 'yarn'", + "Provider = 'nvm'", + "Provider = 'Maven'", + "Provider = 'Gradle'", + 'Generic fallback', + 'active pane should emit sparks', + 'Reduced Motion', + 'High Contrast', + '[ValidateRange(0, 10000)]', + 'SoakIterations', + 'synthetic summary; must remain visible', + 'No files or external commands were used.' + )) + { + Assert-Contains $smoke $required 'Manual Visual Progress Phase 2 fixture' + } + Assert-NotMatches $smoke '(?im)^\s*(?:docker|python|pip|git|curl(?:\.exe)?|wget(?:\.exe)?|npm|pnpm|yarn|nvm|mvn|gradlew)\b' 'Dependency-free synthetic smoke fixture' + Assert-NotMatches $smoke '(?i)Out-File|Set-Content|Add-Content|Export-[A-Za-z]+|Invoke-WebRequest|Start-Process|Invoke-Expression' 'Artifact-free synthetic smoke fixture' - $fixture = Get-Content -LiteralPath (Join-Path $root $requiredFiles[9]) -Raw - foreach ($required in @('9;4;0', '9;4;1;50', '9;4;3', '9;4;4;65', '9;4;2;65', '133;B', '133;D;0')) + $phase2Doc = $source.Phase2Doc + foreach ($required in @( + '# Visual Progress Phase 2', + 'Rainbow Arc Weld renderer', + 'one-element UI mailbox', + 'The composition layers', + '8 live sparks per active pane', + '24 live sparks globally', + 'There is no CPU-driven frame loop', + 'Reduced Motion and High Contrast', + 'Bounded CLI recognition', + 'try-lock-and-drop behavior', + 'Docker Pull', + 'Docker BuildKit', + 'Generic fallback', + 'visualProgress.replaceRecognizedOutput', + 'defaults to `false`', + 'pip, Git, curl, or wget', + 'generic output is never suppressible', + 'alternate-screen output', + 'lifecycle-generation changes', + 'does not upload or persist terminal content', + 'WINTERM_DISABLE_VISUAL_PROGRESS=1', + 'invoke-visual-progress-smoke.ps1', + 'SoakIterations 1000', + 'Phase 3', + 'version remain 1.1.3' + )) { - Assert-Contains $fixture $required 'Manual Visual Progress fixture' + Assert-Contains $phase2Doc $required 'Visual Progress Phase 2 developer documentation' } + Assert-Contains $source.Phase1Doc '[Visual Progress Phase 2](visual-progress-phase2.md)' 'Phase 1 documentation hand-off' $testBinary = Join-Path $root "bin\$Platform\$Configuration\UnitTests_SettingsModel\SettingsModel.Unit.Tests.dll" if ($SourceOnly) @@ -176,7 +650,7 @@ try Write-Host 'SKIP: compiled Visual Progress tests are unavailable.' -ForegroundColor Yellow } - Write-Host 'PASS: Visual Progress Phase 1 source, lifecycle, and safety boundaries.' -ForegroundColor Green + Write-Host 'PASS: Visual Progress Phase 2 renderer, recognition, lifecycle, privacy, and safety boundaries.' -ForegroundColor Green } catch { diff --git a/src/cascadia/UnitTests_SettingsModel/WinTermVisualProgressTests.cpp b/src/cascadia/UnitTests_SettingsModel/WinTermVisualProgressTests.cpp index c8ef6714c..7c8f5867e 100644 --- a/src/cascadia/UnitTests_SettingsModel/WinTermVisualProgressTests.cpp +++ b/src/cascadia/UnitTests_SettingsModel/WinTermVisualProgressTests.cpp @@ -4,7 +4,9 @@ #include "pch.h" #include "../TerminalSettingsModel/GlobalAppSettings.h" +#include "../../winterm/VisualProgress/ProgressRecognition.h" #include "../../winterm/VisualProgress/VisualProgressModel.h" +#include "../../winterm/VisualProgress/VisualProgressRenderModel.h" using namespace WEX::TestExecution; using namespace winTerm::VisualProgress; @@ -20,6 +22,25 @@ namespace SettingsModelUnitTests TEST_METHOD(ClampDeterminateValues); TEST_METHOD(SuppressDuplicateState); TEST_METHOD(StandardProgressPrecedesShellLifecycle); + TEST_METHOD(ProviderProgressPrecedesShellLifecycle); + TEST_METHOD(StandardProgressPrecedesProviderAndFallsBack); + TEST_METHOD(ProviderStatePackingContainsOnlyStructuralFields); + TEST_METHOD(RecognitionClassifiesProvidersAndGenericFallback); + TEST_METHOD(RecognitionHandlesFragmentationAndMalformedInput); + TEST_METHOD(RecognitionSuppressesOnlySafeWholeChunks); + TEST_METHOD(RecognitionBoundsStateAndCoalescesUpdates); + TEST_METHOD(RecognitionKeepsOverlayOnlyProvidersVisible); + TEST_METHOD(RecognitionPreservesTerminalTextAndTerminalStates); + TEST_METHOD(RecognitionTerminalFailuresClearProviderContext); + TEST_METHOD(RecognitionRecoversAndIsolatesEngines); + TEST_METHOD(RecognitionClassifiesAllProvidersOneCodeUnitAtATime); + TEST_METHOD(RecognitionRejectsMalformedNumericAndInterruptedOutput); + TEST_METHOD(RendererPlansRealValuesRegressionAndIndeterminateMode); + TEST_METHOD(RendererHiddenIngressAndOwnershipBoundaries); + TEST_METHOD(RendererPlansStatusAndAccessibilityFallbacks); + TEST_METHOD(RendererFailureAndCloseRemainPaneLocal); + TEST_METHOD(SparkPoolsEnforcePaneAndGlobalCaps); + TEST_METHOD(BackgroundAndHiddenPanesDoNotRequestSparkWork); TEST_METHOD(CommandCompletionClearsAtNextPrompt); TEST_METHOD(EmergencyOverridePrecedesSetting); TEST_METHOD(DisabledFeatureIgnoresEvents); @@ -96,6 +117,1185 @@ namespace SettingsModelUnitTests VERIFY_ARE_EQUAL(static_cast(ProgressStatus::Error), static_cast(fallback->status)); } + void WinTermVisualProgressTests::ProviderProgressPrecedesShellLifecycle() + { + ProgressStateMachine state; + state.SetEnabled(true); + state.ApplyShellLifecycle(ShellLifecycleState::CommandStart, -1); + + const ProviderProgress provider{ + ProgressProvider::Git, + ProgressMode::Determinate, + ProgressStatus::Running, + 42, + ProviderConfidence::High, + true, + true, + true, + 2, + 7, + }; + const auto snapshot = state.ApplyProvider(provider); + VERIFY_IS_TRUE(snapshot.has_value()); + VERIFY_ARE_EQUAL(static_cast(ProgressSource::Provider), static_cast(snapshot->source)); + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::Git), static_cast(snapshot->provider)); + VERIFY_ARE_EQUAL(uint8_t{ 42 }, snapshot->value); + + VERIFY_IS_FALSE(state.ApplyShellLifecycle(ShellLifecycleState::CommandExecuted, -1).has_value()); + const auto fallback = state.ResetProvider(); + VERIFY_IS_TRUE(fallback.has_value()); + VERIFY_ARE_EQUAL(static_cast(ProgressSource::ShellIntegration), static_cast(fallback->source)); + } + + void WinTermVisualProgressTests::StandardProgressPrecedesProviderAndFallsBack() + { + ProgressStateMachine state; + state.SetEnabled(true); + state.ApplyShellLifecycle(ShellLifecycleState::CommandStart, -1); + state.ApplyProvider({ + ProgressProvider::Pip, + ProgressMode::Determinate, + ProgressStatus::Running, + 25, + ProviderConfidence::High, + true, + true, + true, + 1, + 1, + }); + + const auto explicitProgress = state.ApplyTaskbar(1, 75); + VERIFY_IS_TRUE(explicitProgress.has_value()); + VERIFY_ARE_EQUAL(static_cast(ProgressSource::Taskbar), static_cast(explicitProgress->source)); + + VERIFY_IS_FALSE(state.ApplyProvider({ + ProgressProvider::Pip, + ProgressMode::Determinate, + ProgressStatus::Running, + 50, + ProviderConfidence::High, + true, + true, + true, + 1, + 2, + }) + .has_value()); + + const auto fallback = state.ApplyTaskbar(0, 0); + VERIFY_IS_TRUE(fallback.has_value()); + VERIFY_ARE_EQUAL(static_cast(ProgressSource::Provider), static_cast(fallback->source)); + VERIFY_ARE_EQUAL(uint8_t{ 50 }, fallback->value); + + const auto prompt = state.ApplyShellLifecycle(ShellLifecycleState::Prompt, -1); + VERIFY_IS_TRUE(prompt.has_value()); + VERIFY_IS_FALSE(prompt->visible); + } + + void WinTermVisualProgressTests::ProviderStatePackingContainsOnlyStructuralFields() + { + const ProviderProgress expected{ + ProgressProvider::Gradle, + ProgressMode::Indeterminate, + ProgressStatus::Waiting, + 99, + ProviderConfidence::High, + true, + true, + false, + 4095, + 123456, + }; + const auto actual = UnpackProviderProgress(PackProviderProgress(expected)); + VERIFY_ARE_EQUAL(static_cast(expected.provider), static_cast(actual.provider)); + VERIFY_ARE_EQUAL(static_cast(expected.mode), static_cast(actual.mode)); + VERIFY_ARE_EQUAL(static_cast(expected.status), static_cast(actual.status)); + VERIFY_ARE_EQUAL(expected.value, actual.value); + VERIFY_ARE_EQUAL(static_cast(expected.confidence), static_cast(actual.confidence)); + VERIFY_ARE_EQUAL(expected.visible, actual.visible); + VERIFY_ARE_EQUAL(expected.transient, actual.transient); + VERIFY_ARE_EQUAL(expected.suppressible, actual.suppressible); + VERIFY_ARE_EQUAL(expected.stage, actual.stage); + VERIFY_ARE_EQUAL(expected.sequence, actual.sequence); + } + + void WinTermVisualProgressTests::RecognitionClassifiesProvidersAndGenericFallback() + { + const auto expectProvider = [](const std::wstring_view line, + const ProgressProvider provider, + const uint8_t value) { + RecognitionEngine engine; + const auto result = engine.Consume(line, 0); + VERIFY_IS_TRUE(result.progress.has_value()); + if (!result.progress) + { + return; + } + VERIFY_ARE_EQUAL(static_cast(provider), static_cast(result.progress->provider)); + VERIFY_ARE_EQUAL(static_cast(ProgressMode::Determinate), static_cast(result.progress->mode)); + VERIFY_ARE_EQUAL(value, result.progress->value); + VERIFY_IS_FALSE(result.suppressInput); + }; + + expectProvider(L"demo-layer: Downloading 512B/1.0kB\n", ProgressProvider::DockerPull, 50); + expectProvider(L"#7 [3/8] RUN build 50%\n", ProgressProvider::DockerBuildKit, 50); + expectProvider(L"Downloading demo.whl 50% 512kB/1.0MB 1.0MB/s eta 00:01\n", ProgressProvider::Pip, 50); + expectProvider(L"Receiving objects: 50% (50/100)\n", ProgressProvider::Git, 50); + expectProvider(L"wget demo 50%[====> ] 512K 1.0MB/s eta 1s\n", ProgressProvider::Wget, 50); + expectProvider(L"npm fetch 50% (5/10)\n", ProgressProvider::Npm, 50); + expectProvider(L"pnpm download 50% (5/10)\n", ProgressProvider::Pnpm, 50); + expectProvider(L"yarn fetch 50% (5/10)\n", ProgressProvider::Yarn, 50); + expectProvider(L"nvm downloading node.js 50%\n", ProgressProvider::Nvm, 50); + expectProvider(L"[INFO] Progress (1): 512/1024 kB\n", ProgressProvider::Maven, 50); + expectProvider(L"75% EXECUTING\n", ProgressProvider::Gradle, 75); + expectProvider(L"42% (42/100) 1.0 MB/s ETA 00:05\n", ProgressProvider::Generic, 42); + + RecognitionEngine curl; + curl.Consume(L"% Total % Received\r\n", 0); + const auto curlResult = curl.Consume(L"50 1024 50 512 0\r\x1b[2K", 50); + VERIFY_IS_TRUE(curlResult.progress.has_value()); + if (curlResult.progress) + { + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::Curl), static_cast(curlResult.progress->provider)); + VERIFY_ARE_EQUAL(uint8_t{ 50 }, curlResult.progress->value); + } + + RecognitionEngine unknownTransfer; + const auto generic = unknownTransfer.Consume(L"42% (42/100) 1.0 MB/s ETA 00:05\n", 0); + VERIFY_IS_TRUE(generic.progress.has_value()); + if (generic.progress) + { + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::Generic), static_cast(generic.progress->provider)); + VERIFY_IS_FALSE(generic.progress->suppressible); + } + + RecognitionEngine etaOnly; + VERIFY_IS_FALSE(etaOnly.Consume(L"working ETA soon\n", 0).progress.has_value()); + + RecognitionEngine prompt; + const auto promptResult = prompt.Consume(L"continue? [y/n] 50% (1/2)\r\x1b[2K", 0); + VERIFY_IS_FALSE(promptResult.progress.has_value()); + VERIFY_IS_FALSE(promptResult.suppressInput); + } + + void WinTermVisualProgressTests::RecognitionHandlesFragmentationAndMalformedInput() + { + RecognitionEngine splitCrLf; + const auto carriageReturn = splitCrLf.Consume(L"Receiving objects: 50% (50/100)\r", 0); + VERIFY_IS_FALSE(carriageReturn.progress.has_value()); + VERIFY_IS_FALSE(carriageReturn.suppressInput); + const auto lineFeed = splitCrLf.Consume(L"\n", 50); + VERIFY_IS_TRUE(lineFeed.progress.has_value()); + if (lineFeed.progress) + { + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::Git), static_cast(lineFeed.progress->provider)); + VERIFY_IS_FALSE(lineFeed.progress->transient); + } + VERIFY_IS_FALSE(lineFeed.suppressInput); + + RecognitionEngine splitCsi; + const auto csiPrefix = splitCsi.Consume(L"Receiving objects: 50% (50/100)\r\x1b[", 0); + VERIFY_IS_TRUE(csiPrefix.progress.has_value()); + VERIFY_IS_FALSE(csiPrefix.suppressInput); + const auto csiSuffix = splitCsi.Consume(L"2K", 50); + VERIFY_IS_TRUE(csiSuffix.healthy); + VERIFY_IS_FALSE(csiSuffix.suppressInput); + + RecognitionEngine splitSurrogate; + std::wstring surrogatePrefix{ L"42% (42/100) ETA 00:01 " }; + surrogatePrefix.push_back(static_cast(0xd83d)); + const auto high = splitSurrogate.Consume(surrogatePrefix, 0); + VERIFY_IS_TRUE(high.healthy); + VERIFY_IS_FALSE(high.progress.has_value()); + std::wstring surrogateSuffix; + surrogateSuffix.push_back(static_cast(0xde00)); + surrogateSuffix.append(L"\r\x1b[2K"); + const auto low = splitSurrogate.Consume(surrogateSuffix, 50); + VERIFY_IS_TRUE(low.healthy); + VERIFY_IS_TRUE(low.progress.has_value()); + if (low.progress) + { + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::Generic), static_cast(low.progress->provider)); + } + VERIFY_IS_FALSE(low.suppressInput); + + RecognitionEngine malformedUtf16; + std::wstring malformedLine; + malformedLine.push_back(static_cast(0xdc00)); + malformedLine.append(L"42% (42/100) ETA 00:01\r\x1b[2K"); + const auto malformedResult = malformedUtf16.Consume(malformedLine, 0); + VERIFY_IS_FALSE(malformedResult.healthy); + VERIFY_IS_FALSE(malformedResult.progress.has_value()); + VERIFY_IS_FALSE(malformedResult.suppressInput); + + RecognitionEngine utf16Engine; + Utf8RecognitionAdapter utf8{ utf16Engine }; + const std::string bytes = "42% (42/100) 1.0 MB/s ETA 00:05 \xf0\x9f\x98\x80\r\x1b[2K"; + std::optional latest; + for (size_t i = 0; i < bytes.size(); ++i) + { + const auto result = utf8.Consume(std::string_view{ bytes.data() + i, 1 }, i * 50); + VERIFY_IS_TRUE(result.accepted); + VERIFY_IS_TRUE(result.healthy); + VERIFY_IS_FALSE(result.suppressInput); + if (result.progress) + { + latest = result.progress; + } + } + VERIFY_IS_TRUE(latest.has_value()); + if (latest) + { + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::Generic), static_cast(latest->provider)); + } + VERIFY_IS_TRUE(utf8.Finish().healthy); + + RecognitionEngine malformedUtf8Engine; + Utf8RecognitionAdapter malformedUtf8{ malformedUtf8Engine }; + const auto invalid = malformedUtf8.Consume(std::string_view{ "\xc0\x80", 2 }, 0); + VERIFY_IS_FALSE(invalid.accepted); + VERIFY_IS_FALSE(invalid.healthy); + VERIFY_IS_FALSE(invalid.suppressInput); + VERIFY_IS_FALSE(malformedUtf8.Consume("42% (42/100) ETA\n", 50).accepted); + malformedUtf8.Reset(); + VERIFY_IS_TRUE(malformedUtf8.Consume("42% (42/100) ETA\n", 100).progress.has_value()); + + RecognitionEngine truncatedUtf8Engine; + Utf8RecognitionAdapter truncatedUtf8{ truncatedUtf8Engine }; + VERIFY_IS_TRUE(truncatedUtf8.Consume(std::string_view{ "\xe2", 1 }, 0).healthy); + const auto truncated = truncatedUtf8.Finish(); + VERIFY_IS_FALSE(truncated.accepted); + VERIFY_IS_FALSE(truncated.healthy); + VERIFY_IS_FALSE(truncated.suppressInput); + } + + void WinTermVisualProgressTests::RecognitionSuppressesOnlySafeWholeChunks() + { + RecognitionOptions replacement; + replacement.replacementEnabled = true; + replacement.rendererEnabled = true; + + const auto primeCursor = [](RecognitionEngine& engine) { + const auto result = engine.Consume(L"\r", 0); + VERIFY_IS_FALSE(result.suppressInput); + }; + + RecognitionEngine git; + primeCursor(git); + const auto gitResult = git.Consume(L"Receiving objects: 50% (50/100)\r\x1b[2K", 50, replacement); + VERIFY_IS_TRUE(gitResult.progress.has_value()); + VERIFY_IS_TRUE(gitResult.suppressInput); + + RecognitionEngine pip; + primeCursor(pip); + const auto pipResult = pip.Consume(L"Downloading demo.whl 50% 512kB/1.0MB 1.0MB/s eta 00:01\r\x1b[2K", 50, replacement); + VERIFY_IS_TRUE(pipResult.progress.has_value()); + VERIFY_IS_TRUE(pipResult.suppressInput); + + RecognitionEngine unrelatedDownload; + primeCursor(unrelatedDownload); + const auto backupResult = unrelatedDownload.Consume(L"Downloading backup 50% 1MB/2MB 1MB/s eta 1s\r\x1b[2K", 50, replacement); + VERIFY_IS_TRUE(backupResult.progress.has_value()); + if (backupResult.progress) + { + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::Generic), static_cast(backupResult.progress->provider)); + VERIFY_IS_FALSE(backupResult.progress->suppressible); + } + VERIFY_IS_FALSE(backupResult.suppressInput); + + RecognitionEngine wget; + wget.Consume(L"Saving to: 'demo.bin'\n", 0); + primeCursor(wget); + const auto wgetResult = wget.Consume(L"demo.bin 50%[====> ] 512K 1.0MB/s eta 1s\r\x1b[2K", 50, replacement); + VERIFY_IS_TRUE(wgetResult.progress.has_value()); + VERIFY_IS_TRUE(wgetResult.suppressInput); + + RecognitionEngine unrelatedBracketMeter; + primeCursor(unrelatedBracketMeter); + const auto bracketResult = unrelatedBracketMeter.Consume(L"Backup 50%[====> ] 1MB/s eta 1s\r\x1b[2K", 50, replacement); + VERIFY_IS_FALSE(bracketResult.progress.has_value()); + VERIFY_IS_FALSE(bracketResult.suppressInput); + + RecognitionEngine curl; + curl.Consume(L"% Total % Received\r\n", 0); + const auto curlResult = curl.Consume(L"50 1024 50 512 0\r\x1b[2K", 50, replacement); + VERIFY_IS_TRUE(curlResult.progress.has_value()); + VERIFY_IS_TRUE(curlResult.suppressInput); + + RecognitionEngine generic; + primeCursor(generic); + const auto genericResult = generic.Consume(L"42% (42/100) 1.0 MB/s ETA 00:05\r\x1b[2K", 50, replacement); + VERIFY_IS_TRUE(genericResult.progress.has_value()); + VERIFY_IS_FALSE(genericResult.suppressInput); + + const auto expectGitPreserved = [&](const RecognitionOptions options) { + RecognitionEngine engine; + primeCursor(engine); + const auto result = engine.Consume(L"Receiving objects: 50% (50/100)\r\x1b[2K", 50, options); + VERIFY_IS_TRUE(result.progress.has_value()); + VERIFY_IS_FALSE(result.suppressInput); + }; + + auto gated = replacement; + gated.replacementEnabled = false; + expectGitPreserved(gated); + gated = replacement; + gated.rendererEnabled = false; + expectGitPreserved(gated); + gated = replacement; + gated.normalScreen = false; + expectGitPreserved(gated); + gated = replacement; + gated.parserHealthy = false; + expectGitPreserved(gated); + + RecognitionEngine resetOrigin; + primeCursor(resetOrigin); + resetOrigin.Reset(); + VERIFY_IS_FALSE(resetOrigin.Consume(L"Receiving objects: 50% (50/100)\r\x1b[2K", 50, replacement).suppressInput); + primeCursor(resetOrigin); + VERIFY_IS_TRUE(resetOrigin.Consume(L"Receiving objects: 60% (60/100)\r\x1b[2K", 100, replacement).suppressInput); + + RecognitionEngine newline; + primeCursor(newline); + VERIFY_IS_FALSE(newline.Consume(L"Receiving objects: 50% (50/100)\n", 50, replacement).suppressInput); + + RecognitionEngine ambiguousCarriageReturn; + primeCursor(ambiguousCarriageReturn); + VERIFY_IS_FALSE(ambiguousCarriageReturn.Consume(L"Receiving objects: 50% (50/100)\r", 50, replacement).suppressInput); + + RecognitionEngine fragmented; + primeCursor(fragmented); + VERIFY_IS_FALSE(fragmented.Consume(L"Receiving objects: 50% ", 50, replacement).suppressInput); + VERIFY_IS_FALSE(fragmented.Consume(L"(50/100)\r\x1b[2K", 100, replacement).suppressInput); + + RecognitionEngine styled; + primeCursor(styled); + const auto sgr = styled.Consume(L"\x1b[31mReceiving objects: 50% (50/100)\r\x1b[2K", 50, replacement); + VERIFY_IS_TRUE(sgr.progress.has_value()); + VERIFY_IS_FALSE(sgr.suppressInput); + + RecognitionEngine unsupportedCsi; + primeCursor(unsupportedCsi); + const auto cursorControl = unsupportedCsi.Consume(L"\x1b[?25hReceiving objects: 50% (50/100)\r\x1b[2K", 50, replacement); + VERIFY_IS_TRUE(cursorControl.progress.has_value()); + VERIFY_IS_FALSE(cursorControl.suppressInput); + + RecognitionEngine warning; + primeCursor(warning); + const auto warningResult = warning.Consume(L"Downloading demo.whl 50% 512kB/1.0MB 1.0MB/s warning\r\x1b[2K", 50, replacement); + VERIFY_IS_TRUE(warningResult.progress.has_value()); + VERIFY_IS_FALSE(warningResult.suppressInput); + + RecognitionEngine prompt; + primeCursor(prompt); + const auto promptResult = prompt.Consume(L"username 50% (1/2)\r\x1b[2K", 50, replacement); + VERIFY_IS_FALSE(promptResult.progress.has_value()); + VERIFY_IS_FALSE(promptResult.suppressInput); + } + + void WinTermVisualProgressTests::RecognitionBoundsStateAndCoalescesUpdates() + { + RecognitionEngine chunkBound; + std::wstring oversizedChunk(RecognitionEngine::MaxChunkCodeUnits + 1, L'a'); + const auto chunkOverflow = chunkBound.Consume(oversizedChunk, 0); + VERIFY_IS_TRUE(chunkOverflow.overflow); + VERIFY_IS_FALSE(chunkOverflow.healthy); + VERIFY_IS_FALSE(chunkOverflow.suppressInput); + + RecognitionEngine recordBound; + std::wstring oversizedRecord(RecognitionEngine::MaxCurrentLineCodeUnits + 1, L'a'); + oversizedRecord.push_back(L'\n'); + const auto recordOverflow = recordBound.Consume(oversizedRecord, 0); + VERIFY_IS_TRUE(recordOverflow.overflow); + VERIFY_IS_FALSE(recordOverflow.healthy); + VERIFY_IS_FALSE(recordOverflow.progress.has_value()); + const auto recordRecovered = recordBound.Consume(L"42% (42/100) ETA 00:01\n", 50); + VERIFY_IS_TRUE(recordRecovered.progress.has_value()); + + RecognitionEngine ansiBound; + std::wstring oversizedCsi{ L"\x1b[" }; + oversizedCsi.append(RecognitionEngine::MaxAnsiSequenceCodeUnits + 1, L'1'); + oversizedCsi.push_back(L'K'); + const auto ansiOverflow = ansiBound.Consume(oversizedCsi, 0); + VERIFY_IS_TRUE(ansiOverflow.overflow); + VERIFY_IS_FALSE(ansiOverflow.healthy); + VERIFY_IS_FALSE(ansiOverflow.suppressInput); + + RecognitionEngine dockerBound; + std::wstring dockerLines; + for (size_t i = 0; i <= RecognitionEngine::DockerLayerCapacity; ++i) + { + dockerLines.append(L"layer"); + dockerLines.append(std::to_wstring(i)); + dockerLines.append(L": Downloading 1B/2B\n"); + } + const auto dockerOverflow = dockerBound.Consume(dockerLines, 0); + VERIFY_IS_TRUE(dockerOverflow.overflow); + VERIFY_IS_FALSE(dockerOverflow.healthy); + VERIFY_IS_FALSE(dockerOverflow.suppressInput); + + RecognitionEngine buildKitBound; + std::wstring buildKitLines; + for (size_t i = 1; i <= RecognitionEngine::BuildKitStepCapacity + 1; ++i) + { + buildKitLines.push_back(L'#'); + buildKitLines.append(std::to_wstring(i)); + buildKitLines.append(L" RUN 50%\n"); + } + const auto buildKitOverflow = buildKitBound.Consume(buildKitLines, 0); + VERIFY_IS_TRUE(buildKitOverflow.overflow); + VERIFY_IS_FALSE(buildKitOverflow.healthy); + + RecognitionEngine coalesced; + const auto first = coalesced.Consume(L"Receiving objects: 10% (10/100)\n", 0); + VERIFY_IS_TRUE(first.progress.has_value()); + VERIFY_IS_FALSE(coalesced.Consume(L"Receiving objects: 20% (20/100)\n", 10).progress.has_value()); + VERIFY_IS_FALSE(coalesced.Consume(L"Receiving objects: 30% (30/100)\n", 49).progress.has_value()); + const auto due = coalesced.Consume(L"Receiving objects: 40% (40/100)\n", 50); + VERIFY_IS_TRUE(due.progress.has_value()); + if (due.progress) + { + VERIFY_ARE_EQUAL(uint8_t{ 40 }, due.progress->value); + } + + RecognitionEngine newestOnly; + const auto newest = newestOnly.Consume( + L"Receiving objects: 10% (10/100)\rReceiving objects: 20% (20/100)\r\n", + 0); + VERIFY_IS_TRUE(newest.progress.has_value()); + if (newest.progress) + { + VERIFY_ARE_EQUAL(uint8_t{ 20 }, newest.progress->value); + } + + RecognitionEngine stress; + bool stressHealthy{ true }; + for (size_t i = 0; i < 10000; ++i) + { + const auto value = i % 101; + std::wstring line{ L"Receiving objects: " }; + line.append(std::to_wstring(value)); + line.append(L"% ("); + line.append(std::to_wstring(value)); + line.append(L"/100)\n"); + const auto result = stress.Consume(line, i); + stressHealthy = stressHealthy && result.accepted && result.healthy && !result.overflow; + } + VERIFY_IS_TRUE(stressHealthy); + + RecognitionEngine utf8Engine; + Utf8RecognitionAdapter utf8{ utf8Engine }; + std::string oversizedUtf8(Utf8RecognitionAdapter::MaxChunkBytes + 1, 'a'); + const auto utf8Overflow = utf8.Consume(oversizedUtf8, 0); + VERIFY_IS_TRUE(utf8Overflow.overflow); + VERIFY_IS_FALSE(utf8Overflow.accepted); + VERIFY_IS_FALSE(utf8Overflow.healthy); + VERIFY_IS_FALSE(utf8Overflow.suppressInput); + } + + void WinTermVisualProgressTests::RecognitionKeepsOverlayOnlyProvidersVisible() + { + RecognitionOptions replacement; + replacement.replacementEnabled = true; + replacement.rendererEnabled = true; + + const auto expectOverlayOnly = [&](const std::wstring_view line, const ProgressProvider provider) { + RecognitionEngine engine; + engine.Consume(L"\r", 0); + const auto result = engine.Consume(line, 50, replacement); + VERIFY_IS_TRUE(result.progress.has_value()); + if (!result.progress) + { + return; + } + VERIFY_ARE_EQUAL(static_cast(provider), static_cast(result.progress->provider)); + VERIFY_IS_FALSE(result.progress->suppressible); + VERIFY_IS_FALSE(result.suppressInput); + }; + + expectOverlayOnly(L"demo-layer: Downloading 512B/1.0kB\r\x1b[2K", ProgressProvider::DockerPull); + expectOverlayOnly(L"#7 [3/8] RUN build 50%\r\x1b[2K", ProgressProvider::DockerBuildKit); + expectOverlayOnly(L"npm fetch 50% (5/10)\r\x1b[2K", ProgressProvider::Npm); + expectOverlayOnly(L"pnpm download 50% (5/10)\r\x1b[2K", ProgressProvider::Pnpm); + expectOverlayOnly(L"yarn fetch 50% (5/10)\r\x1b[2K", ProgressProvider::Yarn); + expectOverlayOnly(L"nvm downloading node.js 50%\r\x1b[2K", ProgressProvider::Nvm); + expectOverlayOnly(L"[INFO] Progress (1): 512/1024 kB\r\x1b[2K", ProgressProvider::Maven); + expectOverlayOnly(L"75% EXECUTING\r\x1b[2K", ProgressProvider::Gradle); + } + + void WinTermVisualProgressTests::RecognitionPreservesTerminalTextAndTerminalStates() + { + RecognitionOptions replacement; + replacement.replacementEnabled = true; + replacement.rendererEnabled = true; + + const auto expectTerminal = [&](const std::wstring_view line, + const ProgressProvider provider, + const ProgressStatus status) { + RecognitionEngine engine; + engine.Consume(L"\r", 0); + const auto result = engine.Consume(line, 50, replacement); + VERIFY_IS_TRUE(result.progress.has_value()); + if (!result.progress) + { + return; + } + VERIFY_ARE_EQUAL(static_cast(provider), static_cast(result.progress->provider)); + VERIFY_ARE_EQUAL(static_cast(status), static_cast(result.progress->status)); + VERIFY_IS_FALSE(result.suppressInput); + }; + + expectTerminal(L"Error response from daemon: pull access denied\r\x1b[2K", ProgressProvider::DockerPull, ProgressStatus::Error); + expectTerminal(L"Downloaded newer image for demo:latest\r\x1b[2K", ProgressProvider::DockerPull, ProgressStatus::Success); + expectTerminal(L"fatal: authentication failed\r\x1b[2K", ProgressProvider::Git, ProgressStatus::Error); + expectTerminal(L"npm ERR! failed to fetch package\r\x1b[2K", ProgressProvider::Npm, ProgressStatus::Error); + expectTerminal(L"npm completed\r\x1b[2K", ProgressProvider::Npm, ProgressStatus::Success); + expectTerminal(L"[INFO] BUILD SUCCESS\r\x1b[2K", ProgressProvider::Maven, ProgressStatus::Success); + expectTerminal(L"[INFO] BUILD FAILURE\r\x1b[2K", ProgressProvider::Maven, ProgressStatus::Error); + expectTerminal(L"BUILD SUCCESSFUL in 1s\r\x1b[2K", ProgressProvider::Gradle, ProgressStatus::Success); + expectTerminal(L"BUILD FAILED in 1s\r\x1b[2K", ProgressProvider::Gradle, ProgressStatus::Error); + expectTerminal(L"curl: (22) The requested URL returned error\r\x1b[2K", ProgressProvider::Curl, ProgressStatus::Error); + expectTerminal(L"wget: unable to resolve host address\r\x1b[2K", ProgressProvider::Wget, ProgressStatus::Error); + + RecognitionEngine gitAuthentication; + gitAuthentication.Consume(L"Receiving objects: 25% (25/100)\n", 0); + const auto authenticationFailure = gitAuthentication.Consume(L"authentication failed for repository\r\x1b[2K", 50, replacement); + VERIFY_IS_TRUE(authenticationFailure.progress.has_value()); + if (authenticationFailure.progress) + { + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::Git), static_cast(authenticationFailure.progress->provider)); + VERIFY_ARE_EQUAL(static_cast(ProgressStatus::Error), static_cast(authenticationFailure.progress->status)); + } + VERIFY_IS_FALSE(authenticationFailure.suppressInput); + + for (const auto line : { + L"npm WARN audit report 50% (1/2)\r\x1b[2K", + L"npm audit found 2 vulnerabilities 50% (1/2)\r\x1b[2K", + L"Authentication required: 50% (1/2)\r\x1b[2K", + L"Password: 50% (1/2)\r\x1b[2K", + L"Enter passphrase: 50% (1/2)\r\x1b[2K", + L"Confirm continue? [y/n] 50% (1/2)\r\x1b[2K", + L"Username: 50% (1/2)\r\x1b[2K" }) + { + RecognitionEngine preserved; + preserved.Consume(L"\r", 0); + const auto result = preserved.Consume(line, 50, replacement); + VERIFY_IS_FALSE(result.progress.has_value()); + VERIFY_IS_FALSE(result.suppressInput); + } + } + + void WinTermVisualProgressTests::RecognitionTerminalFailuresClearProviderContext() + { + uint64_t timestamp{}; + + RecognitionEngine docker; + for (size_t i = 0; i < RecognitionEngine::DockerLayerCapacity; ++i) + { + std::wstring line{ L"layer" }; + line.append(std::to_wstring(i)); + line.append(L": Downloading 1B/2B\n"); + const auto result = docker.Consume(line, timestamp); + timestamp += RecognitionEngine::PublicationIntervalMilliseconds; + VERIFY_IS_FALSE(result.overflow); + } + const auto dockerError = docker.Consume(L"Error response from daemon: denied\n", timestamp); + timestamp += RecognitionEngine::PublicationIntervalMilliseconds; + VERIFY_IS_TRUE(dockerError.progress.has_value()); + if (dockerError.progress) + { + VERIFY_ARE_EQUAL(static_cast(ProgressStatus::Error), static_cast(dockerError.progress->status)); + } + const auto freshLayer = docker.Consume(L"fresh: Downloading 1B/2B\n", timestamp); + VERIFY_IS_TRUE(freshLayer.healthy); + VERIFY_IS_FALSE(freshLayer.overflow); + + RecognitionEngine buildKit; + timestamp = 0; + for (size_t i = 1; i <= RecognitionEngine::BuildKitStepCapacity; ++i) + { + std::wstring line{ L"#" }; + line.append(std::to_wstring(i)); + line.append(L" RUN build 50%\n"); + const auto result = buildKit.Consume(line, timestamp); + timestamp += RecognitionEngine::PublicationIntervalMilliseconds; + VERIFY_IS_FALSE(result.overflow); + } + const auto buildKitError = buildKit.Consume(L"#1 ERROR: build failed\n", timestamp); + timestamp += RecognitionEngine::PublicationIntervalMilliseconds; + VERIFY_IS_TRUE(buildKitError.progress.has_value()); + if (buildKitError.progress) + { + VERIFY_ARE_EQUAL(static_cast(ProgressStatus::Error), static_cast(buildKitError.progress->status)); + } + const auto freshStep = buildKit.Consume(L"#100 RUN build 50%\n", timestamp); + VERIFY_IS_TRUE(freshStep.healthy); + VERIFY_IS_FALSE(freshStep.overflow); + + RecognitionEngine pip; + pip.Consume(L"Downloading demo.whl 50% 1MB/2MB 1MB/s eta 1s\n", 0); + const auto pipError = pip.Consume(L"error: subprocess-exited-with-error\n", 50); + VERIFY_IS_TRUE(pipError.progress.has_value()); + const auto unrelated = pip.Consume(L"Downloading backup 50% 1MB/2MB 1MB/s eta 1s\n", 100); + VERIFY_IS_TRUE(unrelated.progress.has_value()); + if (unrelated.progress) + { + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::Generic), static_cast(unrelated.progress->provider)); + VERIFY_IS_FALSE(unrelated.progress->suppressible); + } + } + + void WinTermVisualProgressTests::RecognitionRecoversAndIsolatesEngines() + { + RecognitionEngine malformed; + std::wstring malformedLine; + malformedLine.push_back(static_cast(0xdc00)); + malformedLine.append(L"Receiving objects: 50% (50/100)\n"); + VERIFY_IS_FALSE(malformed.Consume(malformedLine, 0).healthy); + VERIFY_IS_TRUE(malformed.TryReset()); + const auto afterTryReset = malformed.Consume(L"Receiving objects: 50% (50/100)\n", 50); + VERIFY_IS_TRUE(afterTryReset.healthy); + VERIFY_IS_TRUE(afterTryReset.progress.has_value()); + + malformed.Reset(); + const auto afterReset = malformed.Consume(L"Downloading demo.whl 50% 512kB/1.0MB 1.0MB/s eta 00:01\n", 100); + VERIFY_IS_TRUE(afterReset.healthy); + VERIFY_IS_TRUE(afterReset.progress.has_value()); + if (afterReset.progress) + { + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::Pip), static_cast(afterReset.progress->provider)); + } + + RecognitionEngine curl; + curl.Consume(L"% Total % Received\r\n", 0); + const auto curlSuccess = curl.Consume(L"100 1024 100 1024 0\n", 50); + VERIFY_IS_TRUE(curlSuccess.progress.has_value()); + if (curlSuccess.progress) + { + VERIFY_ARE_EQUAL(static_cast(ProgressStatus::Success), static_cast(curlSuccess.progress->status)); + } + const auto afterCurl = curl.Consume(L"42% (42/100) ETA 00:01\n", 100); + VERIFY_IS_TRUE(afterCurl.progress.has_value()); + if (afterCurl.progress) + { + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::Generic), static_cast(afterCurl.progress->provider)); + } + + RecognitionEngine firstPane; + RecognitionEngine secondPane; + const auto firstProgress = firstPane.Consume(L"Receiving objects: 25% (25/100)\n", 0); + const auto secondProgress = secondPane.Consume(L"Downloading demo.whl 75% 768kB/1.0MB 1.0MB/s eta 00:01\n", 0); + VERIFY_IS_TRUE(firstProgress.progress.has_value()); + VERIFY_IS_TRUE(secondProgress.progress.has_value()); + if (firstProgress.progress && secondProgress.progress) + { + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::Git), static_cast(firstProgress.progress->provider)); + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::Pip), static_cast(secondProgress.progress->provider)); + VERIFY_ARE_EQUAL(uint32_t{ 1 }, firstProgress.progress->sequence); + VERIFY_ARE_EQUAL(uint32_t{ 1 }, secondProgress.progress->sequence); + } + + firstPane.Reset(); + const auto secondContinues = secondPane.Consume(L"Downloading demo.whl 80% 819kB/1.0MB 1.0MB/s eta 00:01\n", 50); + VERIFY_IS_TRUE(secondContinues.progress.has_value()); + if (secondContinues.progress) + { + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::Pip), static_cast(secondContinues.progress->provider)); + VERIFY_ARE_EQUAL(uint8_t{ 80 }, secondContinues.progress->value); + } + } + + void WinTermVisualProgressTests::RecognitionClassifiesAllProvidersOneCodeUnitAtATime() + { + struct Fixture + { + std::wstring_view line; + ProgressProvider provider; + uint8_t value; + }; + + const std::array fixtures{ + Fixture{ L"demo-layer: Downloading 512B/1.0kB\n", ProgressProvider::DockerPull, 50 }, + Fixture{ L"#7 [3/8] RUN build 50%\n", ProgressProvider::DockerBuildKit, 50 }, + Fixture{ L"Downloading demo.whl 50% 512kB/1.0MB 1.0MB/s eta 00:01\n", ProgressProvider::Pip, 50 }, + Fixture{ L"Receiving objects: 50% (50/100)\n", ProgressProvider::Git, 50 }, + Fixture{ L"wget demo 50%[====> ] 512K 1.0MB/s eta 1s\n", ProgressProvider::Wget, 50 }, + Fixture{ L"npm fetch 50% (5/10)\n", ProgressProvider::Npm, 50 }, + Fixture{ L"pnpm download 50% (5/10)\n", ProgressProvider::Pnpm, 50 }, + Fixture{ L"yarn fetch 50% (5/10)\n", ProgressProvider::Yarn, 50 }, + Fixture{ L"nvm downloading node.js 50%\n", ProgressProvider::Nvm, 50 }, + Fixture{ L"[INFO] Progress (1): 512/1024 kB\n", ProgressProvider::Maven, 50 }, + Fixture{ L"75% EXECUTING\n", ProgressProvider::Gradle, 75 }, + Fixture{ L"42% (42/100) 1.0 MB/s ETA 00:05\n", ProgressProvider::Generic, 42 }, + }; + + const auto feedOneCodeUnitAtATime = [](RecognitionEngine& engine, + const std::wstring_view text, + uint64_t& timestamp) { + std::optional latest; + for (size_t i = 0; i < text.size(); ++i) + { + const auto result = engine.Consume(text.substr(i, 1), timestamp); + timestamp += RecognitionEngine::PublicationIntervalMilliseconds; + VERIFY_IS_TRUE(result.accepted); + VERIFY_IS_TRUE(result.healthy); + VERIFY_IS_FALSE(result.suppressInput); + if (result.progress) + { + latest = result.progress; + } + } + return latest; + }; + + for (const auto& fixture : fixtures) + { + RecognitionEngine engine; + uint64_t timestamp{}; + const auto progress = feedOneCodeUnitAtATime(engine, fixture.line, timestamp); + VERIFY_IS_TRUE(progress.has_value()); + if (progress) + { + VERIFY_ARE_EQUAL(static_cast(fixture.provider), static_cast(progress->provider)); + VERIFY_ARE_EQUAL(fixture.value, progress->value); + } + } + + RecognitionEngine curl; + uint64_t curlTimestamp{}; + const auto header = feedOneCodeUnitAtATime(curl, L"% Total % Received\r\n", curlTimestamp); + VERIFY_IS_TRUE(header.has_value()); + const auto meter = feedOneCodeUnitAtATime(curl, L"50 1024 50 512 0\n", curlTimestamp); + VERIFY_IS_TRUE(meter.has_value()); + if (meter) + { + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::Curl), static_cast(meter->provider)); + VERIFY_ARE_EQUAL(uint8_t{ 50 }, meter->value); + } + } + + void WinTermVisualProgressTests::RecognitionRejectsMalformedNumericAndInterruptedOutput() + { + for (const auto line : { + L"101% (101/100) ETA 00:01\n", + L"-1% ETA 00:01\n", + L"50.5% ETA 00:01\n", + L"1/0 ETA 00:01\n", + L"200/100 ETA 00:01\n", + L"184467440737095516160/2 ETA 00:01\n", + L"512XB/1MB ETA 00:01\n" }) + { + RecognitionEngine engine; + const auto result = engine.Consume(line, 0); + VERIFY_IS_FALSE(result.progress.has_value()); + VERIFY_IS_FALSE(result.suppressInput); + } + + RecognitionOptions replacement; + replacement.replacementEnabled = true; + replacement.rendererEnabled = true; + + RecognitionEngine malformedGit; + malformedGit.Consume(L"\r", 0); + const auto git = malformedGit.Consume(L"Receiving objects: 101% (101/100)\r\x1b[2K", 50, replacement); + VERIFY_IS_TRUE(git.progress.has_value()); + if (git.progress) + { + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::Git), static_cast(git.progress->provider)); + VERIFY_ARE_EQUAL(static_cast(ProgressMode::Indeterminate), static_cast(git.progress->mode)); + VERIFY_IS_FALSE(git.progress->suppressible); + } + VERIFY_IS_FALSE(git.suppressInput); + + RecognitionEngine malformedDocker; + const auto docker = malformedDocker.Consume(L"demo-layer: Downloading 512XB/1MB\n", 0); + VERIFY_IS_TRUE(docker.progress.has_value()); + if (docker.progress) + { + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::DockerPull), static_cast(docker.progress->provider)); + VERIFY_ARE_EQUAL(static_cast(ProgressMode::Indeterminate), static_cast(docker.progress->mode)); + } + VERIFY_IS_FALSE(docker.suppressInput); + + RecognitionEngine malformedCurl; + malformedCurl.Consume(L"% Total % Received\r\n", 0); + VERIFY_IS_FALSE(malformedCurl.Consume(L"50 1024\n", 50).progress.has_value()); + + RecognitionEngine interrupted; + VERIFY_IS_FALSE(interrupted.Consume(L"ordinary output 4", 0).progress.has_value()); + const auto ordinaryTail = interrupted.Consume(L"2% complete\n", 50); + VERIFY_IS_FALSE(ordinaryTail.progress.has_value()); + VERIFY_IS_FALSE(ordinaryTail.suppressInput); + + RecognitionEngine explanatoryText; + const auto explanation = explanatoryText.Consume(L"documentation: Receiving objects: 50% is an example\n", 0); + VERIFY_IS_FALSE(explanation.progress.has_value()); + VERIFY_IS_FALSE(explanation.suppressInput); + + RecognitionEngine interruptedNumber; + VERIFY_IS_FALSE(interruptedNumber.Consume(L"42", 0).progress.has_value()); + VERIFY_IS_FALSE(interruptedNumber.Consume(L" files copied\n", 50).progress.has_value()); + } + + void WinTermVisualProgressTests::RendererPlansRealValuesRegressionAndIndeterminateMode() + { + RenderEnvironment environment; + environment.hostLoaded = true; + environment.tabVisible = true; + environment.paneActive = true; + + VisualProgressRenderState renderer; + for (const auto value : { uint8_t{ 0 }, uint8_t{ 1 }, uint8_t{ 50 }, uint8_t{ 99 }, uint8_t{ 100 } }) + { + const auto plan = renderer.Apply( + { ProgressMode::Determinate, ProgressStatus::Running, value, true, ProgressSource::Taskbar, value }, + environment, + RenderTimestamp{ static_cast(value) * 1000 }); + VERIFY_ARE_EQUAL(static_cast(value) / 100.0f, plan.targetProgress); + VERIFY_ARE_EQUAL(value > 0, plan.headVisible); + } + + const auto regression = renderer.Apply( + { ProgressMode::Determinate, ProgressStatus::Running, 1, true, ProgressSource::Provider, 200 }, + environment, + RenderTimestamp{ 101000 }); + VERIFY_ARE_EQUAL(static_cast(RenderTransitionKind::PhaseRegression), static_cast(regression.kind)); + VERIFY_IS_TRUE(regression.phaseReset); + VERIFY_ARE_EQUAL(0.01f, regression.targetProgress); + + const auto indeterminate = renderer.Apply( + { ProgressMode::Indeterminate, ProgressStatus::Running, 0, true, ProgressSource::Provider, 201 }, + environment, + RenderTimestamp{ 102000 }); + VERIFY_ARE_EQUAL(static_cast(RenderTransitionKind::Indeterminate), static_cast(indeterminate.kind)); + VERIFY_IS_TRUE(indeterminate.indeterminateMoving); + VERIFY_IS_TRUE(indeterminate.headVisible); + } + + void WinTermVisualProgressTests::RendererHiddenIngressAndOwnershipBoundaries() + { + RenderEnvironment hiddenEnvironment; + hiddenEnvironment.hostLoaded = false; + hiddenEnvironment.tabVisible = false; + hiddenEnvironment.paneActive = false; + + VisualProgressRenderState hiddenIngress; + const auto unloaded = hiddenIngress.Apply( + { ProgressMode::Determinate, ProgressStatus::Running, 73, true, ProgressSource::Provider, 1 }, + hiddenEnvironment, + RenderTimestamp{ 0 }); + VERIFY_IS_FALSE(unloaded.visible); + VERIFY_IS_FALSE(hiddenIngress.Presenting()); + VERIFY_ARE_EQUAL(0.73f, unloaded.targetProgress); + + hiddenEnvironment.hostLoaded = true; + const auto loadedInHiddenTab = hiddenIngress.RefreshEnvironment(hiddenEnvironment, RenderTimestamp{ 500 }); + VERIFY_IS_FALSE(loadedInHiddenTab.visible); + VERIFY_IS_FALSE(loadedInHiddenTab.rainbowMoving); + VERIFY_IS_FALSE(RequiresSparkWork(loadedInHiddenTab, 0)); + + hiddenEnvironment.tabVisible = true; + const auto visibleInactive = hiddenIngress.RefreshEnvironment(hiddenEnvironment, RenderTimestamp{ 750 }); + VERIFY_IS_TRUE(visibleInactive.visible); + VERIFY_IS_FALSE(visibleInactive.rainbowMoving); + VERIFY_IS_FALSE(visibleInactive.sparksEligible); + VERIFY_ARE_EQUAL(0.73f, visibleInactive.targetProgress); + + hiddenEnvironment.paneActive = true; + const auto visibleActive = hiddenIngress.RefreshEnvironment(hiddenEnvironment, RenderTimestamp{ 1000 }); + VERIFY_IS_TRUE(visibleActive.visible); + VERIFY_IS_TRUE(visibleActive.rainbowMoving); + VERIFY_IS_TRUE(visibleActive.sparksEligible); + VERIFY_IS_TRUE(RequiresSparkWork(visibleActive, 0)); + VERIFY_ARE_EQUAL(0.73f, hiddenIngress.CurrentProgress(RenderTimestamp{ 1000 })); + + hiddenEnvironment.paneActive = false; + const auto visibleBackground = hiddenIngress.RefreshEnvironment(hiddenEnvironment, RenderTimestamp{ 1500 }); + VERIFY_IS_TRUE(visibleBackground.visible); + VERIFY_IS_FALSE(visibleBackground.rainbowMoving); + VERIFY_IS_FALSE(visibleBackground.sparksEligible); + VERIFY_IS_FALSE(RequiresSparkWork(visibleBackground, 0)); + + hiddenEnvironment.paneActive = true; + + const auto explicitlyHidden = hiddenIngress.Apply( + { ProgressMode::Hidden, ProgressStatus::Cancelled, 0, false, ProgressSource::None, 2 }, + hiddenEnvironment, + RenderTimestamp{ 2000 }); + VERIFY_IS_FALSE(explicitlyHidden.visible); + VERIFY_ARE_EQUAL(0.0f, explicitlyHidden.targetProgress); + VERIFY_ARE_EQUAL(0.0f, hiddenIngress.CurrentProgress(RenderTimestamp{ 2000 })); + VERIFY_IS_TRUE(explicitlyHidden.releaseAfterTransition); + VERIFY_IS_FALSE(RequiresSparkWork(explicitlyHidden, 0)); + + for (const auto status : { ProgressStatus::Error, ProgressStatus::Waiting }) + { + VisualProgressRenderState ownershipBoundary; + ownershipBoundary.Apply( + { ProgressMode::Determinate, ProgressStatus::Running, 60, true, ProgressSource::Provider, 1 }, + hiddenEnvironment, + RenderTimestamp{ 0 }); + ownershipBoundary.Apply( + { ProgressMode::Hidden, ProgressStatus::Cancelled, 0, false, ProgressSource::None, 2 }, + hiddenEnvironment, + RenderTimestamp{ 1000 }); + + const auto zeroStatus = ownershipBoundary.Apply( + { ProgressMode::Determinate, status, 0, true, ProgressSource::Provider, 3 }, + hiddenEnvironment, + RenderTimestamp{ 2000 }); + VERIFY_ARE_EQUAL(0.0f, zeroStatus.targetProgress); + + const auto nextCommand = ownershipBoundary.Apply( + { ProgressMode::Determinate, ProgressStatus::Running, 20, true, ProgressSource::Provider, 4 }, + hiddenEnvironment, + RenderTimestamp{ 3000 }); + VERIFY_ARE_NOT_EQUAL( + static_cast(RenderTransitionKind::PhaseRegression), + static_cast(nextCommand.kind)); + VERIFY_IS_FALSE(nextCommand.phaseReset); + VERIFY_ARE_EQUAL(0.2f, nextCommand.targetProgress); + } + } + + void WinTermVisualProgressTests::RendererPlansStatusAndAccessibilityFallbacks() + { + RenderEnvironment environment; + environment.hostLoaded = true; + environment.tabVisible = true; + environment.paneActive = true; + + VisualProgressRenderState renderer; + renderer.Apply( + { ProgressMode::Determinate, ProgressStatus::Running, 65, true, ProgressSource::Taskbar, 1 }, + environment, + RenderTimestamp{ 0 }); + + const auto waiting = renderer.Apply( + { ProgressMode::Determinate, ProgressStatus::Waiting, 0, true, ProgressSource::Taskbar, 2 }, + environment, + RenderTimestamp{ 1000 }); + VERIFY_ARE_EQUAL(static_cast(RenderTransitionKind::Waiting), static_cast(waiting.kind)); + VERIFY_ARE_EQUAL(0.65f, waiting.targetProgress); + VERIFY_IS_TRUE(waiting.breathe); + VERIFY_IS_FALSE(waiting.sparksEligible); + + const auto success = renderer.Apply( + { ProgressMode::Determinate, ProgressStatus::Success, 100, true, ProgressSource::Taskbar, 3 }, + environment, + RenderTimestamp{ 2000 }); + VERIFY_IS_TRUE(success.successSweep); + VERIFY_IS_TRUE(success.finalSparkBurst); + VERIFY_IS_TRUE(success.fadeOut); + VERIFY_IS_TRUE(success.releaseAfterTransition); + VERIFY_IS_TRUE(RequiresSparkWork(success, 0)); + + environment.paneActive = false; + const auto successInactiveRefresh = renderer.RefreshEnvironment(environment, RenderTimestamp{ 2250 }); + VERIFY_ARE_EQUAL(static_cast(RenderTransitionKind::EnvironmentRefresh), static_cast(successInactiveRefresh.kind)); + VERIFY_IS_FALSE(successInactiveRefresh.successSweep); + VERIFY_IS_FALSE(successInactiveRefresh.finalSparkBurst); + VERIFY_IS_FALSE(successInactiveRefresh.fadeOut); + VERIFY_IS_FALSE(RequiresSparkWork(successInactiveRefresh, 0)); + + environment.paneActive = true; + const auto successActiveRefresh = renderer.RefreshEnvironment(environment, RenderTimestamp{ 2500 }); + VERIFY_IS_FALSE(successActiveRefresh.successSweep); + VERIFY_IS_FALSE(successActiveRefresh.finalSparkBurst); + VERIFY_IS_FALSE(successActiveRefresh.fadeOut); + VERIFY_IS_FALSE(RequiresSparkWork(successActiveRefresh, 0)); + + const auto error = renderer.Apply( + { ProgressMode::Determinate, ProgressStatus::Error, 65, true, ProgressSource::Taskbar, 4 }, + environment, + RenderTimestamp{ 3000 }); + VERIFY_IS_TRUE(error.errorPulse); + VERIFY_IS_FALSE(error.sparksEligible); + VERIFY_IS_FALSE(error.fadeOut); + VERIFY_IS_FALSE(RequiresSparkWork(error, 0)); + + environment.paneActive = false; + const auto errorInactiveRefresh = renderer.RefreshEnvironment(environment, RenderTimestamp{ 3250 }); + VERIFY_IS_FALSE(errorInactiveRefresh.errorPulse); + VERIFY_IS_FALSE(RequiresSparkWork(errorInactiveRefresh, 0)); + + environment.paneActive = true; + const auto errorActiveRefresh = renderer.RefreshEnvironment(environment, RenderTimestamp{ 3500 }); + VERIFY_IS_FALSE(errorActiveRefresh.errorPulse); + VERIFY_IS_FALSE(RequiresSparkWork(errorActiveRefresh, 0)); + + environment.animationsEnabled = false; + const auto reducedMotion = renderer.RefreshEnvironment(environment, RenderTimestamp{ 4000 }); + VERIFY_IS_TRUE(reducedMotion.staticFallback); + VERIFY_IS_FALSE(reducedMotion.rainbowMoving); + VERIFY_IS_FALSE(reducedMotion.sparksEligible); + + environment.animationsEnabled = true; + environment.highContrast = true; + const auto highContrast = renderer.RefreshEnvironment(environment, RenderTimestamp{ 5000 }); + VERIFY_IS_TRUE(highContrast.staticFallback); + VERIFY_IS_FALSE(highContrast.rainbowMoving); + VERIFY_IS_FALSE(highContrast.sparksEligible); + + const auto cancelled = renderer.Apply( + { ProgressMode::Hidden, ProgressStatus::Cancelled, 0, false, ProgressSource::None, 5 }, + environment, + RenderTimestamp{ 6000 }); + VERIFY_IS_TRUE(cancelled.releaseAfterTransition); + VERIFY_IS_FALSE(cancelled.finalSparkBurst); + VERIFY_IS_FALSE(cancelled.sparksEligible); + VERIFY_IS_FALSE(RequiresSparkWork(cancelled, 0)); + } + + void WinTermVisualProgressTests::RendererFailureAndCloseRemainPaneLocal() + { + RenderEnvironment environment; + environment.hostLoaded = true; + environment.tabVisible = true; + environment.paneActive = true; + + VisualProgressRenderState first; + VisualProgressRenderState second; + first.Apply( + { ProgressMode::Determinate, ProgressStatus::Running, 25, true, ProgressSource::Taskbar, 1 }, + environment, + RenderTimestamp{ 0 }); + second.Apply( + { ProgressMode::Determinate, ProgressStatus::Running, 75, true, ProgressSource::Taskbar, 1 }, + environment, + RenderTimestamp{ 0 }); + + while (first.Tier() != RenderTier::Disabled) + { + first.Degrade(RenderTimestamp{ 1 }); + } + VERIFY_ARE_EQUAL(static_cast(RenderTier::Disabled), static_cast(first.Tier())); + VERIFY_ARE_EQUAL(static_cast(RenderTier::Full), static_cast(second.Tier())); + VERIFY_ARE_EQUAL(0.75f, second.CurrentProgress(RenderTimestamp{ 1000 })); + + second.Close(); + VERIFY_IS_TRUE(second.Closed()); + VERIFY_IS_FALSE(second.Presenting()); + const auto afterClose = second.Apply( + { ProgressMode::Determinate, ProgressStatus::Running, 100, true, ProgressSource::Taskbar, 2 }, + environment, + RenderTimestamp{ 2000 }); + VERIFY_ARE_EQUAL(static_cast(RenderTier::Disabled), static_cast(afterClose.tier)); + } + + void WinTermVisualProgressTests::SparkPoolsEnforcePaneAndGlobalCaps() + { + SparkBudget budget; + SparkPool first{ budget }; + SparkPool second{ budget }; + SparkPool third{ budget }; + SparkPool fourth{ budget }; + std::array firstHandles{}; + + for (uint8_t index = 0; index < RainbowArcVisualConstants::SparkPoolCapacityPerPane; ++index) + { + const auto firstHandle = first.Acquire(RenderTimestamp{ 0 }, std::chrono::milliseconds{ 120 }); + VERIFY_IS_TRUE(firstHandle.has_value()); + firstHandles[index] = *firstHandle; + VERIFY_IS_TRUE(second.Acquire(RenderTimestamp{ 0 }, std::chrono::milliseconds{ 180 }).has_value()); + VERIFY_IS_TRUE(third.Acquire(RenderTimestamp{ 0 }, std::chrono::milliseconds{ 260 }).has_value()); + } + VERIFY_ARE_EQUAL(uint8_t{ 8 }, first.Live()); + VERIFY_ARE_EQUAL(uint8_t{ 24 }, budget.Live()); + VERIFY_IS_FALSE(fourth.Acquire(RenderTimestamp{ 0 }, std::chrono::milliseconds{ 120 }).has_value()); + + const auto releasedHandle = firstHandles[0]; + VERIFY_IS_TRUE(first.Release(releasedHandle)); + const auto reused = fourth.Acquire(RenderTimestamp{ 1 }, std::chrono::milliseconds{ 120 }); + VERIFY_IS_TRUE(reused.has_value()); + VERIFY_ARE_EQUAL(uint8_t{ 24 }, budget.Live()); + + first.ReleaseAll(); + second.ReleaseAll(); + third.ReleaseAll(); + fourth.ReleaseAll(); + VERIFY_ARE_EQUAL(uint8_t{ 0 }, budget.Live()); + + SparkBudget reuseBudget{ 1 }; + SparkPool reusePool{ reuseBudget }; + const auto original = reusePool.Acquire(RenderTimestamp{ 0 }, std::chrono::milliseconds{ 120 }); + VERIFY_IS_TRUE(original.has_value()); + VERIFY_IS_TRUE(reusePool.IsActive(*original)); + VERIFY_IS_TRUE(reusePool.Release(*original)); + + const auto reacquired = reusePool.Acquire(RenderTimestamp{ 1 }, std::chrono::milliseconds{ 120 }); + VERIFY_IS_TRUE(reacquired.has_value()); + VERIFY_ARE_EQUAL(original->slot, reacquired->slot); + VERIFY_ARE_NOT_EQUAL(original->generation, reacquired->generation); + VERIFY_IS_FALSE(reusePool.IsActive(*original)); + VERIFY_IS_FALSE(reusePool.Release(*original)); + VERIFY_IS_TRUE(reusePool.IsActive(*reacquired)); + VERIFY_IS_TRUE(reusePool.Release(*reacquired)); + VERIFY_ARE_EQUAL(uint8_t{ 0 }, reuseBudget.Live()); + + SparkBudget expiryBudget{ 3 }; + SparkPool expiryPool{ expiryBudget }; + const auto shortLived = expiryPool.Acquire(RenderTimestamp{ 0 }, std::chrono::milliseconds{ 120 }); + const auto persistent = expiryPool.Acquire(RenderTimestamp{ 0 }, std::chrono::milliseconds{ 120 }, true); + const auto longLived = expiryPool.Acquire(RenderTimestamp{ 100 }, std::chrono::milliseconds{ 260 }); + VERIFY_IS_TRUE(shortLived.has_value()); + VERIFY_IS_TRUE(persistent.has_value()); + VERIFY_IS_TRUE(longLived.has_value()); + VERIFY_ARE_EQUAL(uint8_t{ 0 }, expiryPool.ReleaseExpired(RenderTimestamp{ 119 })); + VERIFY_ARE_EQUAL(uint8_t{ 1 }, expiryPool.ReleaseExpired(RenderTimestamp{ 120 })); + VERIFY_IS_FALSE(expiryPool.IsActive(*shortLived)); + VERIFY_IS_TRUE(expiryPool.IsActive(*persistent)); + VERIFY_IS_TRUE(expiryPool.IsActive(*longLived)); + VERIFY_ARE_EQUAL(uint8_t{ 1 }, expiryPool.ReleaseExpired(RenderTimestamp::max())); + VERIFY_IS_TRUE(expiryPool.IsActive(*persistent)); + VERIFY_ARE_EQUAL(uint8_t{ 1 }, expiryPool.Live()); + VERIFY_ARE_EQUAL(uint8_t{ 1 }, expiryBudget.Live()); + VERIFY_IS_TRUE(expiryPool.Release(*persistent)); + VERIFY_ARE_EQUAL(uint8_t{ 0 }, expiryBudget.Live()); + + SparkBudget scopedBudget{ 4 }; + { + SparkPool scopedPool{ scopedBudget }; + VERIFY_IS_TRUE(scopedPool.Acquire(RenderTimestamp{ 0 }, std::chrono::milliseconds{ 120 }).has_value()); + VERIFY_IS_TRUE(scopedPool.Acquire(RenderTimestamp{ 0 }, std::chrono::milliseconds{ 180 }, true).has_value()); + VERIFY_ARE_EQUAL(uint8_t{ 2 }, scopedBudget.Live()); + } + VERIFY_ARE_EQUAL(uint8_t{ 0 }, scopedBudget.Live()); + } + + void WinTermVisualProgressTests::BackgroundAndHiddenPanesDoNotRequestSparkWork() + { + RenderEnvironment environment; + environment.hostLoaded = true; + environment.tabVisible = true; + environment.paneActive = false; + + VisualProgressRenderState renderer; + const auto background = renderer.Apply( + { ProgressMode::Determinate, ProgressStatus::Running, 50, true, ProgressSource::Taskbar, 1 }, + environment, + RenderTimestamp{ 0 }); + VERIFY_IS_FALSE(background.sparksEligible); + VERIFY_IS_FALSE(RequiresSparkWork(background, 0)); + + environment.paneActive = true; + environment.windowVisible = false; + const auto hidden = renderer.RefreshEnvironment(environment, RenderTimestamp{ 1 }); + VERIFY_IS_FALSE(hidden.visible); + VERIFY_IS_FALSE(hidden.sparksEligible); + VERIFY_IS_FALSE(RequiresSparkWork(hidden, 0)); + VERIFY_IS_TRUE(RequiresSparkWork(hidden, 1)); + + environment.windowVisible = true; + environment.tabVisible = false; + const auto backgroundTab = renderer.RefreshEnvironment(environment, RenderTimestamp{ 2 }); + VERIFY_IS_FALSE(backgroundTab.visible); + VERIFY_IS_FALSE(backgroundTab.sparksEligible); + VERIFY_IS_FALSE(RequiresSparkWork(backgroundTab, 0)); + } + void WinTermVisualProgressTests::CommandCompletionClearsAtNextPrompt() { ProgressStateMachine state; @@ -124,6 +1324,19 @@ namespace SettingsModelUnitTests ProgressStateMachine state; VERIFY_IS_FALSE(state.ApplyTaskbar(1, 50).has_value()); VERIFY_IS_FALSE(state.ApplyShellLifecycle(ShellLifecycleState::CommandStart, -1).has_value()); + VERIFY_IS_FALSE(state.ApplyProvider({ + ProgressProvider::Generic, + ProgressMode::Determinate, + ProgressStatus::Running, + 50, + ProviderConfidence::Medium, + true, + true, + false, + 0, + 1, + }) + .has_value()); VERIFY_IS_FALSE(state.Current().visible); } @@ -217,13 +1430,18 @@ namespace SettingsModelUnitTests { Json::Value enabledJson{ Json::objectValue }; enabledJson["visualProgress.enabled"] = true; + enabledJson["visualProgress.replaceRecognizedOutput"] = true; const auto enabled = winrt::Microsoft::Terminal::Settings::Model::implementation::GlobalAppSettings::FromJson(enabledJson); VERIFY_IS_TRUE(enabled->VisualProgressEnabled()); + VERIFY_IS_TRUE(enabled->VisualProgressReplaceRecognizedOutput()); VERIFY_IS_TRUE(enabled->ToJson()["visualProgress.enabled"].asBool()); + VERIFY_IS_TRUE(enabled->ToJson()["visualProgress.replaceRecognizedOutput"].asBool()); Json::Value legacyJson{ Json::objectValue }; const auto migrated = winrt::Microsoft::Terminal::Settings::Model::implementation::GlobalAppSettings::FromJson(legacyJson); VERIFY_IS_FALSE(migrated->VisualProgressEnabled()); + VERIFY_IS_FALSE(migrated->VisualProgressReplaceRecognizedOutput()); VERIFY_IS_FALSE(migrated->ToJson().isMember("visualProgress.enabled")); + VERIFY_IS_FALSE(migrated->ToJson().isMember("visualProgress.replaceRecognizedOutput")); } } From a807f9f4bf35417d4c9858a04cbd9f6676cb0630 Mon Sep 17 00:00:00 2001 From: HelloThisWorld Date: Sat, 1 Aug 2026 07:13:28 +0800 Subject: [PATCH 5/5] docs: document Visual Progress Phase 2 --- docs/development/visual-progress-phase1.md | 2 + docs/development/visual-progress-phase2.md | 193 +++++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 docs/development/visual-progress-phase2.md diff --git a/docs/development/visual-progress-phase1.md b/docs/development/visual-progress-phase1.md index d5f3854b8..cf903b256 100644 --- a/docs/development/visual-progress-phase1.md +++ b/docs/development/visual-progress-phase1.md @@ -2,6 +2,8 @@ Phase 1 provides the static, fail-open foundation for Visual Progress. It is a developer preview, not the finished v1.2.0 rainbow effect. +Phase 2 builds directly on this foundation; see [Visual Progress Phase 2](visual-progress-phase2.md) for the Rainbow Arc Weld renderer, bounded CLI recognition, and safe transient replacement preview. + ## Architecture The implementation reuses the standard terminal progress path: diff --git a/docs/development/visual-progress-phase2.md b/docs/development/visual-progress-phase2.md new file mode 100644 index 000000000..3f815b873 --- /dev/null +++ b/docs/development/visual-progress-phase2.md @@ -0,0 +1,193 @@ +# Visual Progress Phase 2 + +Phase 2 extends the [Phase 1 progress foundation](visual-progress-phase1.md) with the Rainbow Arc Weld renderer and bounded CLI progress recognition. This remains a developer preview: it is not a released or stable v1.2.0 feature, the application version remains 1.1.3, and this work does not create a tag or release. + +## Architecture + +Phase 2 extends the existing per-pane state machine instead of introducing a second progress system: + +```text +OSC 9;4 taskbar progress ---------------------------+ +bounded incremental CLI recognition -> provider ----+-> normalized pane state +OSC 133 command lifecycle --------------------------+ | + v + one-element UI mailbox + | + v + Rainbow Arc Weld renderer +``` + +The normalized state contains only presentation data: source, provider ID, mode, status, real value when known, confidence, transient eligibility, optional stage identity, and sequence. It does not retain command text, package names, paths, URLs, or complete output records, and it is not written to workspaces or snapshots. The one-element mailbox continues to coalesce UI work so newer accepted snapshots replace older pending snapshots and terminal output never waits for decorative rendering. + +Progress ownership follows this order: + +1. Explicit standard OSC 9;4 progress. +2. A high-confidence built-in CLI provider. +3. Generic OSC 133 shell running, success, or error lifecycle. +4. Hidden. + +Generic heuristic recognition cannot override explicit progress or an owned built-in provider. Clearing explicit progress reveals the current valid provider or shell fallback. A new semantic prompt resets provider ownership and bounded parser state for that pane. + +## Rainbow Arc Weld renderer + +The WinTerm-owned renderer is under `src/winterm/VisualProgress/`. Its renderer-independent state and constants are separated from the small Pane integration boundary so timing, status transitions, degradation, and resource budgets can be tested without XAML. + +The continuous rounded beam is layered over the existing pane content row. It does not consume a terminal row, alter viewport dimensions, participate in hit testing, or receive accessibility focus. Transparent drawing space accommodates localized bloom without changing layout. The visual uses Windows composition transforms, clipping, opacity, cached brushes, and compositor animations; it is not constructed from terminal characters or repeated cell glyphs. + +The composition layers, from back to front, are: + +1. **Track:** a six-DIP rounded foundation. +2. **Rainbow fill:** a continuous animated gradient clipped to the real filled width. +3. **Luminous trail:** a short highlight behind the boundary. +4. **Welding head:** a warm white center and 2.5-DIP white-hot core. +5. **Inner glow:** a localized 12-DIP colored glow. +6. **Outer bloom:** a localized 26-DIP soft bloom. +7. **Welding sparks:** a sparse pooled particle layer. + +The centralized geometry uses a 10-DIP horizontal inset, an 8-DIP bottom inset, a three-DIP corner radius, a five-DIP warm core, and an eight-DIP trail. Logical XAML sizing keeps the geometry stable at 100%, 125%, 150%, and 200% DPI and in narrow, maximized, split, and zoomed panes. + +The rainbow is a coherent red-to-orange-to-yellow-to-green-to-cyan-to-blue-to-violet-to-magenta gradient. Its cached brush moves on a 2,000-millisecond cycle and is never rebuilt per frame. Determinate updates normally interpolate for 220 milliseconds. A real regression uses an intentional 240-millisecond phase-reset transition; the renderer does not invent a monotonic value. Zero percent keeps the welding head inside the track, while the fill remains clipped and the bloom drawing space remains available at 100 percent. + +Indeterminate progress uses a welding-head comet with a continuous tail covering 25 percent of the track. It traverses the track in 1,800 milliseconds, fades cleanly at the right edge, and reappears at the left without showing a fabricated percentage. + +### Status presentations + +- **Running:** rainbow motion and the welding head are active. Only an eligible active pane emits sparse sparks. +- **Waiting:** forward movement pauses, the last meaningful value remains visible, sparks stop, and the glow breathes on a 1,600-millisecond cycle. +- **Success:** progress advances to 100 percent when appropriate, the head intensifies, a roughly 320-millisecond white highlight sweeps left to right, one controlled final burst is permitted, the beam takes on a success tint, and it fades over roughly 650 milliseconds. +- **Error:** sparks and rainbow movement stop, the beam changes to the error treatment, one roughly 220-millisecond intensity pulse runs, and the state remains until a new prompt or safe reset. It never flashes continuously. +- **Cancelled:** sparks stop, brightness falls, and the presentation fades in roughly 180 milliseconds before releasing its resources. + +### Sparks and resource limits + +Spark objects come from fixed pools. A normal burst emits one or two sparks; a stronger completion burst emits three to six. Typical particles are one to two DIPs, a rare bright particle is at most three DIPs, lifetimes are 120–260 milliseconds, and travel is 6–18 DIPs. Their color fades from white through warm yellow and orange to transparent, with emission biased forward and slightly upward or downward rather than in a 360-degree explosion. + +The hard caps are **8 live sparks per active pane** and **24 live sparks globally**. The shared global budget is stricter than independent per-window allocation and prevents multiple windows from exceeding 24 live particles in total. Background panes do not emit sparks. Pooled particles are reused, and compositor animations perform movement and fading without per-frame heap allocation. + +There is no CPU-driven frame loop, permanent per-pane timer, spark scheduler, per-frame brush construction, per-frame blur construction, terminal-buffer repaint, or viewport relayout. Two preallocated ambient spark slots use sparse compositor-managed iteration only while a visible, focused, active pane is eligible; all continuous motion remains compositor-managed. + +## Lifecycle and degradation + +Pane load, unload, close, split, detach, zoom, restore, focus, activation, and visibility changes update renderer eligibility without changing terminal state. Hidden or minimized windows pause animation. Inactive or background panes retain an inexpensive presentation but do not emit sparks. An unfocused window suppresses sparks and may simplify motion. Completion, cancellation, pane destruction, settings disable, dispatcher shutdown, and device loss stop animation and release pooled resources. + +Rendering is decorative and degrades independently for each affected pane: + +1. Rainbow, localized glow, and sparks. +2. Rainbow and static glow, without sparks. +3. Static gradient. +4. Solid progress bar. +5. Overlay disabled for that pane. + +Composition or GPU failure never changes PTY, input, output, selection, copy and paste, or pane-management behavior. Normalized UI publication is coalesced to approximately 10–20 updates per second even if a CLI writes more frequently. + +### Reduced Motion and High Contrast + +When Windows disables animation, the renderer uses the Phase 1 static behavior. Determinate values update directly, indeterminate state remains nonnumeric, and there is no moving rainbow, comet, interpolation, breathing, success sweep, or spark emission. + +High Contrast uses a clear system-compatible solid track and fill. It does not depend on rainbow hue or glow to communicate progress. High Contrast also disables sparks and continuous decorative motion. These fallbacks take precedence over the full renderer tier. + +## Bounded CLI recognition + +The recognizer observes only newly arriving output. It never scans scrollback, reprocesses complete command output, or stores an unbounded record. Providers are built in; there is no native plug-in loading or arbitrary third-party parser execution. + +Each terminal control owns one optional, isolated parser/provider state. Phase 2 performs the immediately available bounded decision synchronously before the terminal write; it creates no recognition worker, service, or queue. Incremental decoding accepts arbitrary chunk boundaries, including split ANSI and UTF-8 sequences. Current-line storage, recent provider history, active Docker layer/BuildKit step records, and the UI mailbox all have hard limits defined next to the implementation. Parsing uses bounded state machines and anchored token/numeric parsing with explicit ranges; it does not use regular expressions. + +When a chunk, line, ANSI sequence, provider record, or active set exceeds its bound, recognition abandons that record and preserves the original output. Ingress uses try-lock-and-drop behavior: contention preserves the complete callback, invalidates partial recognition state, and resets before the next inspection. No asynchronous decision or queued work is allowed to delay rendering while deciding whether a record is transient. + +Provider buffers clear on command completion, a new prompt, pane or tab close, connection restart, settings disable, emergency override, or provider error. Malformed ANSI, malformed UTF-8, oversized lines, and unexpected state transitions fail open. + +### Provider support + +| Provider | Recognized progress | Phase 2 output policy | +| --- | --- | --- | +| Docker Pull | Layer states; real byte totals where present; otherwise bounded stage or indeterminate progress | Overlay only; preserve cursor-addressed and daemon output | +| Docker BuildKit | Bounded active steps and reliable current/total step fractions | Overlay only; preserve all build logs and errors | +| pip | Rich-style or legacy transfers with real byte totals, speed/ETA signatures, or real percentages | High-confidence single-line transient transfer frames may be replaced | +| Git | Counting, compressing, receiving, resolving, and updating phases using Git's real percentage or current/total values | High-confidence carriage-return percentage frames may be replaced | +| curl and wget | Standard transfer meters using real percentage and byte totals; indeterminate when length is unknown | High-confidence single-line carriage-return transfer frames may be replaced | +| npm, pnpm, and yarn | Resolve, fetch, link, build, postinstall, completion, and failure stages; determinate only for explicit counts or percentages | Overlay only; preserve warnings, audit findings, scripts, errors, and summaries | +| nvm | Download, extract, install, switch, completion, and failure stages; delegated transfers reuse bounded transfer parsing | Overlay only | +| Maven | Artifact transfer values and dependency/build stages | Overlay only; never estimate overall build percentage | +| Gradle | Task state, dependency transfer, and actual emitted percentage or counts | Overlay only; never estimate overall build percentage | +| Generic fallback | Anchored percentages, current/total, transfer speed with ETA, and repeated transient/spinner-like lines | Overlay only and never suppressible | + +Maven and Gradle use determinate mode only for a real transfer or explicit execution value. Stage names alone select indeterminate mode. Git phase regression is preserved as a real phase reset rather than converted into a fabricated overall percentage. + +## Safe transient replacement preview + +The JSON-only preview setting is: + +```json +{ + "visualProgress.enabled": true, + "visualProgress.replaceRecognizedOutput": false +} +``` + +`visualProgress.replaceRecognizedOutput` defaults to `false`. While it is false, every provider is overlay-only and the original output is unchanged. + +`visualProgress.enabled` also retains its Phase 1 developer-preview default of `false`. Phase 2 adds no polished Settings page for either option. + +When it is true, only high-confidence, immediately recognized, single-line transient progress from **pip, Git, curl, or wget** is eligible for replacement. The known provider must own the record; the record must be a carriage-return, erase-line, spinner, or equivalent temporary frame; parser state must be healthy and within bounds; the pane must be in a compatible normal terminal mode; and the progress system and renderer must be enabled. Replacement is disallowed if removing a frame could disturb later cursor addressing. + +Docker Pull, BuildKit, npm/pnpm/yarn, nvm, Maven, Gradle, and generic recognition remain overlay-only in Phase 2. In particular, generic output is never suppressible, and Docker's multi-line terminal-control behavior is not removed. + +Ordinary newline-terminated logs, warnings, errors, stack traces, test or compilation failures, authentication/password/confirmation prompts, remote messages, package and build summaries, install summaries, Docker daemon errors, Maven `BUILD SUCCESS` or `BUILD FAILURE`, Gradle summaries, alternate-screen output, unknown output, and malformed or incomplete ANSI are always preserved. + +Late or contended recognition, low confidence, parser overflow, renderer unavailability or failure, settings disable, lifecycle-generation changes, and incompatible terminal modes all preserve the original bytes. Suppression is deliberately limited to an unambiguous record wholly contained in the current output callback; fragmented or trailing-carriage-return callbacks remain visible even if their completed record can update the overlay. Only ephemeral progress frames are candidates for removal from the visible buffer; the terminal never waits for recognition and ordinary output is never discarded to protect the effect. + +## Privacy and emergency override + +Recognition is local and in-memory. WinTerm does not upload or persist terminal content and adds no progress telemetry. Diagnostics may contain only structural categories such as provider ID, parser state, dropped-event count, overflow, provider disable, or renderer tier. They must not contain command lines, package or image names, paths, URLs, usernames, hostnames, environment variables, credentials, clipboard data, or terminal output. + +`WINTERM_DISABLE_VISUAL_PROGRESS=1` is authoritative. Set it before launching winTerm to prevent renderer, recognizer, compositor animation, and suppression initialization regardless of JSON settings. With the override active, all original output passes through unchanged. + +## Manual demo + +Enable `visualProgress.enabled`, restart winTerm, and run the synthetic demo in a winTerm pane: + +```powershell +.\scripts\winterm\invoke-visual-progress-smoke.ps1 +``` + +The script uses only PowerShell output and OSC sequences. It requires no Docker, Node, Python, Maven, Gradle, provider CLI, network access, or download. It exercises determinate values at 0, 1, 50, 99, and 100 percent; a real regression; indeterminate, waiting, success, error, cancellation, and clear; and sanitized carriage-return samples for every built-in provider and the generic fallback. + +For replacement preview testing, first run with `visualProgress.replaceRecognizedOutput` false and confirm every synthetic line remains visible. Then set it to true, restart, and confirm only eligible pip, Git, curl, and wget transient frames can disappear. All summaries and every overlay-only provider sample must remain visible. + +For pane eligibility, split the window, run the demo or bounded soak in both panes, and move focus between them. Confirm that only the active pane emits sparks, inactive panes retain a simplified presentation, and both panes keep independent values. Move to another tab and minimize or deactivate the window to confirm hidden and unfocused animation pauses or simplifies. + +For Reduced Motion, disable animation in Windows accessibility settings, restart winTerm, and rerun the demo. Values should update directly with no moving gradient, comet, breathing, sweep, or sparks. Enable a Windows contrast theme and rerun to verify the solid High Contrast treatment. Restore the system settings after testing. + +The optional soak is manually bounded to at most 10,000 iterations and is not part of ordinary CI: + +```powershell +.\scripts\winterm\invoke-visual-progress-smoke.ps1 -DelayMilliseconds 0 -SoakIterations 1000 +``` + +The soak repeatedly publishes real 0–100 determinate values and periodic provider frames, then clears progress. It writes no screenshots, recordings, binaries, or build artifacts. + +Where a corresponding CLI is already installed, optional real-world checks may be run in a disposable directory. Suitable command shapes include `docker pull `, `python -m pip download `, `git clone --progress `, `curl.exe -L --output `, `wget.exe --output-document= `, a package-manager install in a disposable project, `nvm install `, `mvn package`, and `gradlew build --console=rich`. These commands may download content and are not required for the base demo. + +## Validation + +Run source validation first: + +```powershell +.\scripts\winterm\test-visual-progress.ps1 -SourceOnly +``` + +After a Release build with tests, run the compiled coverage: + +```powershell +.\scripts\winterm\test-visual-progress.ps1 -Configuration Release -Platform x64 -RequireCompiled +``` + +The ordinary test suite uses deterministic or injected timing for animation-state tests. It does not depend on wall-clock sleeps, and the longer bounded soak remains opt-in. + +## Known limitations and Phase 3 hand-off + +Phase 2 has no polished Settings UI, labels, speed or ETA text, command names, notifications, history, persistence, or arbitrary external providers. Recognition intentionally rejects unsupported variants and preserves their output. Docker and BuildKit replacement remains disabled. npm/pnpm/yarn, nvm, Maven, and Gradle are overlay-only. Maven and Gradle do not expose a fabricated overall percentage. + +Visual appearance, DPI, theme transitions, device-loss degradation, multi-window budget behavior, and active/background sparks still require packaged-application manual QA. The default shared 24-spark budget is global; a later host may inject a narrower per-window service without weakening the global cap. + +Phase 3 owns the complete adaptive performance-governor tuning, polished settings, final defaults, accessibility polish, long soak validation, release documentation, version bump, installer and portable validation, and v1.2.0 release preparation. Until then, the current source version and public release version remain 1.1.3.