From 4f91f84f3f8f648e2dfc33693c651b6f0ff45c85 Mon Sep 17 00:00:00 2001 From: devjenjen Date: Mon, 26 Oct 2026 18:53:58 +0100 Subject: [PATCH 1/9] feat(hud): rainbow rim around pill while transcribing --- .../src/Murmur.App/Styles/MurmurTokens.axaml | 15 ++++ windows/src/Murmur.App/Views/HudWindow.axaml | 31 ++++++++- .../src/Murmur.App/Views/HudWindow.axaml.cs | 69 +++++++++++++++++++ .../Murmur.Platform.Windows/Native/Win32.cs | 11 +++ .../tests/Murmur.App.Tests/HudWindowTests.cs | 67 ++++++++++++++++++ 5 files changed, 192 insertions(+), 1 deletion(-) diff --git a/windows/src/Murmur.App/Styles/MurmurTokens.axaml b/windows/src/Murmur.App/Styles/MurmurTokens.axaml index 59a38f4..1bafe6e 100644 --- a/windows/src/Murmur.App/Styles/MurmurTokens.axaml +++ b/windows/src/Murmur.App/Styles/MurmurTokens.axaml @@ -872,6 +872,21 @@ + + + + - - @@ -899,4 +909,8 @@ + + diff --git a/windows/src/Murmur.App/Views/HudWindow.axaml b/windows/src/Murmur.App/Views/HudWindow.axaml index 926cdcc..c4e1d5d 100644 --- a/windows/src/Murmur.App/Views/HudWindow.axaml +++ b/windows/src/Murmur.App/Views/HudWindow.axaml @@ -24,37 +24,52 @@ HorizontalAlignment="Center" VerticalAlignment="Center" Background="{DynamicResource HudChassisBrush}" - BorderBrush="{DynamicResource HudHairlineBrush}" + BorderBrush="{DynamicResource HudSpecularHairlineBrush}" BorderThickness="{DynamicResource HudBorderThickness}" CornerRadius="{DynamicResource HudOrbRadius}" RenderTransformOrigin="50%,50%"> - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + @@ -74,11 +89,17 @@ Orientation="Horizontal" Spacing="{DynamicResource HudLampBadgeSpacing}" VerticalAlignment="Center"> - + + + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == IsVisibleProperty && HudMeter is not null) + { + HudMeter.IsVisible = IsVisible; + if (!IsVisible) StopHiddenEffects(); + } + } + + private void StopHiddenEffects() + { + _entryGeneration++; + CancelContentReveal(); + CancelPendingHide(); + HudSurface.Classes.Set("motionOff", true); + HudContentSurface.Classes.Set("motionOff", true); + TranscriptText.Classes.Set("motionOff", true); + RecordingRim.Classes.Set("motionOff", true); + RainbowRim.Classes.Set("motionOff", true); + RecordingRim.Classes.Set("active", false); + StopProcessingAnimation(); + HudMeter.ResetBallistics(); + } + + private bool ApplyMotionPreference(DictationStatus status) + { + var reduced = IsReducedMotion(); + var changed = reduced != _reducedMotion; + _reducedMotion = reduced; + HudMeter.ReducedMotion = reduced; + var instant = reduced || status == DictationStatus.Error; + HudSurface.Classes.Set("motionOff", instant); + HudContentSurface.Classes.Set("motionOff", instant); + TranscriptText.Classes.Set("motionOff", instant); + if (reduced) + { + _entryGeneration++; + CancelContentReveal(); + HudSurface.Opacity = 1; + HudSurface.RenderTransform = TransformScale1; + SetContentVisible(status != DictationStatus.Idle); + } + var instantRims = reduced || status is DictationStatus.Starting or DictationStatus.Error; + RecordingRim.Classes.Set("motionOff", instantRims); + RainbowRim.Classes.Set("motionOff", instantRims); + RecordingRim.Classes.Set("active", status == DictationStatus.Listening); + return changed; + } + private void OnLayoutUpdated(object? sender, EventArgs e) { ApplyNativeRegion(); @@ -196,7 +247,8 @@ private void PositionForForegroundWindow() private IBrush GetBrush(string key, IBrush fallback) { - if (this.TryFindResource(key, out var resource) && resource is IBrush brush) + if ((this.TryFindResource(key, ActualThemeVariant, out var resource) || + this.TryFindResource(key, out resource)) && resource is IBrush brush) { return brush; } @@ -320,6 +372,11 @@ private void CancelContentReveal() private void ScheduleContentReveal() { CancelContentReveal(); + if (_reducedMotion) + { + SetContentVisible(true); + return; + } var timer = new DispatcherTimer { @@ -352,7 +409,11 @@ private void StartProcessingAnimation() { ProcessingDots.IsVisible = true; _processingPhase = 0; + ProcessingDot1.Opacity = ProcessingDot2.Opacity = ProcessingDot3.Opacity = 0.35; _processingTimer?.Stop(); + _processingTimer = null; + StartRainbowRim(); + if (_reducedMotion) return; _processingTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds( @@ -360,7 +421,6 @@ private void StartProcessingAnimation() }; _processingTimer.Tick += OnProcessingTick; _processingTimer.Start(); - StartRainbowRim(); } private void OnProcessingTick(object? sender, EventArgs e) @@ -380,7 +440,7 @@ private static void SetDotOpacity(Ellipse dot, double phase, double offset) private void StartRainbowRim() { RainbowRim.Classes.Set("active", true); - _rainbowAnimating = !IsReducedMotion(); + _rainbowAnimating = !_reducedMotion; if (_rainbowAnimating) { RequestRainbowFrame(); @@ -480,6 +540,7 @@ private static string BuildErrorText(string? message, string? transcript) private void BeginOrbEntry() { + var generation = ++_entryGeneration; ApplyPlacementAlignment(); CancelContentReveal(); @@ -492,12 +553,12 @@ private void BeginOrbEntry() diameter, diameter, orbRadius, - GetBrush("HudHairlineBrush", Brushes.Gray)); + GetBrush("HudSpecularHairlineBrush", Brushes.Gray)); SetContentVisible(false); - HudSurface.Opacity = 0; - HudSurface.RenderTransform = TransformScaleOrbStart; + HudSurface.Opacity = _reducedMotion ? 1 : 0; + HudSurface.RenderTransform = _reducedMotion ? TransformScale1 : TransformScaleOrbStart; if (!IsVisible) { @@ -505,10 +566,16 @@ private void BeginOrbEntry() Show(); } + if (_reducedMotion) + { + SetContentVisible(true); + return; + } + Dispatcher.UIThread.Post(() => { - if (!IsVisible || - _lastStatus == DictationStatus.Idle) + if (generation != _entryGeneration || !IsVisible || + _lastStatus is DictationStatus.Idle or DictationStatus.Error) { return; } @@ -529,6 +596,13 @@ private void CancelPendingHide() private void ScheduleHide() { + CancelPendingHide(); + if (_reducedMotion) + { + SetContentVisible(false); + Hide(); + return; + } var generation = _hideGeneration; var hideTimer = new DispatcherTimer { @@ -602,6 +676,7 @@ public void UpdateLevel(DictationStatus status, float level) { if (Dispatcher.UIThread.CheckAccess()) { + if (!IsVisible || status != _lastStatus) return; HudMeter.Level = Math.Clamp(level, 0.0f, 1.0f); HudMeter.IsActive = status == DictationStatus.Listening; return; @@ -615,6 +690,7 @@ public void UpdateLevel(DictationStatus status, float level) var lvl = _pendingLevel; var st = _pendingLevelStatus; Interlocked.Exchange(ref _levelUpdatePending, 0); + if (!IsVisible || st != _lastStatus) return; HudMeter.Level = Math.Clamp(lvl, 0.0f, 1.0f); HudMeter.IsActive = st == DictationStatus.Listening; @@ -629,13 +705,17 @@ public void UpdateState(DictationState state, float level, string transcript) void ApplyState() { + var motionChanged = ApplyMotionPreference(state.Status); HudMeter.Level = Math.Clamp(level, 0.0f, 1.0f); HudMeter.IsActive = state.Status == DictationStatus.Listening; + if (state.Status is not (DictationStatus.Listening or DictationStatus.Finishing)) + HudMeter.ResetBallistics(); if (state.Status == DictationStatus.Idle) { CancelContentReveal(); StopProcessingAnimation(); + _entryGeneration++; _lastStatus = DictationStatus.Idle; _lastTranscript = null; @@ -646,7 +726,7 @@ void ApplyState() GetDouble("HudOrbDiameter", 52), GetDouble("HudArmedHeight", 44), GetCornerRadius("HudOrbRadius", new CornerRadius(26)), - GetBrush("HudHairlineBrush", Brushes.Gray)); + GetBrush("HudSpecularHairlineBrush", Brushes.Gray)); SetContentTarget( GetDouble("HudArmedWidth", 245), GetDouble("HudArmedHeight", 44)); @@ -674,7 +754,7 @@ void ApplyState() _lastStatus is null || _lastStatus == DictationStatus.Idle; - if (!statusChanged && !transcriptChanged && !errorChanged && !entering) + if (!statusChanged && !transcriptChanged && !errorChanged && !entering && !motionChanged) { return; } @@ -685,9 +765,8 @@ _lastStatus is null || var idleLamp = GetBrush("HudIdleLampBrush", Brushes.DarkGray); var recordingEdge = GetBrush("HudRecordingEdgeBrush", Brushes.Red); - var processingBrush = GetBrush("HudProcessingBrush", Brushes.DodgerBlue); var errorBrush = GetBrush("HudErrorBrush", Brushes.DarkOrange); - var hairlineBrush = GetBrush("HudHairlineBrush", Brushes.Gray); + var hairlineBrush = GetBrush("HudSpecularHairlineBrush", Brushes.Gray); if (entering) { @@ -732,13 +811,13 @@ _lastStatus is null || GetDouble("HudRecordingMaxWidth", 416)); var recordingHeight = GetDouble("HudArmedHeight", 44); - var recordingRadius = GetCornerRadius("HudRadiusRecording", new CornerRadius(26)); + var recordingRadius = GetCornerRadius("HudRadiusRecording", new CornerRadius(22)); var widthStep = GetDouble("HudTranscriptWidthStep", 12); if (_recordingWidth <= 0 || Math.Abs(desiredWidth - _recordingWidth) >= widthStep) { _recordingWidth = desiredWidth; - SetSurfaceTarget(_recordingWidth, recordingHeight, recordingRadius, recordingEdge); + SetSurfaceTarget(_recordingWidth, recordingHeight, recordingRadius, hairlineBrush); SetContentTarget(_recordingWidth, recordingHeight); } break; @@ -755,22 +834,22 @@ _lastStatus is null || ? UiText.HudTranscribing : preview); - if (statusChanged) + if (statusChanged || entering || motionChanged) { StartProcessingAnimation(); } SetSurfaceTarget( GetDouble("HudProcessingWidth", 301), - GetDouble("HudProcessingHeight", 38), + GetDouble("HudProcessingHeight", 44), GetCornerRadius( "HudRadiusProcessing", - new CornerRadius(19)), - processingBrush); + new CornerRadius(22)), + hairlineBrush); SetContentTarget( GetDouble("HudProcessingWidth", 301), - GetDouble("HudProcessingHeight", 38)); + GetDouble("HudProcessingHeight", 44)); AutomationProperties.SetLiveSetting( TranscriptText, @@ -779,8 +858,11 @@ _lastStatus is null || break; case DictationStatus.Error: + _entryGeneration++; CancelContentReveal(); StopProcessingAnimation(); + HudSurface.Opacity = 1; + HudSurface.RenderTransform = TransformScale1; _recordingWidth = 0; RecordLamp.Fill = errorBrush; diff --git a/windows/tests/Murmur.App.Tests/SettingsWindowTests.cs b/windows/tests/Murmur.App.Tests/SettingsWindowTests.cs index fa86026..0485183 100644 --- a/windows/tests/Murmur.App.Tests/SettingsWindowTests.cs +++ b/windows/tests/Murmur.App.Tests/SettingsWindowTests.cs @@ -641,12 +641,13 @@ public void Axaml_ContainsNoGradientsPurpleOrIllegalRed() if (relativeName.Equals("Styles\\MurmurTokens.axaml", StringComparison.OrdinalIgnoreCase)) { - foreach (var key in new[] { "LensPrimaryBrush", "LensProgressBrush", "LensSpecularBrush" }) + foreach (var key in new[] { "LensPrimaryBrush", "LensProgressBrush", "LensSpecularBrush", "HudSpecularHairlineBrush" }) { content.ShouldContain($"x:Key=\"{key}\""); } - content.Split("(HudWindow hud, string name) where T : Control => hud.FindControl(name)!; + + private static object? Field(object instance, string name) => instance.GetType() + .GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(instance); + + [AvaloniaFact] + public async Task LateLevelUpdates_CannotUndoLatestLifecycleState() + { + var hud = new HudWindow { IsReducedMotion = () => false }; + try + { + hud.UpdateState(DictationState.Listening, .6f, "words"); + hud.UpdateState(DictationState.ErrorState("lost"), 0, "words"); + hud.UpdateLevel(DictationStatus.Listening, .9f); + var meter = Part(hud, "HudMeter"); + meter.IsActive.ShouldBeFalse(); + meter.IsAnimationRunning.ShouldBeFalse(); + await Task.Run(() => hud.UpdateLevel(DictationStatus.Listening, .9f)); + Dispatcher.UIThread.RunJobs(); + meter.IsActive.ShouldBeFalse(); + meter.IsAnimationRunning.ShouldBeFalse(); + hud.UpdateState(DictationState.Listening, .8f, "again"); + hud.UpdateLevel(DictationStatus.Finishing, 0); + meter.IsActive.ShouldBeTrue(); + meter.Level.ShouldBe(.8f); + hud.Hide(); + hud.UpdateLevel(DictationStatus.Listening, .9f); + meter.IsAnimationRunning.ShouldBeFalse(); + meter.CurrentPosition.ShouldBe(0); + } + finally { hud.Close(); } + } + + [AvaloniaFact] + public void RapidEntryAndRestart_RetainLatestTextPlacementAndCancelOldHide() + { + var hud = new HudWindow { IsReducedMotion = () => false }; + try + { + hud.SetPlacement("bottom-right"); + hud.UpdateState(DictationState.Starting, 0, ""); + hud.UpdateState(DictationState.Listening, .6f, "first"); + hud.UpdateState(DictationState.Finishing, 0, "first"); + hud.UpdateState(DictationState.Idle, 0, ""); + hud.UpdateState(DictationState.Listening, .8f, "latest"); + Dispatcher.UIThread.RunJobs(); + hud.IsVisible.ShouldBeTrue(); + Part(hud, "TranscriptText").Text.ShouldBe("latest"); + Part(hud, "RecordingRim").Classes.ShouldContain("active"); + Part(hud, "RainbowRim").Classes.ShouldNotContain("active"); + Part(hud, "HudSurface").HorizontalAlignment.ShouldBe(Avalonia.Layout.HorizontalAlignment.Right); + Part(hud, "HudContentSurface").VerticalAlignment.ShouldBe(Avalonia.Layout.VerticalAlignment.Bottom); + Field(hud, "_hideTimer").ShouldBeNull(); + Field(hud, "_processingTimer").ShouldBeNull(); + hud.IsRainbowAnimating.ShouldBeFalse(); + } + finally { hud.Close(); } + } + + // Avalonia's clock is internal; drive its real transition engine without wall-clock sleeps. + private sealed class ManualClock + { + private readonly object _clock = Activator.CreateInstance( + typeof(Animatable).Assembly.GetType("Avalonia.Animation.Clock")!, nonPublic: true)!; + public void Attach(Animatable target) => typeof(Animatable) + .GetProperty("Clock", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(target, _clock); + public void Advance(double seconds) => _clock.GetType().BaseType! + .GetMethod("Pulse", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)! + .Invoke(_clock, [TimeSpan.FromSeconds(seconds)]); + } + + [AvaloniaFact] + public void RimFade_ReversesFromCurrentOpacityAndErrorAbortsImmediately() + { + var hud = new HudWindow { IsReducedMotion = () => false }; + try + { + hud.UpdateState(DictationState.Starting, 0, ""); + Dispatcher.UIThread.RunJobs(); + var recording = Part(hud, "RecordingRim"); + var rainbow = Part(hud, "RainbowRim"); + var clock = new ManualClock(); + clock.Attach(recording); + clock.Attach(rainbow); + hud.UpdateState(DictationState.Listening, .5f, "words"); + clock.Advance(0); + clock.Advance(.2); + recording.Opacity.ShouldBe(1, .0001); + hud.UpdateState(DictationState.Finishing, 0, "words"); + clock.Advance(.2); + clock.Advance(.25); + recording.Opacity.ShouldBeInRange(.01, .99); + rainbow.Opacity.ShouldBeInRange(.01, .99); + (recording.Opacity + rainbow.Opacity).ShouldBe(1, .0001); + var recOpacity = recording.Opacity; + var rainbowOpacity = rainbow.Opacity; + hud.UpdateState(DictationState.Listening, .8f, "again"); + recording.Opacity.ShouldBe(recOpacity, .0001); + rainbow.Opacity.ShouldBe(rainbowOpacity, .0001); + clock.Advance(.25); + clock.Advance(.45); + recording.Opacity.ShouldBe(1, .0001); + rainbow.Opacity.ShouldBe(0, .0001); + hud.UpdateState(DictationState.Finishing, 0, "again"); + hud.UpdateState(DictationState.ErrorState("device lost"), 0, "again"); + Dispatcher.UIThread.RunJobs(); + recording.Opacity.ShouldBe(0); + rainbow.Opacity.ShouldBe(0); + hud.IsRainbowAnimating.ShouldBeFalse(); + Field(hud, "_processingTimer").ShouldBeNull(); + Field(hud, "_contentRevealTimer").ShouldBeNull(); + Part(hud, "TranscriptText").Text!.ShouldContain("device lost"); + Part(hud, "HudContentSurface").Opacity.ShouldBeGreaterThan(0); + } + finally { hud.Close(); } + } + + [AvaloniaFact] + public void Lifecycle_StopsLoopsOnStartingIdleErrorHiddenAndClose() + { + foreach (var ending in new[] { "starting", "idle", "error", "hidden", "close" }) + { + var hud = new HudWindow { IsReducedMotion = () => false }; + try + { + hud.UpdateState(DictationState.Listening, .8f, "words"); + Dispatcher.UIThread.RunJobs(); + var meter = Part(hud, "HudMeter"); + meter.StepBallistics(.09); + var peak = meter.CurrentPosition; + hud.UpdateState(DictationState.Finishing, 0, "words"); + meter.IsActive.ShouldBeFalse(); + meter.StepBallistics(.4); + meter.CurrentPosition.ShouldBeLessThan(peak * .06); + Field(hud, "_processingTimer").ShouldBeOfType().IsEnabled.ShouldBeTrue(); + hud.IsRainbowAnimating.ShouldBeTrue(); + switch (ending) + { + case "starting": hud.UpdateState(DictationState.Starting, 0, ""); break; + case "idle": hud.UpdateState(DictationState.Idle, 0, ""); break; + case "error": hud.UpdateState(DictationState.ErrorState("failed"), 0, ""); break; + case "hidden": hud.Hide(); break; + case "close": hud.Close(); break; + } + var angle = ((ConicGradientBrush)Part(hud, "RainbowRim").Resources["HudRainbowBrush"]!).Angle; + AvaloniaHeadlessPlatform.ForceRenderTimerTick(3); + Dispatcher.UIThread.RunJobs(); + hud.IsRainbowAnimating.ShouldBeFalse(); + Field(hud, "_rainbowFrameRequested").ShouldBe(false); + Field(hud, "_processingTimer").ShouldBeNull(); + meter.IsAnimationRunning.ShouldBeFalse(); + meter.CurrentPosition.ShouldBe(0); + Part(hud, "ProcessingDots").IsVisible.ShouldBeFalse(); + ((ConicGradientBrush)Part(hud, "RainbowRim").Resources["HudRainbowBrush"]!).Angle.ShouldBe(angle); + if (ending is "error" or "hidden" or "close") + { + Field(hud, "_contentRevealTimer").ShouldBeNull(); + Field(hud, "_hideTimer").ShouldBeNull(); + } + } + finally { hud.Close(); } + } + } + + [AvaloniaFact] + public void MotionPreferenceChange_DuringFinishingStopsDotsRotationAndTransitions() + { + var reduced = false; + var reads = 0; + var hud = new HudWindow { IsReducedMotion = () => { reads++; return reduced; } }; + try + { + hud.UpdateState(DictationState.Finishing, 0, "words"); + Dispatcher.UIThread.RunJobs(); + var initialReads = reads; + AvaloniaHeadlessPlatform.ForceRenderTimerTick(3); + Dispatcher.UIThread.RunJobs(); + reads.ShouldBe(initialReads); + reduced = true; + hud.UpdateState(DictationState.Finishing, 0, "words"); + AvaloniaHeadlessPlatform.ForceRenderTimerTick(3); + Dispatcher.UIThread.RunJobs(); + var rim = Part(hud, "RainbowRim"); + rim.Opacity.ShouldBe(1); + rim.Transitions.ShouldBeNull(); + hud.IsRainbowAnimating.ShouldBeFalse(); + Field(hud, "_rainbowFrameRequested").ShouldBe(false); + Field(hud, "_processingTimer").ShouldBeNull(); + Field(hud, "_contentRevealTimer").ShouldBeNull(); + Part(hud, "HudContentSurface").Opacity.ShouldBe(1); + ((ConicGradientBrush)rim.Resources["HudRainbowBrush"]!).Angle.ShouldBe(-90); + reduced = false; + hud.UpdateState(DictationState.Finishing, 0, "words"); + rim.Transitions.ShouldNotBeNull(); + hud.IsRainbowAnimating.ShouldBeTrue(); + Field(hud, "_processingTimer").ShouldBeOfType().IsEnabled.ShouldBeTrue(); + } + finally { hud.Close(); } + } + + [AvaloniaFact] + public void SettingsMeter_KeepsLegacyBallisticsAndTimerWhileHudReducedMotionSnaps() + { + var settings = new VUMeterControl { ReducedMotion = true, IsActive = true, Level = .75f }; + var window = new Window { Content = settings }; + try + { + window.Show(); + settings.IsAnimationRunning.ShouldBeTrue(); + settings.CurrentPosition.ShouldBe(0); + typeof(VUMeterControl).GetMethod("AdvanceNeedle", BindingFlags.NonPublic | BindingFlags.Instance)!.Invoke(settings, null); + settings.CurrentPosition.ShouldBe(.288, .000001); + settings.HoldRemainingSeconds.ShouldBe(0); + var hud = new VUMeterControl { UseHudPalette = true, ReducedMotion = true, IsActive = true, Level = .75f }; + hud.CurrentPosition.ShouldBe(.75); + hud.Level = .25f; + hud.CurrentPosition.ShouldBe(.25); + hud.HoldRemainingSeconds.ShouldBe(0); + hud.IsAnimationRunning.ShouldBeFalse(); + } + finally { window.Close(); } + } + + [AvaloniaFact] + public void RecordingAndFinishing_ShareHeightRadiusAndRimGeometry() + { + var hud = new HudWindow { IsReducedMotion = () => true }; + try + { + hud.UpdateState(DictationState.Listening, .7f, "words"); + var surface = Part(hud, "HudSurface"); + var radius = surface.CornerRadius; + surface.Height.ShouldBe(44); + hud.UpdateState(DictationState.Finishing, 0, "words"); + surface.Height.ShouldBe(44); + surface.CornerRadius.ShouldBe(radius); + Part(hud, "HudContentSurface").Height.ShouldBe(44); + var recording = Part(hud, "RecordingRim"); + var rainbow = Part(hud, "RainbowRim"); + recording.ShouldNotBeNull(); + recording.Margin.ShouldBe(rainbow.Margin); + recording.Children.OfType().Select(b => b.BorderThickness) + .ShouldBe(rainbow.Children.OfType().Select(b => b.BorderThickness)); + } + finally { hud.Close(); } + } + + [AvaloniaFact] + public void ReducedMotion_UsesImmediatePropertiesAndNoMeterDotsOrRimLoops() + { + var hud = new HudWindow { IsReducedMotion = () => true }; + try + { + foreach (var state in new[] { DictationState.Starting, DictationState.Listening, DictationState.Finishing }) + { + hud.UpdateState(state, .75f, "words"); + Dispatcher.UIThread.RunJobs(); + var meter = Part(hud, "HudMeter"); + meter.IsAnimationRunning.ShouldBeFalse(); + meter.CurrentPosition.ShouldBe(state.Status == DictationStatus.Listening ? .75 : 0); + meter.HoldRemainingSeconds.ShouldBe(0); + Part(hud, "RecordingRim").Opacity.ShouldBe(state.Status == DictationStatus.Listening ? 1 : 0); + Part(hud, "RainbowRim").Opacity.ShouldBe(state.Status == DictationStatus.Finishing ? 1 : 0); + Part(hud, "HudSurface").Opacity.ShouldBe(1); + Part(hud, "HudContentSurface").Opacity.ShouldBe(1); + Part(hud, "HudSurface").Transitions.ShouldBeNull(); + Part(hud, "HudContentSurface").Transitions.ShouldBeNull(); + Part(hud, "TranscriptText").Transitions.ShouldBeNull(); + Part(hud, "RecordingRim").Transitions.ShouldBeNull(); + Part(hud, "RainbowRim").Transitions.ShouldBeNull(); + Field(hud, "_processingTimer").ShouldBeNull(); + Field(hud, "_contentRevealTimer").ShouldBeNull(); + Field(hud, "_rainbowFrameRequested").ShouldBe(false); + } + var brush = (ConicGradientBrush)Part(hud, "RainbowRim").Resources["HudRainbowBrush"]!; + var dots = DotNames + .Select(name => Part(hud, name)).ToArray(); + var opacities = dots.Select(d => d.Opacity).ToArray(); + AvaloniaHeadlessPlatform.ForceRenderTimerTick(5); + Dispatcher.UIThread.RunJobs(); + brush.Angle.ShouldBe(-90); + dots.Select(d => d.Opacity).ShouldBe(opacities); + hud.UpdateState(DictationState.Idle, 0, ""); + hud.IsVisible.ShouldBeFalse(); + Field(hud, "_hideTimer").ShouldBeNull(); + } + finally { hud.Close(); } + } + + [AvaloniaFact] + public void HudBallistics_AttackHoldReleaseAreMonotoneAndTimeStepIndependent() + { + var meter = new VUMeterControl { UseHudPalette = true, IsActive = true, Level = .8f }; + meter.StepBallistics(.09); + meter.CurrentPosition.ShouldBeInRange(.75, .8); + var peak = meter.CurrentPosition; + meter.Level = 0; + meter.StepBallistics(.10); + meter.CurrentPosition.ShouldBe(peak); + meter.HoldRemainingSeconds.ShouldBe(.08, .000001); + var split = new VUMeterControl { UseHudPalette = true, IsActive = true, Level = .8f }; + split.StepBallistics(.09); + split.Level = 0; + split.StepBallistics(.18); + split.StepBallistics(.02); + meter.StepBallistics(.10); + meter.CurrentPosition.ShouldBe(split.CurrentPosition, .000001); + var previous = meter.CurrentPosition; + for (var i = 0; i < 100; i++) + { + meter.StepBallistics(.016); + meter.CurrentPosition.ShouldBeInRange(0, previous); + previous = meter.CurrentPosition; + } + meter.CurrentPosition.ShouldBe(0); + } + + [AvaloniaFact] + public void HudBallistics_NewPeakInterruptsHoldAndInactiveReleasesWithoutSecondHold() + { + var meter = new VUMeterControl { UseHudPalette = true, IsActive = true, Level = .6f }; + meter.StepBallistics(.09); + meter.Level = 0; + meter.StepBallistics(.05); + var held = meter.CurrentPosition; + meter.Level = 1; + meter.StepBallistics(.03); + meter.CurrentPosition.ShouldBeGreaterThan(held); + var peak = meter.CurrentPosition; + meter.IsActive = false; + meter.StepBallistics(.4); + meter.CurrentPosition.ShouldBeLessThan(peak * .06); + meter.HoldRemainingSeconds.ShouldBe(0); + meter.StepBallistics(double.NaN); + meter.StepBallistics(-1); + double.IsFinite(meter.CurrentPosition).ShouldBeTrue(); + } + + [AvaloniaFact] + public void StudioTheme_ReusesRecordRedAndPreservesAmberError() + { + foreach (var theme in new[] { ThemeVariant.Dark, ThemeVariant.Light }) + { + var hud = new HudWindow { RequestedThemeVariant = theme, IsReducedMotion = () => true }; + try + { + hud.UpdateState(DictationState.Listening, .5f, "words"); + var lamp = Part(hud, "RecordLamp"); + lamp.Parent.ShouldBeOfType().BorderThickness.Left.ShouldBeGreaterThan(0); + var rim = Part(hud, "RecordingRim"); + rim.Children.OfType().Last().BorderBrush.ShouldBe(lamp.Fill); + hud.TryFindResource("HudSpecularHairlineBrush", theme, out var edge).ShouldBeTrue(); + Part(hud, "HudSurface").BorderBrush.ShouldBe(edge); + var gradient = edge.ShouldBeAssignableTo(); + gradient!.GradientStops![0].Color.A.ShouldBeGreaterThan(gradient.GradientStops[^1].Color.A); + hud.TryFindResource("HudSolidFallbackBrush", theme, out var fallback).ShouldBeTrue(); + var background = fallback.ShouldBeAssignableTo()!.Color; + var foreground = Part(hud, "TranscriptText").Foreground.ShouldBeAssignableTo()!.Color; + var bg = Luminance(background); + var fg = Luminance(foreground); + ((Math.Max(bg, fg) + .05) / (Math.Min(bg, fg) + .05)).ShouldBeGreaterThan(4.5); + hud.UpdateState(DictationState.Finishing, 0, "words"); + Part(hud, "HudSurface").BorderBrush.ShouldBe(edge); + hud.UpdateState(DictationState.ErrorState("device lost"), 0, "words"); + hud.TryFindResource("HudErrorBrush", theme, out var amber).ShouldBeTrue(); + Part(hud, "HudSurface").BorderBrush.ShouldBe(amber); + lamp.Fill.ShouldBe(amber); + Part(hud, "RainbowRim").Opacity.ShouldBe(0); + } + finally { hud.Close(); } + } + } + + private static double Luminance(Color color) + { + static double Linear(byte channel) + { + var value = channel / 255.0; + return value <= .04045 ? value / 12.92 : Math.Pow((value + .055) / 1.055, 2.4); + } + return .2126 * Linear(color.R) + .7152 * Linear(color.G) + .0722 * Linear(color.B); + } +} From 7cbf59c5a739d4ec22415d41a51e11e8371947e0 Mon Sep 17 00:00:00 2001 From: devjenjen Date: Mon, 26 Oct 2026 21:02:53 +0100 Subject: [PATCH 3/9] feat(hud): pulse recording rim and sharpen meter response --- .../src/Murmur.App/Controls/VUMeterControl.cs | 6 +- .../src/Murmur.App/Styles/MurmurTokens.axaml | 4 +- windows/src/Murmur.App/Views/HudWindow.axaml | 11 +-- .../src/Murmur.App/Views/HudWindow.axaml.cs | 55 ++++++++--- .../tests/Murmur.App.Tests/StudioHudTests.cs | 99 +++++++++++++++++-- 5 files changed, 143 insertions(+), 32 deletions(-) diff --git a/windows/src/Murmur.App/Controls/VUMeterControl.cs b/windows/src/Murmur.App/Controls/VUMeterControl.cs index 3f501bd..12e749d 100644 --- a/windows/src/Murmur.App/Controls/VUMeterControl.cs +++ b/windows/src/Murmur.App/Controls/VUMeterControl.cs @@ -355,7 +355,7 @@ internal void StepBallistics(double dtSeconds) var rising = target >= _position; if (rising) { - _holdRemaining = .180; + _holdRemaining = .040; } else { @@ -365,8 +365,8 @@ internal void StepBallistics(double dtSeconds) dtSeconds -= heldTime; } - // Exact exponential response: 95% in 90/400ms, monotone even after a delayed frame. - _position = target + (_position - target) * Math.Exp(-3 * dtSeconds / (rising ? .090 : .400)); + // The controller already smooths input; keep the HUD's extra response short and monotone. + _position = target + (_position - target) * Math.Exp(-3 * dtSeconds / (rising ? .035 : .160)); if (Math.Abs(target - _position) < .001) { _position = target; diff --git a/windows/src/Murmur.App/Styles/MurmurTokens.axaml b/windows/src/Murmur.App/Styles/MurmurTokens.axaml index 5ec6fd6..7e02336 100644 --- a/windows/src/Murmur.App/Styles/MurmurTokens.axaml +++ b/windows/src/Murmur.App/Styles/MurmurTokens.axaml @@ -86,7 +86,7 @@ - + @@ -214,7 +214,7 @@ - + diff --git a/windows/src/Murmur.App/Views/HudWindow.axaml b/windows/src/Murmur.App/Views/HudWindow.axaml index c4e1d5d..dc01594 100644 --- a/windows/src/Murmur.App/Views/HudWindow.axaml +++ b/windows/src/Murmur.App/Views/HudWindow.axaml @@ -33,14 +33,13 @@ Classes="hudRecordingRim" Margin="-1" IsHitTestVisible="False"> - - + + BorderBrush="{DynamicResource HudRecordingEdgeBrush}" />