diff --git a/DesktopClock.Tests/PixelShifterTests.cs b/DesktopClock.Tests/PixelShifterTests.cs index d8ae5d2..770adb4 100644 --- a/DesktopClock.Tests/PixelShifterTests.cs +++ b/DesktopClock.Tests/PixelShifterTests.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using DesktopClock.Utilities; namespace DesktopClock.Tests; @@ -7,249 +6,90 @@ namespace DesktopClock.Tests; public class PixelShifterTests { [Theory] - [InlineData(5, 10)] // Evenly divisible. - [InlineData(3, 10)] // Not evenly divisible. - [InlineData(10, 5)] // Amount is larger than total. - public void ShiftX_ShouldNotExceedMaxTotalShift(int shiftAmount, int maxTotalShift) + [InlineData(50, 0.1, 100, 5)] + [InlineData(20, 0.5, 4, 4)] + public void GetEffectiveMaxOffset_ShouldUseWindowSizeRatio(double windowSize, double ratio, int maxOffset, double expected) { var shifter = new PixelShifter { - PixelsPerShift = shiftAmount, - MaxPixelOffset = maxTotalShift, + MaxPixelOffsetRatio = ratio, + MaxPixelOffset = maxOffset, }; - double totalShiftX = 0; - - // Test 100 times because it's random. - for (var i = 0; i < 100; i++) - { - var shift = shifter.ShiftX(); - totalShiftX += shift; + var effective = shifter.GetEffectiveMaxOffset(windowSize); - Assert.InRange(Math.Abs(totalShiftX), 0, maxTotalShift); - } + Assert.Equal(expected, effective); } - [Theory] - [InlineData(5, 10)] // Evenly divisible. - [InlineData(3, 10)] // Not evenly divisible. - [InlineData(10, 5)] // Amount is larger than total. - public void ShiftY_ShouldNotExceedMaxTotalShift(int shiftAmount, int maxTotalShift) + [Fact] + public void ShiftX_ShouldBounceDeterministicallyWithinBounds() { var shifter = new PixelShifter { - PixelsPerShift = shiftAmount, - MaxPixelOffset = maxTotalShift, + PixelsPerShift = 2, + MaxPixelOffset = 100, + MaxPixelOffsetRatio = 0.1, }; - double totalShiftY = 0; + const double windowSize = 50; + Assert.Equal(5d, shifter.GetEffectiveMaxOffset(windowSize)); - // Test 100 times because it's random. - for (var i = 0; i < 100; i++) + var expectedShifts = new double[] { 2, 2, 1, -2, -2, -2, -2, -2, 2, 2 }; + foreach (var expected in expectedShifts) { - var shift = shifter.ShiftY(); - totalShiftY += shift; - - Assert.InRange(Math.Abs(totalShiftY), 0, maxTotalShift); + var shift = shifter.ShiftX(windowSize); + Assert.Equal(expected, shift); + Assert.InRange(Math.Abs(shifter.TotalShiftX), 0, 5); } } [Fact] - public void ShiftX_WithZeroPixelsPerShift_ShouldReturnZero() + public void ShiftY_ShouldBounceDeterministicallyWithinBounds() { - // Arrange var shifter = new PixelShifter { - PixelsPerShift = 0, - MaxPixelOffset = 10, + PixelsPerShift = 2, + MaxPixelOffset = 100, + MaxPixelOffsetRatio = 0.1, }; - // Act - var shift = shifter.ShiftX(); - - // Assert - Assert.Equal(0, shift); - } + const double windowSize = 50; + Assert.Equal(5d, shifter.GetEffectiveMaxOffset(windowSize)); - [Fact] - public void ShiftY_WithZeroPixelsPerShift_ShouldReturnZero() - { - // Arrange - var shifter = new PixelShifter - { - PixelsPerShift = 0, - MaxPixelOffset = 10, - }; - - // Act - var shift = shifter.ShiftY(); - - // Assert - Assert.Equal(0, shift); - } - - [Fact] - public void ShiftX_WithZeroMaxOffset_ShouldReturnZero() - { - // Arrange - var shifter = new PixelShifter + var expectedShifts = new double[] { 2, 2, 1, -2, -2, -2, -2, -2, 2, 2 }; + foreach (var expected in expectedShifts) { - PixelsPerShift = 5, - MaxPixelOffset = 0, - }; - - // Act - var shift = shifter.ShiftX(); - - // Assert - Assert.Equal(0, shift); + var shift = shifter.ShiftY(windowSize); + Assert.Equal(expected, shift); + Assert.InRange(Math.Abs(shifter.TotalShiftY), 0, 5); + } } - [Fact] - public void ShiftY_WithZeroMaxOffset_ShouldReturnZero() + [Theory] + [InlineData(0, 50)] + [InlineData(2, 0)] + public void ShiftX_WhenDisabled_ReturnsZero(int pixelsPerShift, double windowSize) { - // Arrange var shifter = new PixelShifter { - PixelsPerShift = 5, - MaxPixelOffset = 0, + PixelsPerShift = pixelsPerShift, + MaxPixelOffset = 10, + MaxPixelOffsetRatio = 0.1, }; - // Act - var shift = shifter.ShiftY(); + var shift = shifter.ShiftX(windowSize); - // Assert Assert.Equal(0, shift); + Assert.Equal(0, shifter.TotalShiftX); } [Fact] public void DefaultValues_ShouldBeExpected() { - // Arrange var shifter = new PixelShifter(); - // Assert Assert.Equal(1, shifter.PixelsPerShift); Assert.Equal(4, shifter.MaxPixelOffset); - } - - [Fact] - public void ShiftX_ShouldReverseDirectionAtBoundary() - { - // Arrange - set up to hit boundary quickly - var shifter = new PixelShifter - { - PixelsPerShift = 10, - MaxPixelOffset = 10, - }; - - // Act - call multiple times to force direction reversal - double total = 0; - var shifts = new List(); - for (int i = 0; i < 10; i++) - { - var shift = shifter.ShiftX(); - shifts.Add(shift); - total += shift; - } - - // Assert - total should stay within bounds - Assert.InRange(Math.Abs(total), 0, 10); - - // There should be both positive and negative shifts (direction reversal) - // OR the total stayed within bounds - Assert.True(Math.Abs(total) <= 10); - } - - [Fact] - public void ShiftY_ShouldReverseDirectionAtBoundary() - { - // Arrange - set up to hit boundary quickly - var shifter = new PixelShifter - { - PixelsPerShift = 10, - MaxPixelOffset = 10, - }; - - // Act - call multiple times to force direction reversal - double total = 0; - var shifts = new List(); - for (int i = 0; i < 10; i++) - { - var shift = shifter.ShiftY(); - shifts.Add(shift); - total += shift; - } - - // Assert - total should stay within bounds - Assert.InRange(Math.Abs(total), 0, 10); - } - - [Fact] - public void ShiftX_And_ShiftY_ShouldBeIndependent() - { - // Arrange - var shifter = new PixelShifter - { - PixelsPerShift = 5, - MaxPixelOffset = 20, - }; - - // Act - double totalX = 0, totalY = 0; - for (int i = 0; i < 50; i++) - { - totalX += shifter.ShiftX(); - totalY += shifter.ShiftY(); - } - - // Assert - both should be within bounds independently - Assert.InRange(Math.Abs(totalX), 0, 20); - Assert.InRange(Math.Abs(totalY), 0, 20); - } - - [Fact] - public void ShiftX_MultipleShiftersWithSameConfig_ShouldBeIndependent() - { - // Arrange - var shifter1 = new PixelShifter { PixelsPerShift = 5, MaxPixelOffset = 10 }; - var shifter2 = new PixelShifter { PixelsPerShift = 5, MaxPixelOffset = 10 }; - - // Act - double total1 = 0, total2 = 0; - for (int i = 0; i < 20; i++) - { - total1 += shifter1.ShiftX(); - total2 += shifter2.ShiftX(); - } - - // Assert - both should be within their own bounds - Assert.InRange(Math.Abs(total1), 0, 10); - Assert.InRange(Math.Abs(total2), 0, 10); - } - - [Theory] - [InlineData(1, 5)] - [InlineData(2, 8)] - [InlineData(3, 15)] - public void Shift_ShouldReturnValueWithinPixelsPerShiftRange(int pixelsPerShift, int maxOffset) - { - // Arrange - var shifter = new PixelShifter - { - PixelsPerShift = pixelsPerShift, - MaxPixelOffset = maxOffset, - }; - - // Act & Assert - for (int i = 0; i < 50; i++) - { - var shiftX = shifter.ShiftX(); - var shiftY = shifter.ShiftY(); - - // Shift should be within the range [-pixelsPerShift, +pixelsPerShift] - Assert.InRange(shiftX, -pixelsPerShift, pixelsPerShift); - Assert.InRange(shiftY, -pixelsPerShift, pixelsPerShift); - } + Assert.Equal(0.1, shifter.MaxPixelOffsetRatio, 5); } } diff --git a/DesktopClock/MainWindow.xaml.cs b/DesktopClock/MainWindow.xaml.cs index 769fcaf..c8ecf9a 100644 --- a/DesktopClock/MainWindow.xaml.cs +++ b/DesktopClock/MainWindow.xaml.cs @@ -1,6 +1,5 @@ using System; using System.ComponentModel; -using System.Diagnostics; using System.Globalization; using System.IO; using System.Media; @@ -36,12 +35,6 @@ public partial class MainWindow : Window [ObservableProperty] private string _currentTimeOrCountdownString; - /// - /// The amount of margin applied in order to shift the clock's pixels and help prevent burn-in. - /// - [ObservableProperty] - private Thickness _pixelShift; - public MainWindow() { InitializeComponent(); @@ -111,6 +104,8 @@ public void Exit() Application.Current.Shutdown(); } + public PixelShifter PixelShifter => Settings.Default.BurnInMitigation ? (_pixelShifter ??= new()) : null; + protected override void OnClosed(EventArgs e) { Settings.Default.PropertyChanged -= _settingsPropertyChanged; @@ -242,9 +237,7 @@ private void TryShiftPixels() if (!IsVisible || WindowState == WindowState.Minimized) return; - _pixelShifter ??= new(); - Left += _pixelShifter.ShiftX(); - Top += _pixelShifter.ShiftY(); + PixelShifter?.ApplyShift(this); }); } @@ -270,9 +263,11 @@ private void Window_MouseDown(object sender, MouseButtonEventArgs e) if (e.ChangedButton == MouseButton.Left && Settings.Default.DragToMove) { // Pause time updates to maintain placement. + PixelShifter?.ClearShift(this); _systemClockTimer.Stop(); DragMove(); + PixelShifter?.UpdateBasePosition(this); UpdateTimeString(); _systemClockTimer.Start(); @@ -298,6 +293,7 @@ private void Window_MouseWheel(object sender, MouseWheelEventArgs e) private void Window_SourceInitialized(object sender, EventArgs e) { this.SetPlacement(Settings.Default.Placement); + PixelShifter?.UpdateBasePosition(this); // Apply click-through setting. this.SetClickThrough(Settings.Default.ClickThrough); @@ -337,6 +333,7 @@ private void Window_ContentRendered(object sender, EventArgs e) private void Window_Closing(object sender, CancelEventArgs e) { // Save the last text and the placement to preserve dimensions and position of the clock. + PixelShifter?.RestoreBasePosition(this); Settings.Default.LastDisplay = CurrentTimeOrCountdownString; Settings.Default.Placement = this.GetPlacement(); @@ -358,6 +355,7 @@ private void Window_SizeChanged(object sender, SizeChangedEventArgs e) { var widthChange = e.NewSize.Width - e.PreviousSize.Width; Left -= widthChange; + PixelShifter?.AdjustForRightAlignedWidthChange(widthChange); } } diff --git a/DesktopClock/Utilities/PixelShifter.cs b/DesktopClock/Utilities/PixelShifter.cs index 73f78aa..c600621 100644 --- a/DesktopClock/Utilities/PixelShifter.cs +++ b/DesktopClock/Utilities/PixelShifter.cs @@ -1,12 +1,17 @@ using System; +using System.Windows; namespace DesktopClock.Utilities; public class PixelShifter { - private readonly Random _random = new(); private double _totalShiftX; private double _totalShiftY; + private int _directionX = 1; + private int _directionY = 1; + private double _baseLeft; + private double _baseTop; + private bool _hasBasePosition; /// /// The number of pixels that will be shifted each time. @@ -18,54 +23,180 @@ public class PixelShifter /// public int MaxPixelOffset { get; set; } = 4; + /// + /// The ratio of the window size that determines the effective max drift. + /// + public double MaxPixelOffsetRatio { get; set; } = 0.1; + + /// + /// The total amount shifted horizontally. + /// + public double TotalShiftX => _totalShiftX; + + /// + /// The total amount shifted vertically. + /// + public double TotalShiftY => _totalShiftY; + /// /// Returns an amount to shift horizontally by while staying within the specified bounds. /// - public double ShiftX() + public double ShiftX(double windowWidth) { - double pixelsToMoveBy = GetRandomShift(); - pixelsToMoveBy = GetFinalShiftAmount(_totalShiftX, pixelsToMoveBy, MaxPixelOffset); - _totalShiftX += pixelsToMoveBy; - return pixelsToMoveBy; + var maxOffset = GetEffectiveMaxOffset(windowWidth); + return ShiftAxis(ref _totalShiftX, ref _directionX, maxOffset); } /// /// Returns an amount to shift vertically by while staying within the specified bounds. /// - public double ShiftY() + public double ShiftY(double windowHeight) + { + var maxOffset = GetEffectiveMaxOffset(windowHeight); + return ShiftAxis(ref _totalShiftY, ref _directionY, maxOffset); + } + + /// + /// Applies the current shift to the window's position. + /// + public void ApplyShift(Window window) { - double pixelsToMoveBy = GetRandomShift(); - pixelsToMoveBy = GetFinalShiftAmount(_totalShiftY, pixelsToMoveBy, MaxPixelOffset); - _totalShiftY += pixelsToMoveBy; - return pixelsToMoveBy; + EnsureBasePosition(window.Left, window.Top); + ShiftX(window.ActualWidth); + ShiftY(window.ActualHeight); + window.Left = _baseLeft + _totalShiftX; + window.Top = _baseTop + _totalShiftY; } /// - /// Returns a random amount to shift by within the specified amount. + /// Clears any shift and restores the base position. /// - private int GetRandomShift() => _random.Next(-PixelsPerShift, PixelsPerShift + 1); + public void ClearShift(Window window) + { + EnsureBasePosition(window.Left, window.Top); + Reset(); + window.Left = _baseLeft; + window.Top = _baseTop; + } + + /// + /// Restores the base position to persist the unshifted placement. + /// + public void RestoreBasePosition(Window window) + { + if (!_hasBasePosition) + { + UpdateBasePosition(window.Left, window.Top); + return; + } + + window.Left = _baseLeft; + window.Top = _baseTop; + } + + /// + /// Updates the base position after a user move. + /// + public void UpdateBasePosition(Window window) => UpdateBasePosition(window.Left, window.Top); + + private void UpdateBasePosition(double currentLeft, double currentTop) + { + _baseLeft = currentLeft; + _baseTop = currentTop; + _hasBasePosition = true; + } + + /// + /// Adjusts the base position when right alignment nudges the left edge. + /// + public void AdjustForRightAlignedWidthChange(double widthChange) + { + if (!_hasBasePosition) + return; + + _baseLeft -= widthChange; + } + + /// + /// Returns the effective max offset based on the window size ratio. + /// + public double GetEffectiveMaxOffset(double windowSize) + { + if (windowSize <= 0 || MaxPixelOffset <= 0) + return 0; + + if (MaxPixelOffsetRatio <= 0) + return MaxPixelOffset; + + var ratioOffset = (int)Math.Floor(windowSize * MaxPixelOffsetRatio); + return Math.Min(MaxPixelOffset, Math.Max(0, ratioOffset)); + } + + /// + /// Resets the shifter state back to zero. + /// + public void Reset() + { + _totalShiftX = 0; + _totalShiftY = 0; + _directionX = 1; + _directionY = 1; + } + + /// + /// Ensures the base position is captured once. + /// + private void EnsureBasePosition(double currentLeft, double currentTop) + { + if (_hasBasePosition) + return; + + _baseLeft = currentLeft; + _baseTop = currentTop; + _hasBasePosition = true; + } /// /// Returns a capped amount to shift by. /// - /// The current total amount of shift that has occurred. - /// The proposed amount to shift by this time. + /// The current total amount of shift that has occurred. + /// The direction of the next shift. /// The bounds to stay within in respect to the total shift. - private double GetFinalShiftAmount(double current, double offset, double max) + private double ShiftAxis(ref double total, ref int direction, double max) { - var newTotal = current + offset; + if (PixelsPerShift <= 0 || max <= 0) + { + total = 0; + direction = 1; + return 0; + } - if (newTotal > max) + if (total >= max) { - return max - current; + total = max; + direction = -1; } - else if (newTotal < -max) + else if (total <= -max) { - return -max - current; + total = -max; + direction = 1; } - else + + var step = direction * PixelsPerShift; + var proposed = total + step; + + if (proposed > max) { - return offset; + step = (int)(max - total); + direction = -1; } + else if (proposed < -max) + { + step = (int)(-max - total); + direction = 1; + } + + total += step; + return step; } }