Files
LlamaCasty/Models/PreviewHistory.cs
T
gramps fdb27f0758 feat(ui): gear to the rail, EDIT on the panel edge, preview undo/redo
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.
2026-11-01 07:46:10 -08:00

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);
}
}
}
}