diff --git a/docs/development/visual-progress-phase2.md b/docs/development/visual-progress-phase2.md index 3f815b873..9f6cfe5c8 100644 --- a/docs/development/visual-progress-phase2.md +++ b/docs/development/visual-progress-phase2.md @@ -46,6 +46,8 @@ The composition layers, from back to front, are: 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. +`SizeChanged` recomputes the track and bloom drawing geometry. When the usable track width or vertical origin changes, the renderer stops the compositor animations that captured the old geometry and rebinds rainbow and comet movement through the current environment plan. The post-layout generation therefore uses the new endpoints without a CPU frame loop, stale absolute offsets, or a terminal viewport relayout. + 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. @@ -55,7 +57,7 @@ Indeterminate progress uses a welding-head comet with a continuous tail covering - **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. +- **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. If an error has no meaningful progress value, the renderer preserves the real zero fill but colors the track and parks a visible error head at the zero boundary for the one-shot pulse. Static and solid fallbacks color the track instead, so unknown progress remains visibly erroneous without inventing a percentage. - **Cancelled:** sparks stop, brightness falls, and the presentation fades in roughly 180 milliseconds before releasing its resources. ### Sparks and resource limits @@ -68,7 +70,9 @@ There is no CPU-driven frame loop, permanent per-pane timer, spark scheduler, pe ## 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. +Pane load, unload, close, split, detach, zoom, restore, focus, activation, and visibility changes update renderer eligibility without changing terminal state. In the XAML-Islands host, `CoreWindow` visibility and activation are not authoritative for the top-level Win32 window. `TerminalPage::WindowVisibilityChanged` and `TerminalPage::WindowActivated` instead publish the HWND-derived visible/focused pair through the root pane to every leaf renderer. New and restored tabs receive the current pair during tab-event registration, and newly split children inherit it immediately. A renderer starts unfocused and creates no continuous work or sparks until this authoritative state arrives. + +Hidden or minimized windows pause animation. Inactive or background panes retain an inexpensive presentation but do not emit sparks. An unfocused window pauses all continuous motion and suppresses sparks. 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: @@ -82,7 +86,7 @@ Composition or GPU failure never changes PTY, input, output, selection, copy and ### 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. +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. Solid and static status treatments use separate dark- and light-surface palettes selected from the XAML host theme, with system background luminance as a fallback when the theme is default. This keeps running, waiting, success, and error states legible on light backgrounds as well as dark ones. 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. @@ -102,17 +106,23 @@ Provider buffers clear on command completion, a new prompt, pane or tab close, c | --- | --- | --- | | 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 | +| pip | Modern Rich two-record downloads, legacy transfers, 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 | +| Maven | Tagged or untagged resolver transfers plus 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. +Modern Rich pip may emit a sized archive announcement followed by an otherwise untagged meter such as a shared-unit `current/total MB` value with transfer rate and ETA. A bounded wheel announcement can establish pip context directly; generic source-archive extensions require an existing pip claim from an explicit signature such as `Collecting`. The following meter supplies the real value. Archive names are inspected only as bounded signatures and are not retained. Summaries remain ordinary terminal output. + +Maven Resolver output is not always prefixed with `[INFO]`. Anchored `Downloading from ...`, `Downloaded from ...`, and `Progress (n): current/total unit` records can bootstrap the Maven provider without a prior tagged line. Repository URLs and artifact names are not retained, and every Maven record remains overlay-only. + +The Generic fallback recognizes anchored real percentages or counts, transfer speed plus ETA even when no total is available, transient single-character `|`, `/`, `-`, or `\` spinners, and repeated carriage-return records with the same structural shape. A repeated transient shape must be observed again before it produces indeterminate progress. Generic recognition is always overlay-only, never suppresses output, and cannot replace an active high-confidence built-in provider. When the next complete record no longer resembles progress, a structural hidden update clears the Generic overlay immediately without retaining or logging that record. + ## Safe transient replacement preview The JSON-only preview setting is: diff --git a/scripts/winterm/test-visual-progress.ps1 b/scripts/winterm/test-visual-progress.ps1 index 70bb79592..f275a61ca 100644 --- a/scripts/winterm/test-visual-progress.ps1 +++ b/scripts/winterm/test-visual-progress.ps1 @@ -152,6 +152,7 @@ try TestProject = 'src\cascadia\UnitTests_SettingsModel\SettingsModel.UnitTests.vcxproj' PaneCpp = 'src\cascadia\TerminalApp\Pane.cpp' PaneH = 'src\cascadia\TerminalApp\Pane.h' + TerminalPageCpp = 'src\cascadia\TerminalApp\TerminalPage.cpp' AppXaml = 'src\cascadia\TerminalApp\App.xaml' TerminalPaneContentCpp = 'src\cascadia\TerminalApp\TerminalPaneContent.cpp' TerminalPaneContentH = 'src\cascadia\TerminalApp\TerminalPaneContent.h' @@ -254,7 +255,11 @@ try 'MinimumSparkLifetime{ 120 }', 'MaximumSparkLifetime{ 260 }', 'SparkPoolCapacityPerPane{ 8 }', - 'SparkCapacityPerWindowOrProcess{ 24 }' + 'SparkCapacityPerWindowOrProcess{ 24 }', + 'LightRunningSolid', + 'LightWaitingSolid', + 'LightSuccessSolid', + 'LightErrorSolid' )) { Assert-Contains $constants $required 'Centralized Rainbow Arc visual constants' @@ -288,6 +293,7 @@ try 'successSweep', 'finalSparkBurst', 'errorPulse', + 'errorWithoutProgress', 'releaseAfterTransition', 'class VisualProgressRenderState', 'class SparkBudget', @@ -308,6 +314,7 @@ try '_initializeGradientStage', '_initializeHeadStage', '_initializeSparkStage', + 'SetHostWindowState', 'CreateRoundedRectangleGeometry', 'CreateInsetClip', '_rainbowFillVisual', @@ -323,6 +330,9 @@ try '_sparkPool.Release', 'CreateScopedBatch', '_applyWithDegradation', + '_restartGeometryAnimations', + '_showErrorWithoutProgress', + '_isLightTheme', 'UsesStaticFallback', '_stopAllAnimations', '_releaseAllSparks', @@ -336,6 +346,18 @@ try { throw 'The Rainbow Arc gradient brush must have one initializer call and one cached-brush factory definition.' } + foreach ($status in @('Running', 'Waiting', 'Success', 'Error')) + { + Assert-Matches $renderer "light\s*\?\s*RainbowArcVisualConstants::Light$($status)Solid\s*:\s*RainbowArcVisualConstants::$($status)Solid" "Light-surface $status fallback palette selection" + } + foreach ($forbidden in @( + 'CoreWindow::GetForCurrentThread', + '_coreWindow.Visible' + )) + { + Assert-NotContains $renderer $forbidden 'Authoritative XAML-Islands host-window lifecycle boundary' + } + Assert-NotMatches $renderer '_coreWindow\s*\.\s*(?:VisibilityChanged|Activated)\s*\(' 'Authoritative XAML-Islands host-window lifecycle boundary' $paneVisualStart = $source.PaneCpp.IndexOf('void Pane::_SetVisualProgressEnabled', [System.StringComparison]::Ordinal) $paneVisualEnd = $source.PaneCpp.IndexOf('void Pane::_UpdatePaneHeader', $paneVisualStart, [System.StringComparison]::Ordinal) @@ -390,6 +412,55 @@ try 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' + foreach ($required in @( + 'void SetVisualProgressHostWindowState(bool visible, bool focused) noexcept', + '_visualProgressHostWindowVisible', + '_visualProgressHostWindowFocused' + )) + { + Assert-Contains $source.PaneH $required 'Per-pane authoritative host-window state' + } + foreach ($required in @( + 'void Pane::SetVisualProgressHostWindowState', + '_firstChild->SetVisualProgressHostWindowState', + '_secondChild->SetVisualProgressHostWindowState', + '_visualProgressRenderer->SetHostWindowState' + )) + { + Assert-Contains $pane $required 'Pane-tree host-window lifecycle propagation' + } + + $splitStart = $pane.IndexOf('Pane::_Split(', [System.StringComparison]::Ordinal) + $splitEnd = $pane.IndexOf('Pane::_CreateMinSizeTree', $splitStart, [System.StringComparison]::Ordinal) + if ($splitStart -lt 0 -or $splitEnd -le $splitStart) + { + throw 'The Pane split boundary could not be isolated.' + } + $splitImplementation = $pane.Substring($splitStart, $splitEnd - $splitStart) + Assert-Contains $splitImplementation '_firstChild->SetVisualProgressHostWindowState' 'First split-child host-window state propagation' + Assert-Contains $splitImplementation '_secondChild->SetVisualProgressHostWindowState' 'Second split-child host-window state propagation' + + $terminalPage = $source.TerminalPageCpp + $registerStart = $terminalPage.IndexOf('void TerminalPage::_RegisterTabEvents', [System.StringComparison]::Ordinal) + $registerEnd = $terminalPage.IndexOf('void TerminalPage::_UnZoomIfNeeded', $registerStart, [System.StringComparison]::Ordinal) + $visibilityStart = $terminalPage.IndexOf('void TerminalPage::WindowVisibilityChanged', [System.StringComparison]::Ordinal) + $visibilityEnd = $terminalPage.IndexOf('void TerminalPage::_Find', $visibilityStart, [System.StringComparison]::Ordinal) + $activationStart = $terminalPage.IndexOf('void TerminalPage::WindowActivated', [System.StringComparison]::Ordinal) + $activationEnd = $terminalPage.IndexOf('safe_void_coroutine TerminalPage::_ControlCompletionsChangedHandler', $activationStart, [System.StringComparison]::Ordinal) + if ($registerStart -lt 0 -or $registerEnd -le $registerStart -or + $visibilityStart -lt 0 -or $visibilityEnd -le $visibilityStart -or + $activationStart -lt 0 -or $activationEnd -le $activationStart) + { + throw 'The TerminalPage host-window lifecycle boundaries could not be isolated.' + } + $registerImplementation = $terminalPage.Substring($registerStart, $registerEnd - $registerStart) + $visibilityImplementation = $terminalPage.Substring($visibilityStart, $visibilityEnd - $visibilityStart) + $activationImplementation = $terminalPage.Substring($activationStart, $activationEnd - $activationStart) + Assert-Contains $registerImplementation 'GetRootPane()->SetVisualProgressHostWindowState(_visible, _activated)' 'Initial tab host-window state propagation' + Assert-Contains $visibilityImplementation '_visible = showOrHide' 'Authoritative window visibility state' + Assert-Contains $visibilityImplementation 'GetRootPane()->SetVisualProgressHostWindowState(_visible, _activated)' 'Window visibility propagation' + Assert-Contains $activationImplementation '_activated = activated' 'Authoritative window activation state' + Assert-Contains $activationImplementation 'GetRootPane()->SetVisualProgressHostWindowState(_visible, _activated)' 'Window activation propagation' $recognition = $source.Recognition foreach ($required in @( @@ -426,7 +497,18 @@ try 'progress.provider != ProgressProvider::Generic', 'class Utf8RecognitionAdapter final', 'MaxChunkBytes', - 'bool TryReset() noexcept' + 'bool TryReset() noexcept', + '_findSharedUnitQuantityFraction', + '_hasTransferRateAndEta', + 'pipSizedDownload', + 'resolverTransfer', + 'resolverProgress', + '_isSpinner', + '_genericTransientShape', + '_resetGenericHeuristics', + '_hasActiveHighConfidenceBuiltInClaim', + '_providerClear', + 'pendingCandidate' )) { Assert-Contains $recognition $required 'Bounded fail-open recognition framework' @@ -520,6 +602,11 @@ try 'RecognitionHandlesFragmentationAndMalformedInput', 'RecognitionSuppressesOnlySafeWholeChunks', 'RecognitionBoundsStateAndCoalescesUpdates', + 'RecognitionAppliesSuppressionSafetyMatrix', + 'RecognitionPreservesHighConfidenceOwnershipAndClearsGeneric', + 'RecognitionBootstrapsRichPipAndMavenResolver', + 'RecognitionHandlesGenericIndeterminateShapes', + 'RecognitionHandlesArbitraryProviderSplitsAndReset', 'RendererPlansRealValuesRegressionAndIndeterminateMode', 'RendererPlansStatusAndAccessibilityFallbacks', 'RendererFailureAndCloseRemainPaneLocal', @@ -541,6 +628,8 @@ try 'rendererEnabled = false', 'normalScreen = false', 'parserHealthy = false', + 'std::array', + 'split < fixture.stream.size()', 'i < 10000', 'uint8_t{ 8 }', 'uint8_t{ 24 }', @@ -607,9 +696,19 @@ try 'Reduced Motion and High Contrast', 'Bounded CLI recognition', 'try-lock-and-drop behavior', + 'XAML-Islands host', + 'HWND-derived visible/focused pair', + 'rebinds rainbow and comet movement', + 'light-surface palettes', + 'no meaningful progress value', 'Docker Pull', 'Docker BuildKit', 'Generic fallback', + 'Modern Rich pip', + 'Maven Resolver output is not always prefixed', + 'transfer speed plus ETA', + 'cannot replace an active high-confidence built-in provider', + 'clears the Generic overlay immediately', 'visualProgress.replaceRecognizedOutput', 'defaults to `false`', 'pip, Git, curl, or wget', diff --git a/src/cascadia/TerminalApp/Pane.cpp b/src/cascadia/TerminalApp/Pane.cpp index 89a2d278c..d2e5658df 100644 --- a/src/cascadia/TerminalApp/Pane.cpp +++ b/src/cascadia/TerminalApp/Pane.cpp @@ -1334,6 +1334,24 @@ void Pane::SetActive() UpdateVisuals(); } +void Pane::SetVisualProgressHostWindowState(const bool visible, const bool focused) noexcept +{ + _visualProgressHostWindowVisible = visible; + _visualProgressHostWindowFocused = focused; + + if (!_IsLeaf()) + { + _firstChild->SetVisualProgressHostWindowState(visible, focused); + _secondChild->SetVisualProgressHostWindowState(visible, focused); + return; + } + + if (_visualProgressRenderer) + { + _visualProgressRenderer->SetHostWindowState(visible, focused); + } +} + void Pane::SetPaneHeadersVisible(const bool visible) { _paneHeadersVisible = visible; @@ -2305,6 +2323,9 @@ void Pane::_CreateVisualProgressOverlay() return; } + _visualProgressRenderer->SetHostWindowState( + _visualProgressHostWindowVisible, + _visualProgressHostWindowFocused); _visualProgressRenderer->SetPaneActive(_lastActive); _visualProgressRenderer->Apply(_visualProgressState.Current()); if (_visualProgressRenderer->Faulted()) @@ -3673,12 +3694,18 @@ std::pair, std::shared_ptr> Pane::_Split(SplitDirect } _secondChild = newPane; // If we want the new pane to be the first child, swap the children - if (splitType == SplitDirection::Up || splitType == SplitDirection::Left) - { - std::swap(_firstChild, _secondChild); - } - - _root.ColumnDefinitions().Clear(); + if (splitType == SplitDirection::Up || splitType == SplitDirection::Left) + { + std::swap(_firstChild, _secondChild); + } + _firstChild->SetVisualProgressHostWindowState( + _visualProgressHostWindowVisible, + _visualProgressHostWindowFocused); + _secondChild->SetVisualProgressHostWindowState( + _visualProgressHostWindowVisible, + _visualProgressHostWindowFocused); + + _root.ColumnDefinitions().Clear(); _root.RowDefinitions().Clear(); _CreateRowColDefinitions(); diff --git a/src/cascadia/TerminalApp/Pane.h b/src/cascadia/TerminalApp/Pane.h index 4af57e9b5..d0c30f869 100644 --- a/src/cascadia/TerminalApp/Pane.h +++ b/src/cascadia/TerminalApp/Pane.h @@ -120,6 +120,7 @@ class Pane : public std::enable_shared_from_this void ClearActive(); void SetActive(); void SetPaneHeadersVisible(bool visible); + void SetVisualProgressHostWindowState(bool visible, bool focused) noexcept; struct BuildStartupState { @@ -328,6 +329,8 @@ class Pane : public std::enable_shared_from_this std::atomic _visualProgressFaulted{ false }; std::atomic _visualProgressRendererReady{ false }; std::atomic _visualProgressUpdateQueued{ false }; + bool _visualProgressHostWindowVisible{ true }; + bool _visualProgressHostWindowFocused{ false }; winTerm::VisualProgress::ProgressStateMachine _visualProgressState; winTerm::VisualProgress::ProgressUpdateMailbox _visualProgressMailbox; std::shared_ptr _visualProgressRenderer; diff --git a/src/cascadia/TerminalApp/TerminalPage.cpp b/src/cascadia/TerminalApp/TerminalPage.cpp index 89b0fbb6c..4d29ad109 100644 --- a/src/cascadia/TerminalApp/TerminalPage.cpp +++ b/src/cascadia/TerminalApp/TerminalPage.cpp @@ -2094,6 +2094,11 @@ namespace winrt::TerminalApp::implementation // - hostingTab: The Tab that's hosting this TermControl instance void TerminalPage::_RegisterTabEvents(Tab& hostingTab) { + // New and restored pane trees must inherit the current HWND state; + // waiting for the next activation event would leave new renderers in + // their safe, non-animating default indefinitely. + hostingTab.GetRootPane()->SetVisualProgressHostWindowState(_visible, _activated); + auto weakTab{ hostingTab.get_weak() }; auto weakThis{ get_weak() }; // PropertyChanged is the generic mechanism by which the Tab @@ -4163,6 +4168,7 @@ namespace winrt::TerminalApp::implementation { if (auto tabImpl{ _GetTabImpl(tab) }) { + tabImpl->GetRootPane()->SetVisualProgressHostWindowState(_visible, _activated); // Manually enumerate the panes in each tab; this will let us recycle TerminalSettings // objects but only have to iterate one time. tabImpl->GetRootPane()->WalkTree([&](auto&& pane) { @@ -5387,6 +5393,14 @@ namespace winrt::TerminalApp::implementation _activated = activated; _updateThemeColors(); + for (const auto& tab : _tabs) + { + if (const auto tabImpl{ _GetTabImpl(tab) }) + { + tabImpl->GetRootPane()->SetVisualProgressHostWindowState(_visible, _activated); + } + } + _adjustProcessPriorityThrottled->Run(); if (const auto& tab{ _GetFocusedTabImpl() }) diff --git a/src/cascadia/UnitTests_SettingsModel/WinTermVisualProgressTests.cpp b/src/cascadia/UnitTests_SettingsModel/WinTermVisualProgressTests.cpp index 7c8f5867e..d3c8ca6de 100644 --- a/src/cascadia/UnitTests_SettingsModel/WinTermVisualProgressTests.cpp +++ b/src/cascadia/UnitTests_SettingsModel/WinTermVisualProgressTests.cpp @@ -35,6 +35,11 @@ namespace SettingsModelUnitTests TEST_METHOD(RecognitionRecoversAndIsolatesEngines); TEST_METHOD(RecognitionClassifiesAllProvidersOneCodeUnitAtATime); TEST_METHOD(RecognitionRejectsMalformedNumericAndInterruptedOutput); + TEST_METHOD(RecognitionPreservesHighConfidenceOwnershipAndClearsGeneric); + TEST_METHOD(RecognitionBootstrapsRichPipAndMavenResolver); + TEST_METHOD(RecognitionHandlesGenericIndeterminateShapes); + TEST_METHOD(RecognitionHandlesArbitraryProviderSplitsAndReset); + TEST_METHOD(RecognitionAppliesSuppressionSafetyMatrix); TEST_METHOD(RendererPlansRealValuesRegressionAndIndeterminateMode); TEST_METHOD(RendererHiddenIngressAndOwnershipBoundaries); TEST_METHOD(RendererPlansStatusAndAccessibilityFallbacks); @@ -395,7 +400,7 @@ namespace SettingsModelUnitTests RecognitionEngine unrelatedDownload; primeCursor(unrelatedDownload); - const auto backupResult = unrelatedDownload.Consume(L"Downloading backup 50% 1MB/2MB 1MB/s eta 1s\r\x1b[2K", 50, replacement); + const auto backupResult = unrelatedDownload.Consume(L"Downloading backup.zip 50% 1MB/2MB 1MB/s eta 1s\r\x1b[2K", 50, replacement); VERIFY_IS_TRUE(backupResult.progress.has_value()); if (backupResult.progress) { @@ -939,6 +944,441 @@ namespace SettingsModelUnitTests VERIFY_IS_FALSE(interruptedNumber.Consume(L" files copied\n", 50).progress.has_value()); } + void WinTermVisualProgressTests::RecognitionPreservesHighConfidenceOwnershipAndClearsGeneric() + { + RecognitionEngine owned; + const auto initial = owned.Consume(L"Receiving objects: 40% (40/100)\n", 0); + VERIFY_IS_TRUE(initial.progress.has_value()); + if (initial.progress) + { + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::Git), static_cast(initial.progress->provider)); + VERIFY_ARE_EQUAL(static_cast(ProviderConfidence::High), static_cast(initial.progress->confidence)); + } + + const auto genericLooking = owned.Consume(L"42% (42/100) 1.0 MB/s ETA 00:05\n", 50); + VERIFY_IS_FALSE(genericLooking.progress.has_value()); + VERIFY_IS_FALSE(genericLooking.suppressInput); + + const auto continued = owned.Consume(L"Receiving objects: 60% (60/100)\n", 100); + VERIFY_IS_TRUE(continued.progress.has_value()); + if (continued.progress) + { + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::Git), static_cast(continued.progress->provider)); + VERIFY_ARE_EQUAL(uint8_t{ 60 }, continued.progress->value); + } + + RecognitionEngine expiredOwnership; + const auto claimed = expiredOwnership.Consume(L"Receiving objects: 40% (40/100)\n", 0); + VERIFY_IS_TRUE(claimed.progress.has_value()); + const auto ordinary = expiredOwnership.Consume(L"ordinary command output\n", 50); + VERIFY_IS_FALSE(ordinary.progress.has_value()); + const auto laterGeneric = expiredOwnership.Consume(L"42% (42/100) 1.0 MB/s ETA 00:05\n", 100); + VERIFY_IS_TRUE(laterGeneric.progress.has_value()); + if (laterGeneric.progress) + { + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::Generic), static_cast(laterGeneric.progress->provider)); + VERIFY_IS_FALSE(laterGeneric.progress->suppressible); + } + VERIFY_IS_FALSE(laterGeneric.suppressInput); + + RecognitionOptions replacement; + replacement.replacementEnabled = true; + replacement.rendererEnabled = true; + + RecognitionEngine staleCurl; + VERIFY_IS_TRUE(staleCurl.Consume(L"% Total % Received\n", 0, replacement).progress.has_value()); + VERIFY_IS_FALSE(staleCurl.Consume(L"ordinary command output\n", 50, replacement).progress.has_value()); + const auto laterCurlShape = staleCurl.Consume(L"50 1024 50 512 0\r\x1b[2K", 100, replacement); + VERIFY_IS_FALSE(laterCurlShape.progress.has_value()); + VERIFY_IS_FALSE(laterCurlShape.suppressInput); + + RecognitionEngine staleWget; + VERIFY_IS_TRUE(staleWget.Consume(L"Saving to: 'demo.bin'\n", 0, replacement).progress.has_value()); + VERIFY_IS_FALSE(staleWget.Consume(L"ordinary command output\n", 50, replacement).progress.has_value()); + const auto laterWgetShape = staleWget.Consume( + L"demo.bin 50%[====> ] 512K 1.0MB/s eta 1s\r\x1b[2K", + 100, + replacement); + VERIFY_IS_FALSE(laterWgetShape.progress.has_value()); + VERIFY_IS_FALSE(laterWgetShape.suppressInput); + + RecognitionEngine generic; + const auto visible = generic.Consume(L"42% (42/100) 1.0 MB/s ETA 00:05\n", 0); + VERIFY_IS_TRUE(visible.progress.has_value()); + if (visible.progress) + { + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::Generic), static_cast(visible.progress->provider)); + VERIFY_IS_TRUE(visible.progress->visible); + } + + const auto cleared = generic.Consume(L"ordinary command output\n", 1); + VERIFY_IS_TRUE(cleared.progress.has_value()); + if (cleared.progress) + { + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::None), static_cast(cleared.progress->provider)); + VERIFY_ARE_EQUAL(static_cast(ProgressMode::Hidden), static_cast(cleared.progress->mode)); + VERIFY_IS_FALSE(cleared.progress->visible); + } + VERIFY_IS_FALSE(cleared.suppressInput); + } + + void WinTermVisualProgressTests::RecognitionBootstrapsRichPipAndMavenResolver() + { + RecognitionEngine pip; + const auto announcement = pip.Consume( + L"Downloading demo_package-1.0-py3-none-any.whl (2.0 MB)\n", + 0); + VERIFY_IS_TRUE(announcement.progress.has_value()); + if (announcement.progress) + { + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::Pip), static_cast(announcement.progress->provider)); + VERIFY_ARE_EQUAL(static_cast(ProgressMode::Indeterminate), static_cast(announcement.progress->mode)); + } + + const auto rich = pip.Consume( + L"\x2501\x2501\x2501\x2501 1.0/2.0 MB 4.0 MB/s eta 0:00:01\r\x1b[2K", + 50); + VERIFY_IS_TRUE(rich.progress.has_value()); + if (rich.progress) + { + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::Pip), static_cast(rich.progress->provider)); + VERIFY_ARE_EQUAL(static_cast(ProgressMode::Determinate), static_cast(rich.progress->mode)); + VERIFY_ARE_EQUAL(uint8_t{ 50 }, rich.progress->value); + VERIFY_ARE_EQUAL(static_cast(ProviderConfidence::High), static_cast(rich.progress->confidence)); + } + VERIFY_IS_FALSE(rich.suppressInput); + + const auto summary = pip.Consume(L"Successfully installed demo-package\n", 100); + VERIFY_IS_FALSE(summary.progress.has_value()); + VERIFY_IS_FALSE(summary.suppressInput); + + RecognitionOptions replacement; + replacement.replacementEnabled = true; + replacement.rendererEnabled = true; + + RecognitionEngine unrelatedArchive; + const auto unrelatedAnnouncement = unrelatedArchive.Consume(L"Downloading backup.zip (2.0 MB)\n", 0, replacement); + VERIFY_IS_FALSE(unrelatedAnnouncement.progress.has_value()); + const auto unrelatedMeter = unrelatedArchive.Consume( + L"\x2501\x2501\x2501\x2501 1.0/2.0 MB 4.0 MB/s eta 0:00:01\r\x1b[2K", + 50, + replacement); + VERIFY_IS_TRUE(unrelatedMeter.progress.has_value()); + if (unrelatedMeter.progress) + { + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::Generic), static_cast(unrelatedMeter.progress->provider)); + } + VERIFY_IS_FALSE(unrelatedMeter.suppressInput); + + RecognitionEngine archiveSubstring; + const auto substring = archiveSubstring.Consume( + L"Downloading backup.zipper 50% 1MB/2MB 1MB/s eta 1s\r\x1b[2K", + 0, + replacement); + VERIFY_IS_TRUE(substring.progress.has_value()); + if (substring.progress) + { + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::Generic), static_cast(substring.progress->provider)); + } + VERIFY_IS_FALSE(substring.suppressInput); + + RecognitionEngine trustedSourceArchive; + const auto collecting = trustedSourceArchive.Consume(L"Collecting demo-package\n", 0, replacement); + VERIFY_IS_TRUE(collecting.progress.has_value()); + const auto sourceAnnouncement = trustedSourceArchive.Consume( + L"Downloading demo-package.tar.gz (2.0 MB)\n", + 50, + replacement); + // This announcement is structurally identical to the preceding + // indeterminate pip update, so publication may be coalesced. + if (sourceAnnouncement.progress) + { + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::Pip), static_cast(sourceAnnouncement.progress->provider)); + } + VERIFY_IS_FALSE(sourceAnnouncement.suppressInput); + const auto sourceMeter = trustedSourceArchive.Consume( + L"\x2501\x2501\x2501\x2501 1.0/2.0 MB 4.0 MB/s eta 0:00:01\r\x1b[2K", + 100, + replacement); + VERIFY_IS_TRUE(sourceMeter.progress.has_value()); + if (sourceMeter.progress) + { + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::Pip), static_cast(sourceMeter.progress->provider)); + VERIFY_ARE_EQUAL(static_cast(ProviderConfidence::High), static_cast(sourceMeter.progress->confidence)); + } + + RecognitionEngine maven; + const auto download = maven.Consume( + L"Downloading from central: https://repo.example.invalid/artifact.jar\n", + 0); + VERIFY_IS_TRUE(download.progress.has_value()); + if (download.progress) + { + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::Maven), static_cast(download.progress->provider)); + VERIFY_ARE_EQUAL(static_cast(ProgressMode::Indeterminate), static_cast(download.progress->mode)); + } + + const auto resolver = maven.Consume(L"Progress (1): 512/1024 kB\r\x1b[2K", 50); + VERIFY_IS_TRUE(resolver.progress.has_value()); + if (resolver.progress) + { + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::Maven), static_cast(resolver.progress->provider)); + VERIFY_ARE_EQUAL(static_cast(ProgressMode::Determinate), static_cast(resolver.progress->mode)); + VERIFY_ARE_EQUAL(uint8_t{ 50 }, resolver.progress->value); + VERIFY_IS_FALSE(resolver.progress->suppressible); + } + VERIFY_IS_FALSE(resolver.suppressInput); + + RecognitionEngine taggedMaven; + const auto taggedDownload = taggedMaven.Consume( + L"[INFO] Downloading from central: https://repo.example.invalid/artifact.jar\n", + 0); + VERIFY_IS_TRUE(taggedDownload.progress.has_value()); + if (taggedDownload.progress) + { + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::Maven), static_cast(taggedDownload.progress->provider)); + VERIFY_ARE_EQUAL(static_cast(ProgressMode::Indeterminate), static_cast(taggedDownload.progress->mode)); + } + + RecognitionEngine mavenStage; + const auto tests = mavenStage.Consume(L"[INFO] Tests run: 5/10\n", 0); + VERIFY_IS_TRUE(tests.progress.has_value()); + if (tests.progress) + { + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::Maven), static_cast(tests.progress->provider)); + VERIFY_ARE_EQUAL(static_cast(ProgressMode::Indeterminate), static_cast(tests.progress->mode)); + } + } + + void WinTermVisualProgressTests::RecognitionHandlesGenericIndeterminateShapes() + { + RecognitionOptions replacement; + replacement.replacementEnabled = true; + replacement.rendererEnabled = true; + + RecognitionEngine spinner; + spinner.Consume(L"\r", 0); + const auto spinnerResult = spinner.Consume(L"|\r\x1b[2K", 50, replacement); + VERIFY_IS_TRUE(spinnerResult.progress.has_value()); + if (spinnerResult.progress) + { + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::Generic), static_cast(spinnerResult.progress->provider)); + VERIFY_ARE_EQUAL(static_cast(ProgressMode::Indeterminate), static_cast(spinnerResult.progress->mode)); + VERIFY_IS_FALSE(spinnerResult.progress->suppressible); + } + VERIFY_IS_FALSE(spinnerResult.suppressInput); + + RecognitionEngine speedAndEta; + const auto transfer = speedAndEta.Consume(L"4.0 MB/s ETA 00:03\r\x1b[2K", 0, replacement); + VERIFY_IS_TRUE(transfer.progress.has_value()); + if (transfer.progress) + { + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::Generic), static_cast(transfer.progress->provider)); + VERIFY_ARE_EQUAL(static_cast(ProgressMode::Indeterminate), static_cast(transfer.progress->mode)); + VERIFY_IS_FALSE(transfer.progress->suppressible); + } + VERIFY_IS_FALSE(transfer.suppressInput); + + RecognitionEngine repeated; + const auto first = repeated.Consume(L"processed 1\r\x1b[2K", 0, replacement); + VERIFY_IS_FALSE(first.progress.has_value()); + VERIFY_IS_FALSE(first.suppressInput); + const auto second = repeated.Consume(L"processed 2\r\x1b[2K", 50, replacement); + VERIFY_IS_TRUE(second.progress.has_value()); + if (second.progress) + { + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::Generic), static_cast(second.progress->provider)); + VERIFY_ARE_EQUAL(static_cast(ProgressMode::Indeterminate), static_cast(second.progress->mode)); + VERIFY_IS_FALSE(second.progress->suppressible); + } + VERIFY_IS_FALSE(second.suppressInput); + + const auto clear = repeated.Consume(L"finished ordinary work\n", 51, replacement); + VERIFY_IS_TRUE(clear.progress.has_value()); + if (clear.progress) + { + VERIFY_IS_FALSE(clear.progress->visible); + VERIFY_ARE_EQUAL(static_cast(ProgressProvider::None), static_cast(clear.progress->provider)); + } + VERIFY_IS_FALSE(clear.suppressInput); + } + + void WinTermVisualProgressTests::RecognitionHandlesArbitraryProviderSplitsAndReset() + { + struct Fixture + { + std::wstring_view stream; + ProgressProvider provider; + }; + + const std::array fixtures{ + Fixture{ L"demo-layer: Downloading 512B/1.0kB\n", ProgressProvider::DockerPull }, + Fixture{ L"#7 [3/8] RUN build 50%\n", ProgressProvider::DockerBuildKit }, + Fixture{ L"Downloading demo_package-1.0-py3-none-any.whl (2.0 MB)\n\x2501\x2501 1.0/2.0 MB 4.0 MB/s eta 0:00:01\n", ProgressProvider::Pip }, + Fixture{ L"Receiving objects: 50% (50/100)\n", ProgressProvider::Git }, + Fixture{ L"% Total % Received\r\n50 1024 50 512 0\n", ProgressProvider::Curl }, + Fixture{ L"Saving to: 'demo.bin'\ndemo.bin 50%[====> ] 512K 1.0MB/s eta 1s\n", ProgressProvider::Wget }, + Fixture{ L"npm fetch 50% (5/10)\n", ProgressProvider::Npm }, + Fixture{ L"pnpm download 50% (5/10)\n", ProgressProvider::Pnpm }, + Fixture{ L"yarn fetch 50% (5/10)\n", ProgressProvider::Yarn }, + Fixture{ L"nvm downloading node.js 50%\n", ProgressProvider::Nvm }, + Fixture{ L"Downloading from central: https://repo.example.invalid/artifact.jar\nProgress (1): 512/1024 kB\n", ProgressProvider::Maven }, + Fixture{ L"75% EXECUTING\n", ProgressProvider::Gradle }, + Fixture{ L"42% (42/100) 1.0 MB/s ETA 00:05\n", ProgressProvider::Generic }, + }; + + const auto remember = [](std::optional& latest, const RecognitionResult& result) { + if (result.progress) + { + latest = result.progress; + } + }; + + for (const auto& fixture : fixtures) + { + for (size_t split = 1; split < fixture.stream.size(); ++split) + { + RecognitionEngine engine; + std::optional latest; + remember(latest, engine.Consume(fixture.stream.substr(0, split), 0)); + remember(latest, engine.Consume(fixture.stream.substr(split), 50)); + VERIFY_IS_TRUE(latest.has_value()); + if (latest) + { + VERIFY_ARE_EQUAL(static_cast(fixture.provider), static_cast(latest->provider)); + } + } + + RecognitionEngine reset; + reset.Consume(fixture.stream.substr(0, fixture.stream.size() / 2), 0); + VERIFY_IS_TRUE(reset.TryReset()); + const auto afterReset = reset.Consume(fixture.stream, 50); + VERIFY_IS_TRUE(afterReset.progress.has_value()); + if (afterReset.progress) + { + VERIFY_ARE_EQUAL(static_cast(fixture.provider), static_cast(afterReset.progress->provider)); + } + VERIFY_IS_FALSE(afterReset.suppressInput); + } + } + + void WinTermVisualProgressTests::RecognitionAppliesSuppressionSafetyMatrix() + { + struct Fixture + { + std::wstring_view prelude; + std::wstring_view transientRecord; + std::wstring_view newlineRecord; + ProgressProvider provider; + bool safelyReplaceable; + }; + + const std::array fixtures{ + Fixture{ {}, L"demo-layer: Downloading 512B/1.0kB\r\x1b[2K", L"demo-layer: Downloading 512B/1.0kB\n", ProgressProvider::DockerPull, false }, + Fixture{ {}, L"#7 [3/8] RUN build 50%\r\x1b[2K", L"#7 [3/8] RUN build 50%\n", ProgressProvider::DockerBuildKit, false }, + Fixture{ L"Downloading demo_package-1.0-py3-none-any.whl (2.0 MB)\n", L"\x2501\x2501 1.0/2.0 MB 4.0 MB/s eta 0:00:01\r\x1b[2K", L"\x2501\x2501 1.0/2.0 MB 4.0 MB/s eta 0:00:01\n", ProgressProvider::Pip, true }, + Fixture{ {}, L"Receiving objects: 50% (50/100)\r\x1b[2K", L"Receiving objects: 50% (50/100)\n", ProgressProvider::Git, true }, + Fixture{ L"% Total % Received\r\n", L"50 1024 50 512 0\r\x1b[2K", L"50 1024 50 512 0\n", ProgressProvider::Curl, true }, + Fixture{ L"Saving to: 'demo.bin'\n", L"demo.bin 50%[====> ] 512K 1.0MB/s eta 1s\r\x1b[2K", L"demo.bin 50%[====> ] 512K 1.0MB/s eta 1s\n", ProgressProvider::Wget, true }, + Fixture{ {}, L"npm fetch 50% (5/10)\r\x1b[2K", L"npm fetch 50% (5/10)\n", ProgressProvider::Npm, false }, + Fixture{ {}, L"pnpm download 50% (5/10)\r\x1b[2K", L"pnpm download 50% (5/10)\n", ProgressProvider::Pnpm, false }, + Fixture{ {}, L"yarn fetch 50% (5/10)\r\x1b[2K", L"yarn fetch 50% (5/10)\n", ProgressProvider::Yarn, false }, + Fixture{ {}, L"nvm downloading node.js 50%\r\x1b[2K", L"nvm downloading node.js 50%\n", ProgressProvider::Nvm, false }, + Fixture{ L"Downloading from central: https://repo.example.invalid/artifact.jar\n", L"Progress (1): 512/1024 kB\r\x1b[2K", L"Progress (1): 512/1024 kB\n", ProgressProvider::Maven, false }, + Fixture{ {}, L"75% EXECUTING\r\x1b[2K", L"75% EXECUTING\n", ProgressProvider::Gradle, false }, + Fixture{ {}, L"42% (42/100) 1.0 MB/s ETA 00:05\r\x1b[2K", L"42% (42/100) 1.0 MB/s ETA 00:05\n", ProgressProvider::Generic, false }, + }; + + RecognitionOptions replacement; + replacement.replacementEnabled = true; + replacement.rendererEnabled = true; + + const auto prepare = [](RecognitionEngine& engine, + const std::wstring_view prelude, + uint64_t& timestamp) { + if (!prelude.empty()) + { + engine.Consume(prelude, timestamp); + timestamp += RecognitionEngine::PublicationIntervalMilliseconds; + } + engine.Consume(L"\r", timestamp); + timestamp += RecognitionEngine::PublicationIntervalMilliseconds; + }; + + for (const auto& fixture : fixtures) + { + RecognitionEngine whole; + uint64_t timestamp{}; + prepare(whole, fixture.prelude, timestamp); + const auto wholeResult = whole.Consume(fixture.transientRecord, timestamp, replacement); + VERIFY_IS_TRUE(wholeResult.progress.has_value()); + if (wholeResult.progress) + { + VERIFY_ARE_EQUAL(static_cast(fixture.provider), static_cast(wholeResult.progress->provider)); + VERIFY_ARE_EQUAL(fixture.safelyReplaceable, wholeResult.progress->suppressible); + } + VERIFY_ARE_EQUAL(fixture.safelyReplaceable, wholeResult.suppressInput); + + RecognitionEngine newline; + timestamp = 0; + prepare(newline, fixture.prelude, timestamp); + const auto newlineResult = newline.Consume(fixture.newlineRecord, timestamp, replacement); + VERIFY_IS_TRUE(newlineResult.progress.has_value()); + if (newlineResult.progress) + { + VERIFY_ARE_EQUAL(static_cast(fixture.provider), static_cast(newlineResult.progress->provider)); + VERIFY_IS_FALSE(newlineResult.progress->suppressible); + } + VERIFY_IS_FALSE(newlineResult.suppressInput); + + RecognitionEngine fragmented; + timestamp = 0; + prepare(fragmented, fixture.prelude, timestamp); + const auto split = fixture.transientRecord.size() / 2; + std::optional fragmentedProgress; + const auto prefix = fragmented.Consume(fixture.transientRecord.substr(0, split), timestamp, replacement); + if (prefix.progress) + { + fragmentedProgress = prefix.progress; + } + const auto suffix = fragmented.Consume( + fixture.transientRecord.substr(split), + timestamp + RecognitionEngine::PublicationIntervalMilliseconds, + replacement); + if (suffix.progress) + { + fragmentedProgress = suffix.progress; + } + VERIFY_IS_TRUE(fragmentedProgress.has_value()); + if (fragmentedProgress) + { + VERIFY_ARE_EQUAL(static_cast(fixture.provider), static_cast(fragmentedProgress->provider)); + } + VERIFY_IS_FALSE(prefix.suppressInput); + VERIFY_IS_FALSE(suffix.suppressInput); + + auto rendererUnavailable = replacement; + rendererUnavailable.rendererEnabled = false; + auto alternateScreen = replacement; + alternateScreen.normalScreen = false; + auto parserUnavailable = replacement; + parserUnavailable.parserHealthy = false; + for (const auto gated : { rendererUnavailable, alternateScreen, parserUnavailable }) + { + RecognitionEngine gatedEngine; + timestamp = 0; + prepare(gatedEngine, fixture.prelude, timestamp); + const auto gatedResult = gatedEngine.Consume(fixture.transientRecord, timestamp, gated); + VERIFY_IS_TRUE(gatedResult.progress.has_value()); + if (gatedResult.progress) + { + VERIFY_ARE_EQUAL(static_cast(fixture.provider), static_cast(gatedResult.progress->provider)); + } + VERIFY_IS_FALSE(gatedResult.suppressInput); + } + } + } + void WinTermVisualProgressTests::RendererPlansRealValuesRegressionAndIndeterminateMode() { RenderEnvironment environment; @@ -1112,6 +1552,7 @@ namespace SettingsModelUnitTests environment, RenderTimestamp{ 3000 }); VERIFY_IS_TRUE(error.errorPulse); + VERIFY_IS_FALSE(error.errorWithoutProgress); VERIFY_IS_FALSE(error.sparksEligible); VERIFY_IS_FALSE(error.fadeOut); VERIFY_IS_FALSE(RequiresSparkWork(error, 0)); @@ -1126,6 +1567,31 @@ namespace SettingsModelUnitTests VERIFY_IS_FALSE(errorActiveRefresh.errorPulse); VERIFY_IS_FALSE(RequiresSparkWork(errorActiveRefresh, 0)); + VisualProgressRenderState unknownProgressError; + unknownProgressError.Apply( + { ProgressMode::Indeterminate, ProgressStatus::Running, 0, true, ProgressSource::Provider, 1 }, + environment, + RenderTimestamp{ 0 }); + const auto statusOnlyError = unknownProgressError.Apply( + { ProgressMode::Determinate, ProgressStatus::Error, 0, true, ProgressSource::Provider, 2 }, + environment, + RenderTimestamp{ 100 }); + VERIFY_ARE_EQUAL(0.0f, statusOnlyError.targetProgress); + VERIFY_IS_TRUE(statusOnlyError.errorWithoutProgress); + VERIFY_IS_TRUE(statusOnlyError.headVisible); + VERIFY_IS_TRUE(statusOnlyError.errorPulse); + environment.windowFocused = false; + const auto retainedStatusOnlyError = unknownProgressError.RefreshEnvironment(environment, RenderTimestamp{ 200 }); + VERIFY_IS_TRUE(retainedStatusOnlyError.errorWithoutProgress); + VERIFY_IS_TRUE(retainedStatusOnlyError.headVisible); + VERIFY_IS_FALSE(retainedStatusOnlyError.errorPulse); + environment.windowFocused = true; + + VERIFY_ARE_EQUAL(uint32_t{ 0xFF087A63u }, RainbowArcVisualConstants::LightRunningSolid); + VERIFY_ARE_EQUAL(uint32_t{ 0xFF8A5D00u }, RainbowArcVisualConstants::LightWaitingSolid); + VERIFY_ARE_EQUAL(uint32_t{ 0xFF087A42u }, RainbowArcVisualConstants::LightSuccessSolid); + VERIFY_ARE_EQUAL(uint32_t{ 0xFFB42318u }, RainbowArcVisualConstants::LightErrorSolid); + environment.animationsEnabled = false; const auto reducedMotion = renderer.RefreshEnvironment(environment, RenderTimestamp{ 4000 }); VERIFY_IS_TRUE(reducedMotion.staticFallback); @@ -1281,8 +1747,21 @@ namespace SettingsModelUnitTests VERIFY_IS_FALSE(RequiresSparkWork(background, 0)); environment.paneActive = true; + environment.windowFocused = false; + const auto unfocused = renderer.RefreshEnvironment(environment, RenderTimestamp{ 1 }); + VERIFY_IS_TRUE(unfocused.visible); + VERIFY_IS_FALSE(unfocused.rainbowMoving); + VERIFY_IS_FALSE(unfocused.sparksEligible); + VERIFY_IS_FALSE(RequiresSparkWork(unfocused, 0)); + + environment.windowFocused = true; + const auto refocused = renderer.RefreshEnvironment(environment, RenderTimestamp{ 2 }); + VERIFY_IS_TRUE(refocused.visible); + VERIFY_IS_TRUE(refocused.rainbowMoving); + VERIFY_IS_TRUE(refocused.sparksEligible); + environment.windowVisible = false; - const auto hidden = renderer.RefreshEnvironment(environment, RenderTimestamp{ 1 }); + const auto hidden = renderer.RefreshEnvironment(environment, RenderTimestamp{ 3 }); VERIFY_IS_FALSE(hidden.visible); VERIFY_IS_FALSE(hidden.sparksEligible); VERIFY_IS_FALSE(RequiresSparkWork(hidden, 0)); @@ -1290,7 +1769,7 @@ namespace SettingsModelUnitTests environment.windowVisible = true; environment.tabVisible = false; - const auto backgroundTab = renderer.RefreshEnvironment(environment, RenderTimestamp{ 2 }); + const auto backgroundTab = renderer.RefreshEnvironment(environment, RenderTimestamp{ 4 }); VERIFY_IS_FALSE(backgroundTab.visible); VERIFY_IS_FALSE(backgroundTab.sparksEligible); VERIFY_IS_FALSE(RequiresSparkWork(backgroundTab, 0)); diff --git a/src/winterm/VisualProgress/ProgressRecognition.h b/src/winterm/VisualProgress/ProgressRecognition.h index 658e5b0be..969857fb3 100644 --- a/src/winterm/VisualProgress/ProgressRecognition.h +++ b/src/winterm/VisualProgress/ProgressRecognition.h @@ -280,6 +280,7 @@ namespace winTerm::VisualProgress ProviderProgress progress; bool matched{}; bool preserveOnly{}; + bool pendingCandidate{}; }; struct LayerState @@ -385,6 +386,32 @@ namespace winTerm::VisualProgress return false; } + static bool _containsFileExtensionInsensitive(const std::wstring_view value, + const std::wstring_view extension) noexcept + { + if (extension.empty() || extension.size() > value.size()) + { + return false; + } + for (size_t i = 0; i + extension.size() <= value.size(); ++i) + { + if (!_equalsInsensitive(value.substr(i, extension.size()), extension)) + { + continue; + } + const auto after = i + extension.size(); + if (after == value.size() || + value[after] == L' ' || value[after] == L'\t' || + value[after] == L')' || value[after] == L'?' || + value[after] == L'#' || value[after] == L'\'' || + value[after] == L'"') + { + return true; + } + } + return false; + } + static std::wstring_view _trim(const std::wstring_view value) noexcept { size_t first{}; @@ -627,6 +654,65 @@ namespace winTerm::VisualProgress return std::nullopt; } + // Rich-style progress meters commonly put the unit after the total, + // for example "1.5/3.0 MB". The shared unit is applied to both + // operands so only structural numeric state is retained. + static std::optional _findSharedUnitQuantityFraction(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') || + value[leftFirst - 1] == L'.')) + { + --leftFirst; + } + + auto rightFirst = slash + 1; + while (rightFirst < value.size() && (value[rightFirst] == L' ' || value[rightFirst] == L'\t')) + { + ++rightFirst; + } + auto rightLast = rightFirst; + while (rightLast < value.size() && ((value[rightLast] >= L'0' && value[rightLast] <= L'9') || + value[rightLast] == L'.')) + { + ++rightLast; + } + + auto unitFirst = rightLast; + while (unitFirst < value.size() && (value[unitFirst] == L' ' || value[unitFirst] == L'\t')) + { + ++unitFirst; + } + auto unitLast = unitFirst; + while (unitLast < value.size() && ((value[unitLast] >= L'A' && value[unitLast] <= L'Z') || + (value[unitLast] >= L'a' && value[unitLast] <= L'z'))) + { + ++unitLast; + } + + if (leftFirst == slash || rightFirst == rightLast || unitFirst == unitLast) + { + continue; + } + + const auto current = _parseQuantity(value, leftFirst, slash, unitFirst, unitLast); + const auto total = _parseQuantity(value, rightFirst, rightLast, unitFirst, unitLast); + 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)) @@ -637,9 +723,24 @@ namespace winTerm::VisualProgress { return quantity; } + if (const auto sharedUnitQuantity = _findSharedUnitQuantityFraction(value)) + { + return sharedUnitQuantity; + } return _findIntegerFraction(value); } + static bool _hasTransferRateAndEta(const std::wstring_view value) noexcept + { + const auto hasRate = _containsInsensitive(value, L"kb/s") || + _containsInsensitive(value, L"mb/s") || + _containsInsensitive(value, L"gb/s") || + _containsInsensitive(value, L"kib/s") || + _containsInsensitive(value, L"mib/s") || + _containsInsensitive(value, L"gib/s"); + return hasRate && _containsInsensitive(value, L"eta"); + } + static bool _hasCurlMeterColumns(const std::wstring_view value, size_t cursor) noexcept { size_t numericColumns{ 1 }; @@ -927,32 +1028,59 @@ namespace winTerm::VisualProgress 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")) && + // Once a Python archive anchors the record, either a transfer rate + // or an ETA is sufficient. Requiring both would drop legitimate + // pip meters that append a warning in place of the ETA. + const auto pipTransferSignal = _containsInsensitive(line, L"kb/s") || + _containsInsensitive(line, L"mb/s") || + _containsInsensitive(line, L"gb/s") || + _containsInsensitive(line, L"kib/s") || + _containsInsensitive(line, L"mib/s") || + _containsInsensitive(line, L"gib/s") || + _containsInsensitive(line, L"eta"); + const auto transferShape = pipTransferSignal && (_findPercent(line).has_value() || _findQuantityFraction(line).has_value() || + _findSharedUnitQuantityFraction(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; + const auto pipWheel = _startsWithInsensitive(trimmed, L"downloading ") && + _containsFileExtensionInsensitive(trimmed, L".whl"); + const auto pipArchive = pipWheel || + _containsFileExtensionInsensitive(trimmed, L".tar.gz") || + _containsFileExtensionInsensitive(trimmed, L".tar.bz2") || + _containsFileExtensionInsensitive(trimmed, L".tgz") || + _containsFileExtensionInsensitive(trimmed, L".zip"); + const auto pipSizedDownload = _startsWithInsensitive(trimmed, L"downloading ") && + pipArchive && + (_containsInsensitive(trimmed, L" kb)") || + _containsInsensitive(trimmed, L" mb)") || + _containsInsensitive(trimmed, L" gb)")); + // Modern pip emits a bounded two-record shape: a sized archive + // announcement followed by a Rich meter. Claim the structural + // announcement only; no package name or URL is retained. + // Wheel names are pip-specific enough to bootstrap ownership. + // Generic archive extensions are accepted only after an explicit + // pip signature or an existing pip claim establishes the stream. + const auto pipContext = _claimedProvider == ProgressProvider::Pip || pipSignature || pipWheel; 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)) + if (!pipContext || (!transferShape && !pipSignature && !pipSizedDownload)) { return {}; } - const auto confidence = transferShape ? ProviderConfidence::High : ProviderConfidence::Medium; - return _runningMatch(ProgressProvider::Pip, line, confidence, _containsInsensitive(line, L"installing") ? 2 : 1); + const auto hasRealProgress = _realProgress(line).has_value(); + const auto confidence = transferShape || (pipSizedDownload && hasRealProgress) ? + ProviderConfidence::High : + ProviderConfidence::Medium; + auto match = _runningMatch(ProgressProvider::Pip, line, confidence, _containsInsensitive(line, L"installing") ? 2 : 1); + match.preserveOnly = pipSizedDownload && !transferShape; + return match; } Match _matchGit(const std::wstring_view line) const noexcept @@ -1056,6 +1184,17 @@ namespace winTerm::VisualProgress // 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 (explicitAnchor && (!transferShape || !bracketMeter)) + { + return { _makeProgress(ProgressProvider::Wget, + ProgressMode::Indeterminate, + ProgressStatus::Running, + 0, + ProviderConfidence::High, + 1), + true, + true }; + } if (!transferShape || !bracketMeter || !wgetContext) { return {}; @@ -1172,7 +1311,18 @@ namespace winTerm::VisualProgress 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; + const auto resolverTransfer = _startsWithInsensitive(trimmed, L"downloading from ") || + _startsWithInsensitive(trimmed, L"downloaded from ") || + (taggedInfo && + (_containsInsensitive(trimmed, L"] downloading from ") || + _containsInsensitive(trimmed, L"] downloaded from "))); + const auto resolverProgressPrefix = _startsWithInsensitive(trimmed, L"progress (") || + (taggedInfo && _containsInsensitive(trimmed, L"] progress (")); + const auto resolverProgress = resolverProgressPrefix && + _containsInsensitive(trimmed, L"):") && + _realProgress(trimmed).has_value(); + const auto mavenAnchor = taggedInfo || taggedError || resolverTransfer || resolverProgress || + _claimedProvider == ProgressProvider::Maven; if (!mavenAnchor && _claimedProvider != ProgressProvider::Maven) { return {}; @@ -1186,9 +1336,7 @@ namespace winTerm::VisualProgress 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 (")) + if (resolverTransfer || resolverProgress) stage = 1; else if (_containsInsensitive(line, L"compile")) stage = 2; @@ -1202,7 +1350,16 @@ namespace winTerm::VisualProgress { return {}; } - auto match = _runningMatch(ProgressProvider::Maven, line, ProviderConfidence::High, stage); + auto match = resolverProgress ? + _runningMatch(ProgressProvider::Maven, line, ProviderConfidence::High, stage) : + Match{ _makeProgress(ProgressProvider::Maven, + ProgressMode::Indeterminate, + ProgressStatus::Running, + 0, + ProviderConfidence::High, + stage), + true, + true }; match.preserveOnly = true; return match; } @@ -1234,15 +1391,83 @@ namespace winTerm::VisualProgress return match; } - Match _matchGeneric(const std::wstring_view line) noexcept + static bool _isSpinner(const wchar_t value) noexcept + { + return value == L'|' || value == L'/' || value == L'-' || value == L'\\'; + } + + static uint64_t _genericTransientShape(const std::wstring_view value, bool& hasDigit) noexcept + { + uint64_t hash{ 1469598103934665603ull }; + hasDigit = false; + for (const auto ch : value) + { + uint8_t category{}; + if (ch >= L'0' && ch <= L'9') + { + category = 1; + hasDigit = true; + } + else if ((ch >= L'a' && ch <= L'z') || (ch >= L'A' && ch <= L'Z')) + { + category = 2; + } + else if (ch == L' ' || ch == L'\t') + { + category = 3; + } + else if (ch < 0x80) + { + category = 4; + } + else + { + category = 5; + } + hash ^= category; + hash *= 1099511628211ull; + } + return hash == 0 ? 1 : hash; + } + + ProviderConfidence _advanceGenericConfidence() noexcept + { + _genericMatchStreak = static_cast(_genericMatchStreak < 2 ? _genericMatchStreak + 1 : 2); + return _genericMatchStreak >= 2 ? ProviderConfidence::Medium : ProviderConfidence::Low; + } + + void _resetGenericHeuristics() noexcept + { + _genericMatchStreak = 0; + _genericTransientShapeValue = 0; + _genericTransientShapeLength = 0; + _genericTransientShapeStreak = 0; + } + + Match _matchGeneric(const std::wstring_view line, const bool transientRecord) noexcept { const auto trimmed = _trim(line); if (trimmed.empty() || _isPreserveOnly(trimmed)) { - _genericMatchStreak = 0; + _resetGenericHeuristics(); return {}; } + if (transientRecord && trimmed.size() == 1 && _isSpinner(trimmed.front())) + { + _genericTransientShapeValue = 0; + _genericTransientShapeLength = 0; + _genericTransientShapeStreak = 0; + return { _makeProgress(ProgressProvider::Generic, + ProgressMode::Indeterminate, + ProgressStatus::Running, + 0, + _advanceGenericConfidence(), + 2), + true, + false }; + } + size_t first{}; if (trimmed.size() > 1 && (trimmed.front() == L'|' || trimmed.front() == L'/' || @@ -1272,35 +1497,118 @@ namespace winTerm::VisualProgress 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()); + _findQuantityFraction(trimmed.substr(first)).has_value() || + _findSharedUnitQuantityFraction(trimmed.substr(first)).has_value()); } const auto realProgress = _realProgress(trimmed); - if (!anchored || !realProgress) + const auto transferRateAndEta = _hasTransferRateAndEta(trimmed); + const auto bracketMeter = trimmed.find(L"%[") != std::wstring_view::npos && + trimmed.find(L']') != std::wstring_view::npos; + if (anchored && realProgress) + { + _genericTransientShapeValue = 0; + _genericTransientShapeLength = 0; + _genericTransientShapeStreak = 0; + auto match = Match{ _makeProgress(ProgressProvider::Generic, + ProgressMode::Determinate, + ProgressStatus::Running, + *realProgress, + _advanceGenericConfidence(), + 1), + true, + false }; + match.progress.suppressible = false; + return match; + } + + // A bracket meter with rate and ETA is a wget-shaped record. It is + // only trustworthy after wget has emitted its provider anchor; do + // not let the generic fallback claim unrelated bracketed output. + if (bracketMeter) { - _genericMatchStreak = 0; + _resetGenericHeuristics(); 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; + if (transferRateAndEta) + { + _genericTransientShapeValue = 0; + _genericTransientShapeLength = 0; + _genericTransientShapeStreak = 0; + return { _makeProgress(ProgressProvider::Generic, + realProgress ? ProgressMode::Determinate : ProgressMode::Indeterminate, + ProgressStatus::Running, + realProgress.value_or(0), + _advanceGenericConfidence(), + 3), + true, + false }; + } + + if (transientRecord && trimmed.size() >= 3) + { + bool hasDigit{}; + const auto shape = _genericTransientShape(trimmed, hasDigit); + if (hasDigit) + { + if (_genericTransientShapeValue == shape && _genericTransientShapeLength == trimmed.size()) + { + _genericTransientShapeStreak = static_cast( + _genericTransientShapeStreak < 2 ? _genericTransientShapeStreak + 1 : 2); + } + else + { + _genericTransientShapeValue = shape; + _genericTransientShapeLength = static_cast(trimmed.size()); + _genericTransientShapeStreak = 1; + } + + if (_genericTransientShapeStreak >= 2) + { + return { _makeProgress(ProgressProvider::Generic, + ProgressMode::Indeterminate, + ProgressStatus::Running, + 0, + _advanceGenericConfidence(), + 4), + true, + false }; + } + return { {}, false, false, true }; + } + } + + _resetGenericHeuristics(); + return {}; } - Match _recognize(const std::wstring_view line, CallState& call) noexcept + bool _hasActiveHighConfidenceBuiltInClaim() const noexcept + { + return _claimedProvider != ProgressProvider::None && + _claimedProvider != ProgressProvider::Generic && + _claimedProviderConfidence == ProviderConfidence::High; + } + + static ProviderProgress _providerClear() noexcept + { + ProviderProgress progress; + progress.provider = ProgressProvider::None; + progress.mode = ProgressMode::Hidden; + progress.status = ProgressStatus::Cancelled; + progress.confidence = ProviderConfidence::None; + progress.visible = false; + return progress; + } + + Match _recognize(const std::wstring_view line, + CallState& call, + const bool transientRecord) noexcept { // Interactive text is an output barrier, even when it happens to // contain a percentage or transfer-rate shape. if (_isInteractivePrompt(line)) { - _genericMatchStreak = 0; + _resetGenericHeuristics(); return {}; } @@ -1362,7 +1670,18 @@ namespace winTerm::VisualProgress { return match; } - return _matchGeneric(line); + + auto generic = _matchGeneric(line, transientRecord); + // Generic recognition is a fallback, not a new owner. A valid + // high-confidence built-in claim survives another progress-shaped + // record, but an ordinary record clears ownership below so a + // later command cannot inherit suppression authority. + if (_hasActiveHighConfidenceBuiltInClaim() && + (generic.matched || generic.pendingCandidate)) + { + return { {}, false, false, true }; + } + return generic; } static bool _sameProgress(const ProviderProgress& left, const ProviderProgress& right) noexcept @@ -1441,7 +1760,8 @@ namespace winTerm::VisualProgress // 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); + const auto transientRecord = ending == RecordEnding::CarriageReturn || _recordHadEraseLine; + auto match = _recordOverflow || _recordMalformed ? Match{} : _recognize(line, call, transientRecord); if (match.matched) { auto& progress = match.progress; @@ -1474,10 +1794,12 @@ namespace winTerm::VisualProgress else if (progress.provider != ProgressProvider::Generic && !terminalState) { _claimedProvider = progress.provider; + _claimedProviderConfidence = progress.confidence; } else { _claimedProvider = ProgressProvider::None; + _claimedProviderConfidence = ProviderConfidence::None; } if (ambiguousCarriageReturn) @@ -1503,8 +1825,20 @@ namespace winTerm::VisualProgress else { call.onlySafeContent = false; - _genericMatchStreak = 0; - _claimedProvider = ProgressProvider::None; + if (_lastSeen && _lastSeen->visible && _lastSeen->provider == ProgressProvider::Generic) + { + // A structural clear is terminal-state exempt from the + // publication throttle and contains no output text. + _rememberProgress(_providerClear()); + } + if (!match.pendingCandidate) + { + _resetGenericHeuristics(); + } + if (!match.pendingCandidate) + { + _clearProviderContext(); + } } } @@ -1791,9 +2125,10 @@ namespace winTerm::VisualProgress void _clearProviderContext() noexcept { _claimedProvider = ProgressProvider::None; + _claimedProviderConfidence = ProviderConfidence::None; _curlHeaderSeen = false; _wgetAnchorSeen = false; - _genericMatchStreak = 0; + _resetGenericHeuristics(); _dockerLayers = {}; _buildKitSteps = {}; } @@ -1844,9 +2179,13 @@ namespace winTerm::VisualProgress bool _atColumnZero{}; ProgressProvider _claimedProvider{ ProgressProvider::None }; + ProviderConfidence _claimedProviderConfidence{ ProviderConfidence::None }; bool _curlHeaderSeen{}; bool _wgetAnchorSeen{}; uint8_t _genericMatchStreak{}; + uint64_t _genericTransientShapeValue{}; + uint16_t _genericTransientShapeLength{}; + uint8_t _genericTransientShapeStreak{}; std::array _dockerLayers{}; std::array _buildKitSteps{}; std::array _recentProgress{}; diff --git a/src/winterm/VisualProgress/RainbowArcRenderer.h b/src/winterm/VisualProgress/RainbowArcRenderer.h index d149e6eb3..db2b02011 100644 --- a/src/winterm/VisualProgress/RainbowArcRenderer.h +++ b/src/winterm/VisualProgress/RainbowArcRenderer.h @@ -11,7 +11,6 @@ #include #include #include -#include #include #include #include @@ -32,7 +31,6 @@ 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; @@ -111,6 +109,23 @@ namespace winTerm::VisualProgress _applyWithDegradation(plan, false); } + // XAML Islands do not expose meaningful top-level visibility or + // activation through CoreWindow. Pane feeds these values from the + // existing HWND lifecycle owned by TerminalPage instead. + void SetHostWindowState(const bool visible, const bool focused) noexcept + { + if (_closed || _faulted || + (_environment.windowVisible == visible && _environment.windowFocused == focused)) + { + return; + } + + _environment.windowVisible = visible; + _environment.windowFocused = focused; + const auto plan = _renderState.RefreshEnvironment(_environment, _now()); + _applyWithDegradation(plan, false); + } + void RefreshEnvironment() noexcept { if (_closed || _faulted) @@ -159,7 +174,6 @@ namespace winTerm::VisualProgress _releaseCompositionHandles(); _host = nullptr; - _coreWindow = nullptr; _accessibilitySettings = nullptr; _uiSettings = nullptr; } @@ -210,6 +224,11 @@ namespace winTerm::VisualProgress RainbowArcRenderer() noexcept : _sparkPool{ _sharedSparkBudget } { + // Visible is a safe presentation default. Focus deliberately + // starts false so no continuous work or sparks begin before the + // owning HWND publishes its authoritative state. + _environment.windowVisible = true; + _environment.windowFocused = false; } static RenderTimestamp _now() noexcept @@ -237,6 +256,11 @@ namespace winTerm::VisualProgress return value; } + static bool _sameColor(const WU::Color& left, const WU::Color& right) noexcept + { + return left.A == right.A && left.R == right.R && left.G == right.G && left.B == right.B; + } + bool _initialize(const WUXC::Grid& host) noexcept { _host = host; @@ -437,9 +461,9 @@ namespace winTerm::VisualProgress _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)); + _errorOuterBloomBrush = _createRadialBrush(_withAlpha(_errorColor, 115), 0); + _errorInnerGlowBrush = _createRadialBrush(_withAlpha(_errorColor, 220), 0); + _errorTrailBrush = _createStatusTrailBrush(_errorColor); _outerBloom = _compositor.CreateSpriteVisual(); _outerBloom.Brush(_outerBloomBrush); @@ -667,35 +691,6 @@ namespace winTerm::VisualProgress { } - 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) @@ -754,14 +749,6 @@ namespace winTerm::VisualProgress { _host.ActualThemeChanged(_themeChangedToken); } - if (_coreActivatedSubscribed && _coreWindow) - { - _coreWindow.Activated(_coreActivatedToken); - } - if (_coreVisibilitySubscribed && _coreWindow) - { - _coreWindow.VisibilityChanged(_coreVisibilityToken); - } if (_highContrastSubscribed && _accessibilitySettings) { _accessibilitySettings.HighContrastChanged(_highContrastToken); @@ -778,8 +765,6 @@ namespace winTerm::VisualProgress _unloadedSubscribed = false; _sizeChangedSubscribed = false; _themeChangedSubscribed = false; - _coreActivatedSubscribed = false; - _coreVisibilitySubscribed = false; _highContrastSubscribed = false; _animationsSubscribed = false; } @@ -826,17 +811,6 @@ namespace winTerm::VisualProgress _environment.highContrast = true; } - try - { - if (_coreWindow) - { - _environment.windowVisible = _coreWindow.Visible(); - } - } - catch (...) - { - } - _environment.rendererAvailable = _renderState.Tier() != RenderTier::Disabled; _updatePalette(); } @@ -847,23 +821,40 @@ namespace winTerm::VisualProgress { WU::Color track; WU::Color running; + WU::Color waiting; + WU::Color success; + WU::Color error; WU::Color hot = _color(RainbowArcVisualConstants::WhiteHot); if (_environment.highContrast && _uiSettings) { const auto foreground = _uiSettings.GetColorValue(WUVM::UIColorType::Foreground); track = _withAlpha(foreground, 110); running = foreground; + waiting = foreground; + success = foreground; + error = foreground; hot = foreground; } else { const auto light = _isLightTheme(); track = _color(light ? RainbowArcVisualConstants::LightTrack : RainbowArcVisualConstants::DarkTrack); - running = _color(RainbowArcVisualConstants::RunningSolid); + running = _color(light ? RainbowArcVisualConstants::LightRunningSolid : RainbowArcVisualConstants::RunningSolid); + waiting = _color(light ? RainbowArcVisualConstants::LightWaitingSolid : RainbowArcVisualConstants::WaitingSolid); + success = _color(light ? RainbowArcVisualConstants::LightSuccessSolid : RainbowArcVisualConstants::SuccessSolid); + error = _color(light ? RainbowArcVisualConstants::LightErrorSolid : RainbowArcVisualConstants::ErrorSolid); } + const auto errorColorChanged = !_sameColor(_errorColor, error); + if (errorColorChanged && _headRoot) + { + _refreshErrorHeadBrushes(error); + } _trackColor = track; _runningColor = running; + _waitingColor = waiting; + _successColor = success; + _errorColor = error; _hotColor = hot; if (_trackBrush) { @@ -913,6 +904,8 @@ namespace winTerm::VisualProgress { try { + const auto previousTrackWidth = _trackWidth; + const auto previousTrackY = _trackY; 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)); @@ -948,6 +941,12 @@ namespace winTerm::VisualProgress } } + if (std::fabs(previousTrackWidth - _trackWidth) > 0.01f || + std::fabs(previousTrackY - _trackY) > 0.01f) + { + _restartGeometryAnimations(); + } + _updateFallback(_renderState.CurrentProgress(_now()), _snapshot.mode, _snapshot.status); _showFallback(_renderState.Tier() == RenderTier::Solid || !_root || @@ -959,6 +958,32 @@ namespace winTerm::VisualProgress } } + void _restartGeometryAnimations() noexcept + { + try + { + // Gradient motion is relative, but rebinding it here keeps all + // continuous geometry work on the same post-layout generation. + _setRainbowMovement(false); + if (_indeterminateRunning) + { + if (_cometTail) + { + _cometTail.StopAnimation(L"Offset"); + } + if (_headRoot) + { + _headRoot.StopAnimation(L"Offset"); + _headRoot.StopAnimation(L"Opacity"); + } + } + } + catch (...) + { + } + _indeterminateRunning = false; + } + void _applyWithDegradation(RenderTransitionPlan plan, const bool semanticUpdate) noexcept { for (uint8_t attempt = 0; attempt < 5 && !_closed; ++attempt) @@ -1041,6 +1066,10 @@ namespace winTerm::VisualProgress _clearTerminalBatch(); _stopStatusAnimations(); _setErrorHeadTreatment(false); + if (_trackBrush) + { + _trackBrush.Color(_trackColor); + } if (terminalFade) { @@ -1100,6 +1129,15 @@ namespace winTerm::VisualProgress { _stopContinuousAnimations(); _releaseAllSparks(); + if (plan.errorWithoutProgress) + { + _showSolidFill(0.0f, ProgressMode::Determinate, _statusColor(ProgressStatus::Error)); + if (_trackBrush) + { + _trackBrush.Color(_statusColor(ProgressStatus::Error)); + } + return; + } if (_renderState.Tier() == RenderTier::StaticGradient && !_environment.highContrast && plan.status == ProgressStatus::Running) { if (plan.mode == ProgressMode::Indeterminate) @@ -1244,7 +1282,14 @@ namespace winTerm::VisualProgress { _stopContinuousAnimations(); _releaseAllSparks(); - _showSolidFill(plan.targetProgress, ProgressMode::Determinate, _statusColor(ProgressStatus::Error)); + if (plan.errorWithoutProgress) + { + _showErrorWithoutProgress(); + } + else + { + _showSolidFill(plan.targetProgress, ProgressMode::Determinate, _statusColor(ProgressStatus::Error)); + } _setErrorHeadTreatment(true); if (plan.errorPulse && _headRoot && _headRoot.IsVisible()) { @@ -1252,6 +1297,24 @@ namespace winTerm::VisualProgress } } + void _showErrorWithoutProgress() + { + // Preserve the real zero value: the track carries status while a + // head parked at the zero boundary supplies the one-shot pulse. + // No filled width is fabricated. + _showSolidFill(0.0f, ProgressMode::Determinate, _statusColor(ProgressStatus::Error)); + if (_trackBrush) + { + _trackBrush.Color(_statusColor(ProgressStatus::Error)); + } + if (_headRoot && _renderState.Tier() < RenderTier::StaticGradient) + { + _headRoot.Offset({ RainbowArcVisualConstants::HorizontalInset + RainbowArcVisualConstants::TrackCornerRadius, _headY, 0.0f }); + _headRoot.IsVisible(true); + _headRoot.Opacity(1.0f); + } + } + void _setErrorHeadTreatment(const bool enabled) { if (!_outerBloom || !_innerGlow || !_headTrail) @@ -1263,6 +1326,22 @@ namespace winTerm::VisualProgress _headTrail.Brush(enabled ? _errorTrailBrush : _headTrailBrush); } + void _refreshErrorHeadBrushes(const WU::Color color) + { + // Rebuild only when the effective theme color changes. These + // brushes remain cached across progress updates and frames. + auto outer = _createRadialBrush(_withAlpha(color, 115), 0); + auto inner = _createRadialBrush(_withAlpha(color, 220), 0); + auto trail = _createStatusTrailBrush(color); + _errorOuterBloomBrush = outer; + _errorInnerGlowBrush = inner; + _errorTrailBrush = trail; + if (_snapshot.status == ProgressStatus::Error) + { + _setErrorHeadTreatment(true); + } + } + void _renderCancelled(const RenderTransitionPlan& plan) { _stopContinuousAnimations(); @@ -1817,7 +1896,10 @@ namespace winTerm::VisualProgress 0.0, 0.0, RainbowArcVisualConstants::BottomInset)); - _fallbackTrackBrush.Color(_trackColor); + const auto errorWithoutProgress = status == ProgressStatus::Error && + mode == ProgressMode::Determinate && + progress <= 0.0001f; + _fallbackTrackBrush.Color(errorWithoutProgress ? _statusColor(status) : _trackColor); _fallbackFillBrush.Color(_statusColor(status)); _fallbackFill.Visibility(width > 0.0f ? WUX::Visibility::Visible : WUX::Visibility::Collapsed); } @@ -1835,11 +1917,11 @@ namespace winTerm::VisualProgress switch (status) { case ProgressStatus::Waiting: - return _color(RainbowArcVisualConstants::WaitingSolid); + return _waitingColor; case ProgressStatus::Success: - return _color(RainbowArcVisualConstants::SuccessSolid); + return _successColor; case ProgressStatus::Error: - return _color(RainbowArcVisualConstants::ErrorSolid); + return _errorColor; case ProgressStatus::Running: case ProgressStatus::Cancelled: default: @@ -2023,27 +2105,24 @@ namespace winTerm::VisualProgress 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 _waitingColor{ _color(RainbowArcVisualConstants::WaitingSolid) }; + WU::Color _successColor{ _color(RainbowArcVisualConstants::SuccessSolid) }; + WU::Color _errorColor{ _color(RainbowArcVisualConstants::ErrorSolid) }; WU::Color _hotColor{ _color(RainbowArcVisualConstants::WhiteHot) }; uint64_t _lastBurstSequence{}; float _trackWidth{}; diff --git a/src/winterm/VisualProgress/RainbowArcVisualConstants.h b/src/winterm/VisualProgress/RainbowArcVisualConstants.h index be43242ad..a87400317 100644 --- a/src/winterm/VisualProgress/RainbowArcVisualConstants.h +++ b/src/winterm/VisualProgress/RainbowArcVisualConstants.h @@ -79,6 +79,13 @@ namespace winTerm::VisualProgress::RainbowArcVisualConstants inline constexpr uint32_t WaitingSolid{ 0xFFF4C95Du }; inline constexpr uint32_t SuccessSolid{ 0xFF4DDC88u }; inline constexpr uint32_t ErrorSolid{ 0xFFFF6B6Bu }; + // Light-surface status colors mirror the app theme resources in App.xaml. + // The brighter dark-surface palette does not provide sufficient contrast + // for Reduced Motion and other solid fallbacks on a light background. + inline constexpr uint32_t LightRunningSolid{ 0xFF087A63u }; + inline constexpr uint32_t LightWaitingSolid{ 0xFF8A5D00u }; + inline constexpr uint32_t LightSuccessSolid{ 0xFF087A42u }; + inline constexpr uint32_t LightErrorSolid{ 0xFFB42318u }; inline constexpr uint32_t WarmWhite{ 0xFFFFF2B3u }; inline constexpr uint32_t WhiteHot{ 0xFFFFFFFFu }; inline constexpr uint32_t SparkYellow{ 0xFFFFD166u }; diff --git a/src/winterm/VisualProgress/VisualProgressRenderModel.h b/src/winterm/VisualProgress/VisualProgressRenderModel.h index b5a9a30c7..befe8526b 100644 --- a/src/winterm/VisualProgress/VisualProgressRenderModel.h +++ b/src/winterm/VisualProgress/VisualProgressRenderModel.h @@ -113,6 +113,7 @@ namespace winTerm::VisualProgress bool successSweep{}; bool finalSparkBurst{}; bool errorPulse{}; + bool errorWithoutProgress{}; bool fadeOut{}; bool sparksEligible{}; bool staticFallback{}; @@ -196,7 +197,11 @@ namespace winTerm::VisualProgress case ProgressStatus::Error: plan.kind = RenderTransitionKind::Error; plan.targetProgress = _meaningfulProgress(snapshot, current); - plan.headVisible = snapshot.mode == ProgressMode::Determinate && plan.targetProgress > 0.0f; + plan.errorWithoutProgress = plan.targetProgress <= ProgressEpsilon; + // A zero value remains a real zero. The renderer may present a + // status-only head/track treatment, but must not invent fill. + plan.headVisible = snapshot.mode == ProgressMode::Determinate && + (plan.targetProgress > 0.0f || plan.errorWithoutProgress); plan.errorPulse = motionAllowed; plan.duration = std::chrono::milliseconds::zero(); break; @@ -270,11 +275,15 @@ namespace winTerm::VisualProgress plan.rainbowMoving = motionAllowed && _snapshot.status == ProgressStatus::Running; plan.indeterminateMoving = plan.rainbowMoving && _snapshot.mode == ProgressMode::Indeterminate; plan.breathe = motionAllowed && _snapshot.status == ProgressStatus::Waiting; + plan.errorWithoutProgress = _snapshot.status == ProgressStatus::Error && + _targetProgress <= ProgressEpsilon; plan.sparksEligible = motionAllowed && environment.paneActive && _snapshot.status == ProgressStatus::Running && _tier == RenderTier::Full; - plan.headVisible = _snapshot.mode == ProgressMode::Indeterminate || _targetProgress > 0.0f; + plan.headVisible = _snapshot.mode == ProgressMode::Indeterminate || + _targetProgress > 0.0f || + plan.errorWithoutProgress; if (!plan.visible) { plan.releaseAfterTransition = true;