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.
116 lines
4.7 KiB
C#
116 lines
4.7 KiB
C#
namespace ytLive.Models;
|
|
|
|
/// <summary>
|
|
/// Undo/redo for what the creator does in the preview: move, resize, reorder, add, remove, hide.
|
|
///
|
|
/// <para><b>Snapshots hold element REFERENCES, not copies.</b> 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.</para>
|
|
///
|
|
/// <para><b>Scope, stated plainly:</b> 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.</para>
|
|
///
|
|
/// <para><b>Bounded:</b> <see cref="MaxDepth"/> entries; the oldest falls off. Snapshots are
|
|
/// references plus a handful of doubles, so the stack costs kilobytes, not megabytes.</para>
|
|
/// </summary>
|
|
public sealed class PreviewHistory
|
|
{
|
|
public const int MaxDepth = 50;
|
|
|
|
private readonly List<LayoutState> _undo = new();
|
|
private readonly List<LayoutState> _redo = new();
|
|
|
|
public bool CanUndo => _undo.Count > 0;
|
|
public bool CanRedo => _redo.Count > 0;
|
|
public int UndoDepth => _undo.Count;
|
|
public int RedoDepth => _redo.Count;
|
|
|
|
/// <summary>Record the layout as it is now, before an edit. Clears the redo branch — a new
|
|
/// edit after an undo is a new future.</summary>
|
|
public void Capture(IEnumerable<Scene> scenes)
|
|
{
|
|
_undo.Add(LayoutState.From(scenes));
|
|
_redo.Clear();
|
|
if (_undo.Count > MaxDepth) _undo.RemoveAt(0);
|
|
}
|
|
|
|
/// <summary>Restore the previous layout. The current layout is pushed onto the redo stack, so
|
|
/// redo is exact even after several undos.</summary>
|
|
public bool Undo(IEnumerable<Scene> scenes)
|
|
{
|
|
if (_undo.Count == 0) return false;
|
|
var sceneList = scenes as IList<Scene> ?? 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<Scene> scenes)
|
|
{
|
|
if (_redo.Count == 0) return false;
|
|
var sceneList = scenes as IList<Scene> ?? scenes.ToList();
|
|
_undo.Add(LayoutState.From(sceneList));
|
|
|
|
var next = _redo[^1];
|
|
_redo.RemoveAt(_redo.Count - 1);
|
|
next.RestoreTo(sceneList);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>Dropped when the layout is reloaded from disk — undoing across a reload would
|
|
/// resurrect a layout the creator never had.</summary>
|
|
public void Clear()
|
|
{
|
|
_undo.Clear();
|
|
_redo.Clear();
|
|
}
|
|
|
|
/// <summary>One scene's contents and geometry at a point in time.</summary>
|
|
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<Scene> 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<Scene> 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);
|
|
}
|
|
}
|
|
}
|
|
}
|