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.
This commit is contained in:
2026-11-01 07:46:10 -08:00
parent bd669f2e73
commit fdb27f0758
14 changed files with 403 additions and 55 deletions
+79
View File
@@ -0,0 +1,79 @@
using ytLive.Helpers;
using ytLive.Models;
namespace ytLive.ViewModels;
/// <summary>
/// Preview undo/redo (creator directive 2026-11-01): Ctrl+Z and Ctrl+Shift+Z in edit mode.
///
/// <para>The stack lives on the view model rather than in <c>PreviewPane</c> 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.</para>
///
/// <para><b>Gated on edit mode.</b> 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.</para>
/// </summary>
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!;
/// <summary>Call immediately BEFORE a layout mutation — once per gesture, never per mouse
/// move, or a single drag eats the whole stack.</summary>
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);
}
/// <summary>Dropped on layout load: undoing across a reload would resurrect a layout the
/// creator never had.</summary>
private void ClearPreviewHistory()
{
_previewHistory.Clear();
NotifyHistoryChanged();
}
}
+3
View File
@@ -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));
+1
View File
@@ -18,6 +18,7 @@ public partial class MainViewModel
{
var scene = StagedScene;
if (scene == null) return;
PushPreviewUndo();
var sourceType = parameter is SourceType typed
? typed
+4 -2
View File
@@ -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();