From fdb27f07586becdc819004e55074e6c1b872ada9 Mon Sep 17 00:00:00 2001 From: gramps Date: Sun, 1 Nov 2026 07:46:10 -0800 Subject: [PATCH] feat(ui): gear to the rail, EDIT on the panel edge, preview undo/redo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three creator directives (2026-11-01), done ahead of the surge. **Gear moved to the preview rail** — first tab, orange (Brush.Attention), gear icon only. Left-click opens the same app menu the top bar gear owned (Settings / Bug / Feature / About), right-click goes straight to App Settings; both behaviours moved with it, so nothing lost an entry point. TopBar keeps the wordmark and EDIT. **EDIT right edge aligned to the layers panel.** The work area is margin 8 + a 360px column, the top bar is padding 16, so the shared edge is 352 inside the bar. TopBar column 0 is now a fixed 352 grid with EDIT right-aligned in it — alignment by arithmetic instead of by eye. **Preview undo/redo: Ctrl+Z / Ctrl+Shift+Z.** Models/PreviewHistory snapshots element REFERENCES plus geometry, visibility, order and membership — a deep clone would mirror every property of a dozen element subclasses and quietly miss the next one added. One snapshot per gesture (pushed at drag/resize start, never in the move loop), bounded at 50, redo survives multiple undos, cleared on layout load. Gated on EditingActive: undo while the preview shows program output would rewrite a scene nobody is looking at. Honest scope, in the type's doc comment: layout edits only. Colour/opacity/border/font edits are not captured yet, so Ctrl+Z after one of those restores the last layout change. 6 tests in PreviewHistoryTests; 19 pass across it and the neighbouring UI classes; Debug and Release both build at 0 warnings. --- Controls/PreviewPane.xaml | 22 +++++ Controls/PreviewPane.xaml.cs | 23 ++++++ Controls/TopBar.xaml | 48 +++++------ Controls/TopBar.xaml.cs | 20 ----- HANDOFF.md | 8 +- MainWindow.xaml | 8 ++ Models/PreviewHistory.cs | 115 ++++++++++++++++++++++++++ TASKS.md | 2 +- ViewModels/MainViewModel.Preview.cs | 79 ++++++++++++++++++ ViewModels/MainViewModel.Scenes.cs | 3 + ViewModels/MainViewModel.Sources.cs | 1 + ViewModels/MainViewModel.cs | 6 +- docs/ai/ui-surface.md | 3 +- ytLive.Tests/PreviewHistoryTests.cs | 120 ++++++++++++++++++++++++++++ 14 files changed, 403 insertions(+), 55 deletions(-) create mode 100644 Models/PreviewHistory.cs create mode 100644 ViewModels/MainViewModel.Preview.cs create mode 100644 ytLive.Tests/PreviewHistoryTests.cs diff --git a/Controls/PreviewPane.xaml b/Controls/PreviewPane.xaml index 60a1e7e..87880bc 100644 --- a/Controls/PreviewPane.xaml +++ b/Controls/PreviewPane.xaml @@ -883,6 +883,28 @@ + + + + + + + + + + + + + diff --git a/Controls/PreviewPane.xaml.cs b/Controls/PreviewPane.xaml.cs index 97ad74e..a9cceb4 100644 --- a/Controls/PreviewPane.xaml.cs +++ b/Controls/PreviewPane.xaml.cs @@ -459,6 +459,7 @@ public partial class PreviewPane : UserControl if (selected is { } sel && IsDraggableElement(sel) && HitHandle(e.GetPosition(grid), sel)) { + vm.PushPreviewUndo(); _isResizing = true; _resizeAspect = sel.ClipShape == ClipShape.Round ? 1 : sel.Width / Math.Max(1, sel.Height); grid.CaptureMouse(); @@ -470,6 +471,9 @@ public partial class PreviewPane : UserControl if (hit != null) { vm.SelectedElement = hit; + // One snapshot per gesture: pushing inside the move loop would fill the stack with + // a hundred one-pixel steps. + vm.PushPreviewUndo(); _isDraggingOverlay = true; _grabOffset = new Point(p.X - hit.X, p.Y - hit.Y); grid.CaptureMouse(); @@ -689,4 +693,23 @@ public partial class PreviewPane : UserControl ZoomVisualBrush.Visual = null; } } + + /// Rail settings tab (creator ruling 2026-11-01): left-click opens the same app menu + /// the top-bar gear used to own, so Bug / Feature / About keep their only entry point. + private void SettingsTab_Click(object sender, MouseButtonEventArgs e) + { + if (sender is not FrameworkElement { ContextMenu: { } menu } anchor) return; + menu.PlacementTarget = anchor; + menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Left; + menu.IsOpen = true; + e.Handled = true; + } + + /// Right-click goes straight to App Settings — the same shortcut the old gear had. + private void SettingsTab_PreviewMouseRightButtonDown(object sender, MouseButtonEventArgs e) + { + e.Handled = true; + if (DataContext is MainViewModel { OpenSettingsCommand: { } open } && open.CanExecute(null)) + open.Execute(null); + } } diff --git a/Controls/TopBar.xaml b/Controls/TopBar.xaml index e21f9af..0677526 100644 --- a/Controls/TopBar.xaml +++ b/Controls/TopBar.xaml @@ -5,16 +5,25 @@ x:Name="TopBarRoot"> - + + - - - - - + - + @@ -68,7 +62,7 @@ - + + + + + + diff --git a/Models/PreviewHistory.cs b/Models/PreviewHistory.cs new file mode 100644 index 0000000..b98a09e --- /dev/null +++ b/Models/PreviewHistory.cs @@ -0,0 +1,115 @@ +namespace ytLive.Models; + +/// +/// Undo/redo for what the creator does in the preview: move, resize, reorder, add, remove, hide. +/// +/// Snapshots hold element REFERENCES, not copies. The model has a dozen subclasses +/// with dozens of properties each; a deep clone would be a large, fragile mirror of every one of +/// them, and it would silently miss the next field someone adds. Holding the live instances and +/// recording the values the preview can change (geometry, visibility, order, and which elements a +/// scene contains) means a new model property cannot break undo — it simply is not part of this +/// stack's contract. +/// +/// Scope, stated plainly: this covers layout edits. Property edits (colour, opacity, +/// border, font) are not captured yet — pressing Ctrl+Z after one of those restores the last +/// layout change instead. That is a limitation, not a bug to discover later. +/// +/// Bounded: entries; the oldest falls off. Snapshots are +/// references plus a handful of doubles, so the stack costs kilobytes, not megabytes. +/// +public sealed class PreviewHistory +{ + public const int MaxDepth = 50; + + private readonly List _undo = new(); + private readonly List _redo = new(); + + public bool CanUndo => _undo.Count > 0; + public bool CanRedo => _redo.Count > 0; + public int UndoDepth => _undo.Count; + public int RedoDepth => _redo.Count; + + /// Record the layout as it is now, before an edit. Clears the redo branch — a new + /// edit after an undo is a new future. + public void Capture(IEnumerable scenes) + { + _undo.Add(LayoutState.From(scenes)); + _redo.Clear(); + if (_undo.Count > MaxDepth) _undo.RemoveAt(0); + } + + /// Restore the previous layout. The current layout is pushed onto the redo stack, so + /// redo is exact even after several undos. + public bool Undo(IEnumerable scenes) + { + if (_undo.Count == 0) return false; + var sceneList = scenes as IList ?? scenes.ToList(); + _redo.Add(LayoutState.From(sceneList)); + + var previous = _undo[^1]; + _undo.RemoveAt(_undo.Count - 1); + previous.RestoreTo(sceneList); + return true; + } + + public bool Redo(IEnumerable scenes) + { + if (_redo.Count == 0) return false; + var sceneList = scenes as IList ?? scenes.ToList(); + _undo.Add(LayoutState.From(sceneList)); + + var next = _redo[^1]; + _redo.RemoveAt(_redo.Count - 1); + next.RestoreTo(sceneList); + return true; + } + + /// Dropped when the layout is reloaded from disk — undoing across a reload would + /// resurrect a layout the creator never had. + public void Clear() + { + _undo.Clear(); + _redo.Clear(); + } + + /// One scene's contents and geometry at a point in time. + private sealed record SceneState(Scene Scene, SceneElement[] Order, ElementState[] Elements); + + private sealed record ElementState(SceneElement Element, double X, double Y, double Width, double Height, bool IsVisible); + + private sealed record LayoutState(SceneState[] Scenes) + { + public static LayoutState From(IEnumerable scenes) => new( + scenes.Select(scene => new SceneState( + scene, + scene.Elements.ToArray(), + scene.Elements.Select(element => new ElementState( + element, element.X, element.Y, element.Width, element.Height, element.IsVisible)).ToArray())) + .ToArray()); + + public void RestoreTo(IList scenes) + { + foreach (var state in Scenes) + { + // A scene removed from the layout since the snapshot is not restored — the + // layout, not the history, owns which scenes exist. + if (!scenes.Contains(state.Scene)) continue; + + foreach (var element in state.Elements) + { + element.Element.X = element.X; + element.Element.Y = element.Y; + element.Element.Width = element.Width; + element.Element.Height = element.Height; + element.Element.IsVisible = element.IsVisible; + } + + var elements = state.Scene.Elements; + // Membership AND order: this is what makes add / remove / reorder undo-able. + if (elements.SequenceEqual(state.Order)) continue; + elements.Clear(); + foreach (var element in state.Order) elements.Add(element); + } + } + } +} diff --git a/TASKS.md b/TASKS.md index 7961ad2..17e283c 100644 --- a/TASKS.md +++ b/TASKS.md @@ -52,7 +52,7 @@ persisted; renderer mirrors `Services/ChatBoxRenderer.cs`. | 34 | ☐ | Scheduled streams (Text-drawer version) | [`task-34`](TASKS/task-34-scheduled-streams.md) | | 35 | ☐ | Scene-linked audio | [`task-35`](TASKS/task-35-scene-linked-audio.md) | | 37 | ☐ | Defaults vs current layout split (build-id); enables one-click revert to defaults | [`task-37`](TASKS/task-37-defaults-current-split.md) | -| — | ☐ | **Preview undo (Ctrl+Z / Ctrl+Shift+Z)** — in edit mode, revert the last preview change (drag, resize, add, remove, property edit); bounded stack, cleared on scene change or layout load. The natural partner to 37: undo covers the slip, defaults cover the mess | this line | +| — | ✅ | **Preview undo (Ctrl+Z / Ctrl+Shift+Z)** — done 2026-11-01: `Models/PreviewHistory` + `MainViewModel.Preview`, one step per gesture, gated on `EditingActive`, cleared on layout load. Property edits not yet in the stack | [`ui-surface`](docs/ai/ui-surface.md) | | 39·2 | ☐ | YPP slice 2: Analytics scopes, watch-hours/Shorts velocity + ETA | [`task-39`](TASKS/task-39-ypp-journey-tracker.md) | | 49 | ☐ | Chat profanity filter: local, opt-in, **non-persistent**, user-supplied word list | [`task-49`](TASKS/task-49-chat-profanity-filter.md) | | 50 | ☐ | One-button publish of the local recording (resumable upload) | [`task-50`](TASKS/task-50-publish-recording.md) | diff --git a/ViewModels/MainViewModel.Preview.cs b/ViewModels/MainViewModel.Preview.cs new file mode 100644 index 0000000..651d0db --- /dev/null +++ b/ViewModels/MainViewModel.Preview.cs @@ -0,0 +1,79 @@ +using ytLive.Helpers; +using ytLive.Models; + +namespace ytLive.ViewModels; + +/// +/// Preview undo/redo (creator directive 2026-11-01): Ctrl+Z and Ctrl+Shift+Z in edit mode. +/// +/// The stack lives on the view model rather than in PreviewPane because the edit +/// surface is not one control — a layer can be moved by a drag, removed from the layers list, or +/// hidden from the properties panel — and undo has to cover all of them or it is a lie. +/// +/// Gated on edit mode. Undo only means something while the preview is the edit +/// surface; with editing off the preview shows program output and a stray Ctrl+Z would silently +/// rewrite a scene the creator isn't looking at. +/// +public partial class MainViewModel +{ + private readonly PreviewHistory _previewHistory = new(); + + public bool CanUndoPreview => EditingActive && _previewHistory.CanUndo; + public bool CanRedoPreview => EditingActive && _previewHistory.CanRedo; + + public RelayCommand UndoPreviewCommand { get; private set; } = null!; + public RelayCommand RedoPreviewCommand { get; private set; } = null!; + + /// Call immediately BEFORE a layout mutation — once per gesture, never per mouse + /// move, or a single drag eats the whole stack. + internal void PushPreviewUndo() + { + if (!EditingActive) return; + _previewHistory.Capture(Scenes); + NotifyHistoryChanged(); + } + + private void UndoPreview() + { + if (!EditingActive || !_previewHistory.Undo(Scenes)) return; + AfterHistoryStep(); + } + + private void RedoPreview() + { + if (!EditingActive || !_previewHistory.Redo(Scenes)) return; + AfterHistoryStep(); + } + + private void AfterHistoryStep() + { + // Restored geometry has to reach the compositor: the baked region is cached per scene + // and keyed on element identity, so a pure value change is invisible to it until the + // cache is dropped. Without this, undo moves the model and the picture stays put. + foreach (var scene in Scenes) _sceneGraph.InvalidateBake(scene); + ScheduleSave(); + NotifyHistoryChanged(); + } + + private void NotifyHistoryChanged() + { + OnPropertyChanged(nameof(CanUndoPreview)); + OnPropertyChanged(nameof(CanRedoPreview)); + } + + private void InitPreviewHistory() + { + // RelayCommand queries CommandManager.RequerySuggested, so no explicit invalidation is + // needed when CanExecute changes. + UndoPreviewCommand = new RelayCommand(_ => UndoPreview(), _ => CanUndoPreview); + RedoPreviewCommand = new RelayCommand(_ => RedoPreview(), _ => CanRedoPreview); + } + + /// Dropped on layout load: undoing across a reload would resurrect a layout the + /// creator never had. + private void ClearPreviewHistory() + { + _previewHistory.Clear(); + NotifyHistoryChanged(); + } +} diff --git a/ViewModels/MainViewModel.Scenes.cs b/ViewModels/MainViewModel.Scenes.cs index b597701..f5173ab 100644 --- a/ViewModels/MainViewModel.Scenes.cs +++ b/ViewModels/MainViewModel.Scenes.cs @@ -87,6 +87,9 @@ public partial class MainViewModel { if (!SetProperty(ref _editingActive, value)) return; OnPropertyChanged(nameof(IsEditMode)); + // Undo only exists while the preview is the edit surface. + OnPropertyChanged(nameof(CanUndoPreview)); + OnPropertyChanged(nameof(CanRedoPreview)); // EDIT decides whether the ON-AIR start is live or a test (creator ruling 2026-10-30). OnPropertyChanged(nameof(PrimaryStartKind)); OnPropertyChanged(nameof(PrimaryStartButtonLabel)); diff --git a/ViewModels/MainViewModel.Sources.cs b/ViewModels/MainViewModel.Sources.cs index 0660369..06e0d5b 100644 --- a/ViewModels/MainViewModel.Sources.cs +++ b/ViewModels/MainViewModel.Sources.cs @@ -18,6 +18,7 @@ public partial class MainViewModel { var scene = StagedScene; if (scene == null) return; + PushPreviewUndo(); var sourceType = parameter is SourceType typed ? typed diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index f6620c7..01dcdf7 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -208,9 +208,9 @@ public partial class MainViewModel : ViewModelBase AddSourceCommand = new RelayCommand(parameter => AddSource(parameter)); AddWebcamCommand = new RelayCommand(_ => _ = AddWebcamToStagedSceneAsync()); AddImageCommand = new RelayCommand(_ => AddImage()); - RemoveSourceCommand = new RelayCommand(element => RemoveElement(element as SceneElement)); + RemoveSourceCommand = new RelayCommand(element => { PushPreviewUndo(); RemoveElement(element as SceneElement); }); EditElementCommand = new RelayCommand(element => BeginEditElement(element as SceneElement)); - ToggleElementVisibilityCommand = new RelayCommand(element => ToggleElementVisibility(element as SceneElement)); + ToggleElementVisibilityCommand = new RelayCommand(element => { PushPreviewUndo(); ToggleElementVisibility(element as SceneElement); }); BrowseAlertVideoCommand = new RelayCommand(element => BrowseAlertVideo(element as Source), element => element is Source { Type: SourceType.AlertBox }); ResetAlertVideoCommand = new RelayCommand(element => ResetAlertVideo(element as Source), element => element is Source { Type: SourceType.AlertBox }); ChangeWebcamCommand = new RelayCommand(_ => _ = ChangeWebcamAsync(), _ => CanChangeWebcam); @@ -233,6 +233,7 @@ public partial class MainViewModel : ViewModelBase ToggleGameMuteCommand = new RelayCommand(_ => ToggleGameMute()); OpenMicPickerCommand = new RelayCommand(_ => PickMicrophone()); OpenSettingsCommand = new RelayCommand(_ => ShowOverlay(nameof(IsSettingsOpen), "App Settings")); + InitPreviewHistory(); OpenBugCommand = new RelayCommand(_ => ShowOverlay(nameof(IsBugOpen), "Report Bug")); OpenFeatureCommand = new RelayCommand(_ => ShowOverlay(nameof(IsFeatureOpen), "Feature Request")); OpenAboutCommand = new RelayCommand(_ => ShowOverlay(nameof(IsAboutOpen), "About")); @@ -450,6 +451,7 @@ public partial class MainViewModel : ViewModelBase { _isLoading = false; } + ClearPreviewHistory(); StagedScene = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, SceneCatalog.Starting)) ?? Scenes.FirstOrDefault(); UpdateActiveBackground(); UpdateActiveChatBoxPreviews(); diff --git a/docs/ai/ui-surface.md b/docs/ai/ui-surface.md index 8c3273c..0200022 100644 --- a/docs/ai/ui-surface.md +++ b/docs/ai/ui-surface.md @@ -103,7 +103,8 @@ The app is a **director's control surface**, not an editor. This replaces the OB **Current layout (2026-10-26 redesign commits):** `MainWindow` has a top bar and health banner, then a two-column work area with a fixed 360px left column and preview column. Its top row holds stream readouts/resolution on the left and the five-scene strip above the preview; the lower row holds the left panel and central preview. The bottom bar is now only a height-preserving spacer. - **Left panel:** `EditingActive` is an explicit `EDIT` toggle, not a mode inferred from `StreamStatus`. On = scene layers + properties; off = YouTube chat. Go Live turns editing off and stopping restores it; recording does not automatically switch it off. The staged scene is the preview/edit target; `LiveScene` is the program output. -- **Top bar:** the `EDIT` toggle sits beside the REC/ON-AIR session controls; the brand/gear and account surfaces remain at the sides. The gear's left-click menu remains; right-click opens App Settings directly. +- **Top bar:** the `EDIT` toggle sits beside the REC/ON-AIR session controls; the brand and account surfaces remain at the sides. Its **right edge aligns with the layers panel below** (`TopBar` column 0 is a fixed **352** = work-area margin 8 + 360px column − the bar's own 16px padding), so the button and the panel share an edge. The **settings gear moved to the preview rail** (2026-11-01): first tab, orange (`Brush.Attention`), gear icon only — left-click opens the app menu (Settings / Bug / Feature / About), right-click goes straight to App Settings. +- **Preview undo/redo (2026-11-01):** `Ctrl+Z` / `Ctrl+Shift+Z`, window-level bindings, live only while `EditingActive` — undo with the preview showing program output would rewrite a scene nobody is looking at. `Models/PreviewHistory` snapshots element **references** plus geometry, visibility, order and membership, so move / resize / reorder / add / remove / hide all undo in one step per gesture (`PushPreviewUndo` is called once at drag or resize start, never inside the move loop). Property edits (colour, opacity, border, font) are **not** captured yet — stated in the type's doc comment so nobody assumes otherwise. The stack clears on layout load. Guard: `ytLive.Tests/PreviewHistoryTests.cs`. - **Developer affordances are compiled out of release, not hidden.** Any control that must never ship (today: the all-resolutions override) lives in its own `UserControl` whose `Page` and `Compile` items are removed by `ytLive.csproj` when `$(Configuration) != 'Debug'`, and is added to its host at runtime inside `#if DEBUG` (`OverlayHost`). A collapsed row still exists in the visual tree of every shipped copy; "not compiled" is the only claim worth making. Label such rows with `Brush.Attention` (orange) while they are visible in Debug. Guard: `ytLive.Tests/OutputProfileTests.cs` asserts the build rule, and `Helpers/DevBuild.cs` is the single source for "is this a dev build". - **Scene strip:** five fixed-size cards above the preview show the scene name and hotkey; the staged scene carries a `PROGRAM` badge, and staged/live borders use orange. Click stages a scene while idle; while streaming or recording it changes program output and starts the selected transition. - **Preview rail:** stream info, YPP, Socials, and TEST are icon/color tabs on the preview's right edge. Socials opens an animated in-preview drawer; it is no longer a standalone modal window. The bottom live-controls row retains TRAX and the game/mic meters and controls. diff --git a/ytLive.Tests/PreviewHistoryTests.cs b/ytLive.Tests/PreviewHistoryTests.cs new file mode 100644 index 0000000..8e79330 --- /dev/null +++ b/ytLive.Tests/PreviewHistoryTests.cs @@ -0,0 +1,120 @@ +using System.Collections.ObjectModel; +using System.Linq; +using Xunit; +using ytLive.Models; + +namespace ytLive.Tests; + +/// +/// Preview undo/redo (creator directive 2026-11-01). The contract that matters: one gesture is +/// one undo step, a restored layout is exactly what was there before, and redo survives several +/// undos — because a stack that only works one step deep is worse than none. +/// +public class PreviewHistoryTests +{ + private static Scene NewScene(params SceneElement[] elements) + { + var scene = new Scene { Name = "Live" }; + foreach (var element in elements) scene.Elements.Add(element); + return scene; + } + + private static SceneElement NewElement(double x = 0, double y = 0, double w = 100, double h = 100) + => new Source { Name = "Layer", X = x, Y = y, Width = w, Height = h }; + + [Fact] + public void Undo_restores_geometry_and_redo_reapplies_it() + { + var element = NewElement(10, 20); + var scenes = new ObservableCollection { NewScene(element) }; + var history = new PreviewHistory(); + + history.Capture(scenes); + element.X = 300; + element.Y = 400; + + Assert.True(history.Undo(scenes)); + Assert.Equal(10, element.X); + Assert.Equal(20, element.Y); + + Assert.True(history.Redo(scenes)); + Assert.Equal(300, element.X); + Assert.Equal(400, element.Y); + } + + [Fact] + public void Undo_removes_a_layer_added_after_the_snapshot_and_redo_brings_it_back() + { + var first = NewElement(); + var scene = NewScene(first); + var scenes = new ObservableCollection { scene }; + var history = new PreviewHistory(); + + history.Capture(scenes); + var added = NewElement(); + scene.Elements.Add(added); + + history.Undo(scenes); + Assert.Single(scene.Elements); + Assert.DoesNotContain(added, scene.Elements); + + history.Redo(scenes); + Assert.Equal(2, scene.Elements.Count); + Assert.Contains(added, scene.Elements); + } + + [Fact] + public void Undo_restores_a_removed_layer_in_its_original_position() + { + var top = NewElement(); + var middle = NewElement(); + var bottom = NewElement(); + var scene = NewScene(top, middle, bottom); + var scenes = new ObservableCollection { scene }; + + var history = new PreviewHistory(); + history.Capture(scenes); + scene.Elements.Remove(middle); + + history.Undo(scenes); + Assert.Equal(new SceneElement[] { top, middle, bottom }, scene.Elements.ToArray()); + } + + [Fact] + public void A_new_edit_after_undo_drops_the_redo_branch() + { + var element = NewElement(); + var scenes = new ObservableCollection { NewScene(element) }; + var history = new PreviewHistory(); + + history.Capture(scenes); + element.X = 50; + history.Undo(scenes); + + Assert.True(history.CanRedo); + history.Capture(scenes); + Assert.False(history.CanRedo); + } + + [Fact] + public void The_stack_is_bounded() + { + var scenes = new ObservableCollection { NewScene(NewElement()) }; + var history = new PreviewHistory(); + + for (var i = 0; i < PreviewHistory.MaxDepth + 20; i++) history.Capture(scenes); + + Assert.Equal(PreviewHistory.MaxDepth, history.UndoDepth); + } + + [Fact] + public void Undo_with_nothing_captured_is_a_no_op() + { + var element = NewElement(5, 5); + var scenes = new ObservableCollection { NewScene(element) }; + var history = new PreviewHistory(); + + Assert.False(history.Undo(scenes)); + Assert.Equal(5, element.X); + } +}