diff --git a/CHANGELOG.md b/CHANGELOG.md index 0328e5f..bace430 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ version numbers, because that is what those releases were called. ## [Unreleased] +### Fixed: WPF Timeline interaction and accessibility + +- Added mouse double-click activation alongside Enter for Timeline restore points, with duplicate-open + protection and a readable fallback when a failed snapshot has no diagnostic detail. +- Moved Timeline catalog reads off the UI thread and added explicit loading and failure states so slow + or unavailable snapshot storage does not freeze the window or masquerade as an empty Timeline. +- Stacked the comparison evidence, restore set, and detail cards at narrow window widths while keeping + the established two-column workspace at wider sizes. +- Honored the Windows client-area animation preference for indeterminate progress, exposed stable theme + option automation IDs, and marked inactive alternative content as offscreen for assistive technology. + ### Fixed: WPF lifecycle-safe navigation - Added explicit Timeline and Advanced history destinations to the command bar so every read-only diff --git a/src/WinRestoreKit.Tests/TimelineAccessibilityTests.cs b/src/WinRestoreKit.Tests/TimelineAccessibilityTests.cs index 3f3f347..9f69b57 100644 --- a/src/WinRestoreKit.Tests/TimelineAccessibilityTests.cs +++ b/src/WinRestoreKit.Tests/TimelineAccessibilityTests.cs @@ -19,9 +19,9 @@ namespace WinRestoreKit.Tests public sealed class TimelineAccessibilityTests { [Fact] - public void TimelineView_ExposesEquivalentNamedListAndKeyboardSelection() + public async Task TimelineView_ExposesEquivalentMouseAndKeyboardActivation() { - WpfTestHost.Run(() => + await WpfTestHost.RunAsync(async () => { FakePreparationService service = new FakePreparationService(); TimelineViewModel viewModel = NewTimelineViewModel(service); @@ -31,13 +31,16 @@ public void TimelineView_ExposesEquivalentNamedListAndKeyboardSelection() host.Show(); try { + await viewModel.RefreshAsync(); Layout(view); ListBox list = Assert.IsType(view.FindName("TimelineEventList")); Assert.Equal("Snapshots", AutomationProperties.GetName(list)); + Assert.Equal("TimelineEventList", AutomationProperties.GetAutomationId(list)); Assert.Equal(SelectionMode.Single, list.SelectionMode); Assert.Equal(KeyboardNavigationMode.Continue, KeyboardNavigation.GetDirectionalNavigation(list)); Assert.Contains("Enter", AutomationProperties.GetHelpText(list)); + Assert.Contains("Double-click", AutomationProperties.GetHelpText(list)); list.SelectedIndex = 0; RaiseKey(list, Key.Right); Assert.Equal(1, list.SelectedIndex); @@ -45,6 +48,11 @@ public void TimelineView_ExposesEquivalentNamedListAndKeyboardSelection() Assert.Equal(0, list.SelectedIndex); RaiseKey(list, Key.Enter); Assert.Equal(1, service.Calls); + + ListBoxItem selected = Assert.IsType( + list.ItemContainerGenerator.ContainerFromIndex(0)); + RaiseDoubleClick(selected); + Assert.Equal(2, service.Calls); } finally { @@ -54,9 +62,9 @@ public void TimelineView_ExposesEquivalentNamedListAndKeyboardSelection() } [Fact] - public void TimelineView_ExposesEveryRowStateAndSelectionFailureAsAccessibleText() + public async Task TimelineView_ExposesOnlyTheActiveAlternateStateToAccessibility() { - WpfTestHost.Run(() => + await WpfTestHost.RunAsync(async () => { TimelineViewModel viewModel = NewTimelineViewModel(); TimelineView view = new TimelineView { DataContext = viewModel }; @@ -65,6 +73,7 @@ public void TimelineView_ExposesEveryRowStateAndSelectionFailureAsAccessibleText host.Show(); try { + await viewModel.RefreshAsync(); Layout(view); ListBox list = Assert.IsType(view.FindName("TimelineEventList")); @@ -77,6 +86,27 @@ public void TimelineView_ExposesEveryRowStateAndSelectionFailureAsAccessibleText TextBlock selectionError = Assert.IsType(view.FindName("SelectionErrorText")); Assert.Equal(AutomationLiveSetting.Polite, AutomationProperties.GetLiveSetting(selectionError)); + + Border empty = Assert.IsType(view.FindName("TimelineEmptyState")); + Border loading = Assert.IsType(view.FindName("TimelineLoadingState")); + Border error = Assert.IsType(view.FindName("TimelineSelectionError")); + Assert.Equal(Visibility.Collapsed, empty.Visibility); + Assert.Equal(IsOffscreenBehavior.Offscreen, + AutomationProperties.GetIsOffscreenBehavior(empty)); + Assert.Equal(Visibility.Collapsed, loading.Visibility); + Assert.Equal(IsOffscreenBehavior.Offscreen, + AutomationProperties.GetIsOffscreenBehavior(loading)); + Assert.Equal(Visibility.Collapsed, error.Visibility); + Assert.Equal(IsOffscreenBehavior.Offscreen, + AutomationProperties.GetIsOffscreenBehavior(error)); + + viewModel.SelectedEvent = viewModel.Events[0]; + await viewModel.OpenSelectedAsync(); + Layout(view); + + Assert.Equal(Visibility.Visible, error.Visibility); + Assert.Equal(IsOffscreenBehavior.Onscreen, + AutomationProperties.GetIsOffscreenBehavior(error)); } finally { @@ -91,7 +121,6 @@ private static TimelineViewModel NewTimelineViewModel(FakePreparationService ser SnapshotEvent failed = NewEvent(SnapshotEventKind.Failed, @"C:\timeline\failed", "TEST-PC", "disk full"); TimelineViewModel viewModel = new TimelineViewModel( new FakeCatalog(verified, failed), service ?? new FakePreparationService(), new RecordingNavigator()); - viewModel.RefreshAsync().GetAwaiter().GetResult(); return viewModel; } @@ -109,6 +138,16 @@ private static void RaiseKey(UIElement target, Key key) }); } + private static void RaiseDoubleClick(Control target) + { + PresentationSource inputSource = PresentationSource.FromVisual(target); + Assert.NotNull(inputSource); + target.RaiseEvent(new MouseButtonEventArgs(Mouse.PrimaryDevice, 0, MouseButton.Left) + { + RoutedEvent = Control.MouseDoubleClickEvent + }); + } + private static void Layout(FrameworkElement element) { element.Measure(new Size(1024, 720)); diff --git a/src/WinRestoreKit.Tests/TimelineViewModelTests.cs b/src/WinRestoreKit.Tests/TimelineViewModelTests.cs index 4156f3e..9ed62d2 100644 --- a/src/WinRestoreKit.Tests/TimelineViewModelTests.cs +++ b/src/WinRestoreKit.Tests/TimelineViewModelTests.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using WinRestoreKit; using WinRestoreKit.Wpf.Navigation; +using WinRestoreKit.Wpf.Services; using WinRestoreKit.Wpf.ViewModels.Snapshots; using WinRestoreKit.Wpf.ViewModels.Timeline; using Xunit; @@ -16,49 +17,91 @@ public sealed class TimelineViewModelTests [Fact] public async Task OpenSelectedAsync_PreparesPartialAndTransfersOwnershipToNavigator() { - SnapshotEvent partial = NewEvent(SnapshotEventKind.Partial, @"C:\snapshot"); - RecordingNavigator navigator = new RecordingNavigator(); - TimelineViewModel viewModel = new TimelineViewModel( - new FakeCatalog(partial), new FakePreparationService(partial), navigator); - - await viewModel.RefreshAsync(); - viewModel.SelectedEvent = Assert.Single(viewModel.Events); - await viewModel.OpenSelectedAsync(); - - Assert.Same(partial, navigator.Prepared.Snapshot); - Assert.Null(navigator.Diagnostic); - navigator.Prepared.Dispose(); + await WpfTestHost.RunAsync(async () => + { + SnapshotEvent partial = NewEvent(SnapshotEventKind.Partial, @"C:\snapshot"); + RecordingNavigator navigator = new RecordingNavigator(); + TimelineViewModel viewModel = new TimelineViewModel( + new FakeCatalog(partial), new FakePreparationService(partial), navigator); + + await viewModel.RefreshAsync(); + viewModel.SelectedEvent = Assert.Single(viewModel.Events); + await viewModel.OpenSelectedAsync(); + + Assert.Same(partial, navigator.Prepared.Snapshot); + Assert.Null(navigator.Diagnostic); + navigator.Prepared.Dispose(); + }); } [Fact] public async Task OpenSelectedAsync_ShowsFailedEvidenceWithoutPreparingPayload() { - SnapshotEvent failed = NewEvent(SnapshotEventKind.Failed, @"C:\failed", "disk full"); - FakePreparationService service = new FakePreparationService(); - RecordingNavigator navigator = new RecordingNavigator(); - TimelineViewModel viewModel = new TimelineViewModel(new FakeCatalog(failed), service, navigator); + await WpfTestHost.RunAsync(async () => + { + SnapshotEvent failed = NewEvent(SnapshotEventKind.Failed, @"C:\failed", "disk full"); + FakePreparationService service = new FakePreparationService(); + RecordingNavigator navigator = new RecordingNavigator(); + TimelineViewModel viewModel = new TimelineViewModel(new FakeCatalog(failed), service, navigator); + + await viewModel.RefreshAsync(); + viewModel.SelectedEvent = Assert.Single(viewModel.Events); + await viewModel.OpenSelectedAsync(); + + Assert.Same(failed, navigator.Diagnostic); + Assert.Equal(0, service.Calls); + }); + } + + [Fact] + public async Task OpenSelectedAsync_ReportsPreparationFailureInline() + { + await WpfTestHost.RunAsync(async () => + { + SnapshotEvent verified = NewEvent(SnapshotEventKind.Verified, @"C:\verified"); + TimelineViewModel viewModel = new TimelineViewModel( + new FakeCatalog(verified), new FakePreparationService(), new RecordingNavigator()); - await viewModel.RefreshAsync(); - viewModel.SelectedEvent = Assert.Single(viewModel.Events); - await viewModel.OpenSelectedAsync(); + await viewModel.RefreshAsync(); + viewModel.SelectedEvent = Assert.Single(viewModel.Events); + await viewModel.OpenSelectedAsync(); - Assert.Same(failed, navigator.Diagnostic); - Assert.Equal(0, service.Calls); + Assert.True(viewModel.HasSelectionError); + Assert.Equal("unexpected preparation", viewModel.SelectionError); + }); } [Fact] - public async Task OpenSelectedAsync_ReportsPreparationFailureInline() + public async Task RefreshAsync_ReadsCatalogAwayFromUiThread() + { + await WpfTestHost.RunAsync(async () => + { + int uiThreadId = Thread.CurrentThread.ManagedThreadId; + FakeCatalog catalog = new FakeCatalog( + NewEvent(SnapshotEventKind.Verified, @"C:\verified")); + TimelineViewModel viewModel = new TimelineViewModel( + catalog, new FakePreparationService(), new RecordingNavigator()); + + await viewModel.RefreshAsync(); + + Assert.NotEqual(uiThreadId, catalog.ReadThreadId); + Assert.False(viewModel.IsLoading); + }); + } + + [Fact] + public async Task RefreshAsync_WhenCatalogFailsReportsInlineAndStopsLoading() { - SnapshotEvent verified = NewEvent(SnapshotEventKind.Verified, @"C:\verified"); - TimelineViewModel viewModel = new TimelineViewModel( - new FakeCatalog(verified), new FakePreparationService(), new RecordingNavigator()); + await WpfTestHost.RunAsync(async () => + { + TimelineViewModel viewModel = new TimelineViewModel( + new ThrowingCatalog(), new FakePreparationService(), new RecordingNavigator()); - await viewModel.RefreshAsync(); - viewModel.SelectedEvent = Assert.Single(viewModel.Events); - await viewModel.OpenSelectedAsync(); + await viewModel.RefreshAsync(); - Assert.True(viewModel.HasSelectionError); - Assert.Equal("unexpected preparation", viewModel.SelectionError); + Assert.False(viewModel.IsLoading); + Assert.Equal("Timeline could not be refreshed: catalog unavailable", viewModel.SelectionError); + }); } [Theory] @@ -75,6 +118,15 @@ public void SnapshotEventViewModel_MapsEveryKindToItsStableStatus( Assert.Equal(isDiagnosticOnly, viewModel.Status.IsDiagnosticOnly); } + [Fact] + public void SnapshotDiagnostic_UsesAReadableFallbackWhenNoReasonWasRecorded() + { + SnapshotEvent failed = NewEvent(SnapshotEventKind.Failed, @"C:\failed"); + + Assert.Equal("No additional diagnostic details were recorded for this snapshot.", + CompareDialogService.DiagnosticTextFor(failed)); + } + private static SnapshotEvent NewEvent(SnapshotEventKind kind, string path, string reason = null) => new SnapshotEvent(kind, new DateTime(2026, 8, 9, 12, 0, 0, DateTimeKind.Local), Path.GetFileName(path), Path.GetFullPath(path), reason, "TEST-PC", 0, true, null); @@ -85,7 +137,19 @@ private sealed class FakeCatalog : ISnapshotEventReader internal FakeCatalog(params SnapshotEvent[] events) => this.events = events; - public IReadOnlyList Read() => events; + internal int ReadThreadId { get; private set; } + + public IReadOnlyList Read() + { + ReadThreadId = Thread.CurrentThread.ManagedThreadId; + return events; + } + } + + private sealed class ThrowingCatalog : ISnapshotEventReader + { + public IReadOnlyList Read() + => throw new InvalidOperationException("catalog unavailable"); } private sealed class FakePreparationService : ISnapshotPayloadPreparationService diff --git a/src/WinRestoreKit.Tests/TimelineWpfSmokeTests.cs b/src/WinRestoreKit.Tests/TimelineWpfSmokeTests.cs index eae3eb2..34f0d2e 100644 --- a/src/WinRestoreKit.Tests/TimelineWpfSmokeTests.cs +++ b/src/WinRestoreKit.Tests/TimelineWpfSmokeTests.cs @@ -17,9 +17,9 @@ namespace WinRestoreKit.Tests public sealed class TimelineWpfSmokeTests { [Fact] - public void TimelineView_LoadsSelectionAndTransfersPreparedSnapshot() + public async Task TimelineView_LoadsSelectionAndTransfersPreparedSnapshot() { - WpfTestHost.Run(() => + await WpfTestHost.RunAsync(async () => { RecordingNavigator navigator = new RecordingNavigator(); SnapshotEvent snapshot = NewEvent(SnapshotEventKind.Verified, @"C:\timeline-smoke"); @@ -31,11 +31,11 @@ public void TimelineView_LoadsSelectionAndTransfersPreparedSnapshot() host.Show(); try { - viewModel.RefreshAsync().GetAwaiter().GetResult(); + await viewModel.RefreshAsync(); ListBox list = FindDescendant(view); Assert.NotNull(list); list.SelectedIndex = 0; - viewModel.OpenSelectedAsync().GetAwaiter().GetResult(); + await viewModel.OpenSelectedAsync(); Assert.NotNull(navigator.Prepared); Assert.Equal(SnapshotEventKind.Verified, navigator.Prepared.Snapshot.Kind); diff --git a/src/WinRestoreKit.Tests/WpfSurfaceAccessibilityTests.cs b/src/WinRestoreKit.Tests/WpfSurfaceAccessibilityTests.cs new file mode 100644 index 0000000..8ec5acb --- /dev/null +++ b/src/WinRestoreKit.Tests/WpfSurfaceAccessibilityTests.cs @@ -0,0 +1,242 @@ +using System; +using System.Collections.Generic; +using System.Windows; +using System.Windows.Automation; +using System.Windows.Controls; +using WinRestoreKit; +using WinRestoreKit.Wpf.Services; +using WinRestoreKit.Wpf.ViewModels; +using WinRestoreKit.Wpf.Views; +using Xunit; + +namespace WinRestoreKit.Tests +{ + public sealed class WpfSurfaceAccessibilityTests + { + [Fact] + public void ComparisonWorkspace_StacksAtMinimumWidthAndRestoresWideLayout() + { + WpfTestHost.Run(() => + { + ComparisonWorkspaceViewModel viewModel = ComparisonViewModel(); + ComparisonWorkspaceView view = new ComparisonWorkspaceView { DataContext = viewModel }; + Window host = new Window { Content = view, Width = 1280, Height = 800 }; + + host.Show(); + try + { + Layout(view, 1280, 800); + Border evidence = Require(view, "EvidenceCard"); + Border restoreSet = Require(view, "RestoreSetCard"); + Border detail = Require(view, "DetailCard"); + ScrollViewer scroller = Require(view, "ComparisonPaneScroller"); + + Assert.False(view.IsNarrowLayout); + Assert.Equal(0, Grid.GetColumn(evidence)); + Assert.Equal(3, Grid.GetRowSpan(evidence)); + Assert.Equal(1, Grid.GetColumn(restoreSet)); + Assert.Equal(1, Grid.GetColumn(detail)); + Assert.Equal(ScrollBarVisibility.Disabled, scroller.VerticalScrollBarVisibility); + + host.Width = 900; + Layout(view, 900, 800); + + Assert.True(view.IsNarrowLayout); + Assert.Equal(0, Grid.GetColumn(evidence)); + Assert.Equal(0, Grid.GetRow(evidence)); + Assert.Equal(1, Grid.GetRowSpan(evidence)); + Assert.Equal(0, Grid.GetColumn(restoreSet)); + Assert.Equal(1, Grid.GetRow(restoreSet)); + Assert.Equal(0, Grid.GetColumn(detail)); + Assert.Equal(2, Grid.GetRow(detail)); + Assert.Equal(ScrollBarVisibility.Auto, scroller.VerticalScrollBarVisibility); + + host.Width = 1280; + Layout(view, 1280, 800); + + Assert.False(view.IsNarrowLayout); + Assert.Equal(1, Grid.GetColumn(restoreSet)); + Assert.Equal(1, Grid.GetColumn(detail)); + } + finally + { + host.Close(); + } + }); + } + + [Fact] + public void ComparisonWorkspace_ExposesOnlyTheActiveDetailAlternative() + { + WpfTestHost.Run(() => + { + ComparisonWorkspaceViewModel viewModel = ComparisonViewModel(); + ComparisonWorkspaceView view = new ComparisonWorkspaceView { DataContext = viewModel }; + Window host = new Window { Content = view, Width = 1280, Height = 800 }; + + host.Show(); + try + { + Layout(view, 1280, 800); + Border detail = Require(view, "DetailCard"); + Border placeholder = Require(view, "DetailPlaceholder"); + + Assert.Equal(Visibility.Collapsed, detail.Visibility); + Assert.Equal(IsOffscreenBehavior.Offscreen, + AutomationProperties.GetIsOffscreenBehavior(detail)); + Assert.Equal(Visibility.Visible, placeholder.Visibility); + Assert.Equal(IsOffscreenBehavior.Onscreen, + AutomationProperties.GetIsOffscreenBehavior(placeholder)); + + BackupModuleRegistration registration = new BackupModuleRegistration( + new TestModule("Mouse"), "Input"); + viewModel.SelectedRow = new ModuleComparisonRowViewModel( + registration, viewModel.RestoreSet); + Layout(view, 1280, 800); + + Assert.Equal(Visibility.Visible, detail.Visibility); + Assert.Equal(IsOffscreenBehavior.Onscreen, + AutomationProperties.GetIsOffscreenBehavior(detail)); + Assert.Equal(Visibility.Collapsed, placeholder.Visibility); + Assert.Equal(IsOffscreenBehavior.Offscreen, + AutomationProperties.GetIsOffscreenBehavior(placeholder)); + } + finally + { + host.Close(); + } + }); + } + + [Fact] + public void ConfirmWorkspace_HidesInactiveSectionsFromTheAccessibilityContentView() + { + WpfTestHost.Run(() => + { + ConfirmViewModel viewModel = new ConfirmViewModel( + Snapshot(), new BackupBase[] { new TestModule("Mouse") }); + ConfirmView view = new ConfirmView { DataContext = viewModel }; + Window host = new Window { Content = view, Width = 1100, Height = 800 }; + + host.Show(); + try + { + Layout(view, 1100, 800); + AssertHiddenFromContent(view, "ConfirmPartialWarning"); + AssertHiddenFromContent(view, "ConfirmEmptyModules"); + AssertHiddenFromContent(view, "ConfirmApplicationsSection"); + AssertHiddenFromContent(view, "ConfirmConsentProcesses"); + AssertHiddenFromContent(view, "ConfirmInformationalProcesses"); + AssertHiddenFromContent(view, "ConfirmExplorerSection"); + AssertHiddenFromContent(view, "ConfirmWarningsSection"); + AssertHiddenFromContent(view, "ConfirmRestoreStatusSection"); + AssertHiddenFromContent(view, "ConfirmProgressText"); + AssertHiddenFromContent(view, "ConfirmSummary"); + } + finally + { + host.Close(); + } + }); + } + + [Fact] + public void SettingsThemeOptionsExposeStableAutomationIds() + { + WpfTestHost.Run(() => + { + SettingsView view = new SettingsView + { + DataContext = new SettingsViewModel(new FakeThemeService()) + }; + Window host = new Window { Content = view, Width = 900, Height = 600 }; + + host.Show(); + try + { + Layout(view, 900, 600); + ComboBox selector = Require(view, "themeSelector"); + Assert.Equal("SettingsThemeSelector", AutomationProperties.GetAutomationId(selector)); + selector.IsDropDownOpen = true; + Layout(view, 900, 600); + + Assert.Equal("SettingsThemeFollowSystem", ItemAutomationId(selector, 0)); + Assert.Equal("SettingsThemeLight", ItemAutomationId(selector, 1)); + Assert.Equal("SettingsThemeDark", ItemAutomationId(selector, 2)); + } + finally + { + host.Close(); + } + }); + } + + private static ComparisonWorkspaceViewModel ComparisonViewModel() + => new ComparisonWorkspaceViewModel( + Snapshot(), + Array.Empty(), + new SnapshotComparisonService(), + (_, __) => { }); + + private static SnapshotEvent Snapshot() + => new SnapshotEvent(SnapshotEventKind.Verified, + new DateTime(2026, 8, 10, 0, 0, 0, DateTimeKind.Local), + "snapshot", @"C:\snapshot", string.Empty, "TEST-PC", 0, true, null); + + private static void AssertHiddenFromContent(FrameworkElement view, string name) + { + FrameworkElement element = Require(view, name); + Assert.Equal(Visibility.Collapsed, element.Visibility); + Assert.Equal(IsOffscreenBehavior.Offscreen, + AutomationProperties.GetIsOffscreenBehavior(element)); + } + + private static string ItemAutomationId(ComboBox selector, int index) + { + ComboBoxItem item = Assert.IsType( + selector.ItemContainerGenerator.ContainerFromIndex(index)); + return AutomationProperties.GetAutomationId(item); + } + + private static T Require(FrameworkElement root, string name) where T : FrameworkElement + => Assert.IsAssignableFrom(root.FindName(name)); + + private static void Layout(FrameworkElement element, double width, double height) + { + element.Measure(new Size(width, height)); + element.Arrange(new Rect(0, 0, width, height)); + element.UpdateLayout(); + } + + private sealed class FakeThemeService : IThemeService + { + public ThemeMode Mode { get; private set; } = ThemeMode.FollowSystem; + public ThemeMode EffectiveMode { get; private set; } = ThemeMode.Light; + public event EventHandler ThemeChanged; + + public void SetMode(ThemeMode mode) + { + Mode = mode; + EffectiveMode = mode == ThemeMode.FollowSystem ? ThemeMode.Light : mode; + ThemeChanged?.Invoke(this, EventArgs.Empty); + } + + public void Dispose() { } + } + + private sealed class TestModule : BackupBase + { + internal TestModule(string title) => Title = title; + + public override IReadOnlyList RestoreTargets + => Array.Empty(); + + public override IReadOnlyList ProcessesToCloseBeforeRestore + => Array.Empty(); + + public override bool? HasArtifactIn(string backupPath) => false; + + public override bool? HasDriftedFrom(string backupPath) => false; + } + } +} diff --git a/src/WinRestoreKit.Wpf/Infrastructure/BooleanToOffscreenBehaviorConverter.cs b/src/WinRestoreKit.Wpf/Infrastructure/BooleanToOffscreenBehaviorConverter.cs new file mode 100644 index 0000000..a1124c1 --- /dev/null +++ b/src/WinRestoreKit.Wpf/Infrastructure/BooleanToOffscreenBehaviorConverter.cs @@ -0,0 +1,22 @@ +using System; +using System.Globalization; +using System.Windows.Automation; +using System.Windows.Data; + +namespace WinRestoreKit.Wpf.Infrastructure +{ + public sealed class BooleanToOffscreenBehaviorConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + bool isOnscreen = value is bool flag && flag; + if (string.Equals(parameter as string, "Inverse", StringComparison.OrdinalIgnoreCase)) + isOnscreen = !isOnscreen; + + return isOnscreen ? IsOffscreenBehavior.Onscreen : IsOffscreenBehavior.Offscreen; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => Binding.DoNothing; + } +} diff --git a/src/WinRestoreKit.Wpf/Services/CompareDialogService.cs b/src/WinRestoreKit.Wpf/Services/CompareDialogService.cs index 73b854b..3a39f29 100644 --- a/src/WinRestoreKit.Wpf/Services/CompareDialogService.cs +++ b/src/WinRestoreKit.Wpf/Services/CompareDialogService.cs @@ -1,3 +1,4 @@ +using System; using System.Windows; using WinRestoreKit; @@ -13,7 +14,17 @@ public bool ConfirmDiscardRestoreSet(Window owner, SnapshotEvent current, Snapsh MessageBoxResult.No) == MessageBoxResult.Yes; public void ShowSnapshotDiagnostic(Window owner, SnapshotEvent snapshot) - => MessageBox.Show(owner, snapshot.DiagnosticReason, "Snapshot diagnostic", + => MessageBox.Show(owner, DiagnosticTextFor(snapshot), "Snapshot diagnostic", MessageBoxButton.OK, MessageBoxImage.Error); + + internal static string DiagnosticTextFor(SnapshotEvent snapshot) + { + if (snapshot == null) + throw new ArgumentNullException(nameof(snapshot)); + + return string.IsNullOrWhiteSpace(snapshot.DiagnosticReason) + ? "No additional diagnostic details were recorded for this snapshot." + : snapshot.DiagnosticReason; + } } } diff --git a/src/WinRestoreKit.Wpf/Themes/Controls.xaml b/src/WinRestoreKit.Wpf/Themes/Controls.xaml index 67c38da..3a0c743 100644 --- a/src/WinRestoreKit.Wpf/Themes/Controls.xaml +++ b/src/WinRestoreKit.Wpf/Themes/Controls.xaml @@ -1,5 +1,6 @@ + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:windows="clr-namespace:System.Windows;assembly=PresentationFramework"> @@ -230,9 +233,11 @@ @@ -262,7 +267,8 @@ + Visibility="{Binding IsComparing, Converter={StaticResource BooleanToVisibilityConverter}}" + AutomationProperties.IsOffscreenBehavior="{Binding IsComparing, Converter={StaticResource BooleanToOffscreenBehaviorConverter}}" /> @@ -271,17 +277,23 @@ - + + - - + + - - + + + - @@ -321,12 +333,14 @@ TextWrapping="Wrap" FontSize="13" IsHitTestVisible="False" Foreground="{DynamicResource MutedTextBrush}"> - @@ -340,7 +354,9 @@ - + @@ -382,12 +398,14 @@ - @@ -401,16 +419,18 @@ TextWrapping="Wrap" FontSize="12" Margin="0,3,0,0" Foreground="{DynamicResource WarningTextBrush}"> - @@ -429,12 +449,14 @@ HorizontalAlignment="Center" VerticalAlignment="Center" IsHitTestVisible="False" Foreground="{DynamicResource MutedTextBrush}"> - @@ -443,9 +465,12 @@ - + AutomationProperties.Name="Selected module details" + AutomationProperties.IsOffscreenBehavior="{Binding IsDetailTrayOpen, Converter={StaticResource BooleanToOffscreenBehaviorConverter}}"> @@ -464,10 +489,12 @@ - @@ -493,9 +520,11 @@ @@ -518,15 +547,18 @@ + Visibility="{Binding Impact.RequiresExplorerRestart, Converter={StaticResource BooleanToVisibilityConverter}}" + AutomationProperties.IsOffscreenBehavior="{Binding Impact.RequiresExplorerRestart, Converter={StaticResource BooleanToOffscreenBehaviorConverter}}" /> @@ -542,9 +574,11 @@ - + Background="Transparent" IsHitTestVisible="False" + AutomationProperties.IsOffscreenBehavior="{Binding IsDetailTrayOpen, Converter={StaticResource BooleanToOffscreenBehaviorConverter}, ConverterParameter=Inverse}"> @@ -112,9 +119,10 @@ - + @@ -131,12 +140,14 @@ - + @@ -157,12 +168,14 @@ - + @@ -186,12 +199,14 @@ - + @@ -214,12 +229,14 @@ - + @@ -251,9 +268,10 @@ - + @@ -269,24 +288,29 @@ - - + diff --git a/src/WinRestoreKit.Wpf/Views/Controls/SnapshotEventList.xaml b/src/WinRestoreKit.Wpf/Views/Controls/SnapshotEventList.xaml index cee0074..f33e35e 100644 --- a/src/WinRestoreKit.Wpf/Views/Controls/SnapshotEventList.xaml +++ b/src/WinRestoreKit.Wpf/Views/Controls/SnapshotEventList.xaml @@ -20,6 +20,7 @@ + @@ -46,7 +47,8 @@ SelectedItem="{Binding SelectedEvent, Mode=TwoWay}" SelectionMode="Single" AutomationProperties.Name="Snapshots" - AutomationProperties.HelpText="Left and Right Arrow move snapshots. Enter opens a verified or partial snapshot; failed and unreadable entries open details." + AutomationProperties.AutomationId="TimelineEventList" + AutomationProperties.HelpText="Left and Right Arrow move snapshots. Double-click or press Enter to open a verified or partial snapshot; failed and unreadable entries open details." KeyboardNavigation.DirectionalNavigation="Continue" ItemTemplate="{StaticResource SnapshotEventRowTemplate}" ItemContainerStyle="{StaticResource TimelineEventItemStyle}" diff --git a/src/WinRestoreKit.Wpf/Views/Controls/SnapshotEventList.xaml.cs b/src/WinRestoreKit.Wpf/Views/Controls/SnapshotEventList.xaml.cs index 0e91838..572201b 100644 --- a/src/WinRestoreKit.Wpf/Views/Controls/SnapshotEventList.xaml.cs +++ b/src/WinRestoreKit.Wpf/Views/Controls/SnapshotEventList.xaml.cs @@ -39,6 +39,16 @@ private async void OnPreviewKeyDown(object sender, KeyEventArgs e) } } + private async void OnItemMouseDoubleClick(object sender, MouseButtonEventArgs e) + { + if (sender is not ListBoxItem item || DataContext is not TimelineViewModel viewModel) + return; + + TimelineEventList.SelectedItem = item.DataContext; + e.Handled = true; + await viewModel.OpenSelectedAsync(); + } + private void OnSelectionChanged(object sender, SelectionChangedEventArgs e) { if (TimelineEventList.SelectedIndex >= 0) diff --git a/src/WinRestoreKit.Wpf/Views/SettingsView.xaml b/src/WinRestoreKit.Wpf/Views/SettingsView.xaml index f9bd6ad..695a9aa 100644 --- a/src/WinRestoreKit.Wpf/Views/SettingsView.xaml +++ b/src/WinRestoreKit.Wpf/Views/SettingsView.xaml @@ -30,7 +30,24 @@ + AutomationProperties.Name="Theme" + AutomationProperties.AutomationId="SettingsThemeSelector"> + + + diff --git a/src/WinRestoreKit.Wpf/Views/TimelineView.xaml b/src/WinRestoreKit.Wpf/Views/TimelineView.xaml index e8da4d8..515cc48 100644 --- a/src/WinRestoreKit.Wpf/Views/TimelineView.xaml +++ b/src/WinRestoreKit.Wpf/Views/TimelineView.xaml @@ -12,9 +12,37 @@ + + + + @@ -43,6 +71,25 @@ + + + + + + + -