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:
@@ -883,6 +883,28 @@
|
||||
</Border>
|
||||
<!-- The stacked white tabs -->
|
||||
<StackPanel Orientation="Vertical" HorizontalAlignment="Right" VerticalAlignment="Stretch">
|
||||
<!-- Settings: the first tab, orange, gear only (creator ruling 2026-11-01 —
|
||||
moved off the top bar). Left-click opens the same app menu
|
||||
(Settings / Bug / Feature / About); right-click goes straight
|
||||
to App Settings, matching the old gear's behaviour exactly. -->
|
||||
<Border Width="30" VerticalAlignment="Center" Background="{StaticResource Brush.Attention}"
|
||||
CornerRadius="4,0,0,4" Padding="0,10" Cursor="Hand" Margin="0,4,0,0"
|
||||
ToolTip="App menu — right-click for Settings"
|
||||
MouseLeftButtonUp="SettingsTab_Click"
|
||||
PreviewMouseRightButtonDown="SettingsTab_PreviewMouseRightButtonDown">
|
||||
<Border.ContextMenu>
|
||||
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
|
||||
<MenuItem Header="App Settings" Command="{Binding OpenSettingsCommand}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Report Bug" Command="{Binding OpenBugCommand}"/>
|
||||
<MenuItem Header="Feature Request" Command="{Binding OpenFeatureCommand}"/>
|
||||
<MenuItem Header="About" Command="{Binding OpenAboutCommand}"/>
|
||||
</ContextMenu>
|
||||
</Border.ContextMenu>
|
||||
<Path Data="M19.43,12.98c0.04,-0.32 0.07,-0.65 0.07,-0.98c0,-0.33 -0.03,-0.66 -0.07,-0.98l2.11,-1.65c0.19,-0.15 0.24,-0.42 0.12,-0.64l-2,-3.46c-0.12,-0.22 -0.38,-0.31 -0.61,-0.22l-2.49,1c-0.52,-0.4 -1.08,-0.73 -1.69,-0.98l-0.38,-2.65C14.46,2.18 14.25,2 14,2h-4c-0.25,0 -0.46,0.18 -0.49,0.42l-0.38,2.65c-0.61,0.25 -1.17,0.59 -1.69,0.98l-2.49,-1c-0.23,-0.09 -0.49,0 -0.61,0.22l-2,3.46c-0.13,0.22 -0.07,0.49 0.12,0.64l2.11,1.65c-0.04,0.32 -0.07,0.65 -0.07,0.98c0,0.33 0.03,0.66 0.07,0.98l-2.11,1.65c-0.19,0.15 -0.24,0.42 -0.12,0.64l2,3.46c0.12,0.22 0.38,0.31 0.61,0.22l2.49,-1c0.52,0.4 1.08,0.73 1.69,0.98l0.38,2.65c0.03,0.24 0.24,0.42 0.49,0.42h4c0.25,0 0.46,-0.18 0.49,-0.42l0.38,-2.65c0.61,-0.25 1.17,-0.59 1.69,-0.98l2.49,1c0.23,0.09 0.49,0 0.61,-0.22l2,-3.46c0.12,-0.22 0.07,-0.49 -0.12,-0.64L19.43,12.98zM12,15.5c-1.93,0 -3.5,-1.57 -3.5,-3.5c0,-1.93 1.57,-3.5 3.5,-3.5c1.93,0 3.5,1.57 3.5,3.5C15.5,13.93 13.93,15.5 12,15.5z"
|
||||
Fill="{StaticResource Brush.Background}" Width="18" Height="18" Stretch="Uniform"
|
||||
HorizontalAlignment="Center"/>
|
||||
</Border>
|
||||
<Border Width="30" VerticalAlignment="Center" Background="{StaticResource Brush.Youtube}"
|
||||
CornerRadius="4,0,0,4" Padding="0,10" Cursor="Hand" Margin="0,4,0,0"
|
||||
ToolTip="Broadcast text fields">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Right-click goes straight to App Settings — the same shortcut the old gear had.</summary>
|
||||
private void SettingsTab_PreviewMouseRightButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
e.Handled = true;
|
||||
if (DataContext is MainViewModel { OpenSettingsCommand: { } open } && open.CanExecute(null))
|
||||
open.Execute(null);
|
||||
}
|
||||
}
|
||||
|
||||
+21
-27
@@ -5,16 +5,25 @@
|
||||
x:Name="TopBarRoot">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<!-- 352 = the layers panel's right edge in the work area below: window margin 8 +
|
||||
360px column = 368, minus this bar's own 16px padding. EDIT is right-aligned in
|
||||
this column so the two edges line up exactly (creator ruling 2026-11-01). -->
|
||||
<ColumnDefinition Width="352"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- LOGO / BRAND — clicking opens the in-app About overlay; the gear sits
|
||||
a couple characters past the build stamp and opens App Settings. -->
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center"
|
||||
Margin="0,0,24,0">
|
||||
<Button Background="Transparent" BorderThickness="0" Cursor="Hand"
|
||||
<!-- BRAND + EDIT. The gear moved to the preview's right rail, first tab (2026-11-01):
|
||||
app-level actions live with the other pull-outs now. -->
|
||||
<Grid Grid.Column="0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- LOGO / BRAND — clicking opens the in-app About overlay. -->
|
||||
<Button Grid.Column="0" Background="Transparent" BorderThickness="0" Cursor="Hand"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Center" ToolTip="About LlamaCasty"
|
||||
Command="{Binding OpenAboutCommand}">
|
||||
<Button.Template>
|
||||
@@ -32,28 +41,13 @@
|
||||
ToolTipService.ToolTip="{x:Static helpers:BuildStamp.Label}"/><Run Text=" " FontSize="4"/>
|
||||
</TextBlock>
|
||||
</Button>
|
||||
<!-- Settings gear (user directive): a couple characters beyond the brand,
|
||||
nudged right a couple more places 2026-09-22 (build 77 feedback).
|
||||
Moved up from the bottom bar — one menu: App Settings / Bug / Feature / About. -->
|
||||
<Button Style="{StaticResource IconButton}" Margin="18,0,0,2" VerticalAlignment="Center"
|
||||
ToolTip="Menu" Click="GearButton_Click" PreviewMouseRightButtonDown="GearButton_PreviewMouseRightButtonDown">
|
||||
<Button.ContextMenu>
|
||||
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
|
||||
<MenuItem Header="App Settings" Command="{Binding OpenSettingsCommand}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Report Bug" Command="{Binding OpenBugCommand}"/>
|
||||
<MenuItem Header="Feature Request" Command="{Binding OpenFeatureCommand}"/>
|
||||
<MenuItem Header="About" Command="{Binding OpenAboutCommand}"/>
|
||||
</ContextMenu>
|
||||
</Button.ContextMenu>
|
||||
<Path Data="M19.43,12.98c0.04,-0.32 0.07,-0.65 0.07,-0.98c0,-0.33 -0.03,-0.66 -0.07,-0.98l2.11,-1.65c0.19,-0.15 0.24,-0.42 0.12,-0.64l-2,-3.46c-0.12,-0.22 -0.38,-0.31 -0.61,-0.22l-2.49,1c-0.52,-0.4 -1.08,-0.73 -1.69,-0.98l-0.38,-2.65C14.46,2.18 14.25,2 14,2h-4c-0.25,0 -0.46,0.18 -0.49,0.42l-0.38,2.65c-0.61,0.25 -1.17,0.59 -1.69,0.98l-2.49,-1c-0.23,-0.09 -0.49,0 -0.61,0.22l-2,3.46c-0.13,0.22 -0.07,0.49 0.12,0.64l2.11,1.65c-0.04,0.32 -0.07,0.65 -0.07,0.98c0,0.33 0.03,0.66 0.07,0.98l-2.11,1.65c-0.19,0.15 -0.24,0.42 -0.12,0.64l2,3.46c0.12,0.22 0.38,0.31 0.61,0.22l2.49,-1c0.52,0.4 1.08,0.73 1.69,0.98l0.38,2.65c0.03,0.24 0.24,0.42 0.49,0.42h4c0.25,0 0.46,-0.18 0.49,-0.42l0.38,-2.65c0.61,-0.25 1.17,-0.59 1.69,-0.98l2.49,1c0.23,0.09 0.49,0 0.61,-0.22l2,-3.46c0.12,-0.22 0.07,-0.49 -0.12,-0.64L19.43,12.98zM12,15.5c-1.93,0 -3.5,-1.57 -3.5,-3.5c0,-1.93 1.57,-3.5 3.5,-3.5c1.93,0 3.5,1.57 3.5,3.5C15.5,13.93 13.93,15.5 12,15.5z"
|
||||
Fill="{StaticResource Brush.Attention}" Width="18" Height="18" Stretch="Uniform"/>
|
||||
</Button>
|
||||
|
||||
<!-- EDIT: explicit toggle. Neutral = off (chat), blue = on (layers/properties). The
|
||||
trigger used to set the SAME colour as the resting state, so the button never
|
||||
looked pressed. -->
|
||||
<ToggleButton IsChecked="{Binding EditingActive, Mode=TwoWay}" Content="EDIT"
|
||||
Cursor="Hand" Height="32" Padding="14,0" Margin="18,0,0,2" VerticalAlignment="Center"
|
||||
looked pressed. Right edge aligns with the layers panel below. -->
|
||||
<ToggleButton Grid.Column="1" IsChecked="{Binding EditingActive, Mode=TwoWay}" Content="EDIT"
|
||||
Cursor="Hand" Height="32" Padding="14,0" Margin="18,0,0,2"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||
FontWeight="Bold" FontSize="12" Foreground="{StaticResource Brush.OnAccent}" BorderThickness="0">
|
||||
<ToggleButton.Template>
|
||||
<ControlTemplate TargetType="ToggleButton">
|
||||
@@ -68,7 +62,7 @@
|
||||
</ControlTemplate>
|
||||
</ToggleButton.Template>
|
||||
</ToggleButton>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- Session surface (top bar redesign): the bar is ONE surface rendered by
|
||||
the FIRST decision — Record or Stream. Each world is the whole bar; the
|
||||
|
||||
@@ -26,26 +26,6 @@ public partial class TopBar : UserControl
|
||||
_ = LoadAvatarImageAsync();
|
||||
}
|
||||
|
||||
private void GearButton_PreviewMouseRightButtonDown(object sender, System.Windows.Input.MouseButtonEventArgs e)
|
||||
{
|
||||
// Right-click goes straight to App Settings; the menu stays on left-click.
|
||||
e.Handled = true;
|
||||
if (DataContext is MainViewModel { OpenSettingsCommand: { } open } && open.CanExecute(null))
|
||||
open.Execute(null);
|
||||
}
|
||||
|
||||
private void GearButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
// One menu (Settings / Bug / Feature / About), opened on click — moved up
|
||||
// from the bottom bar so app-level actions live next to the brand.
|
||||
if (sender is Button { ContextMenu: { } menu } button)
|
||||
{
|
||||
menu.PlacementTarget = button;
|
||||
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
|
||||
menu.IsOpen = true;
|
||||
}
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task LoadAvatarImageAsync()
|
||||
{
|
||||
var vm = DataContext as MainViewModel;
|
||||
|
||||
+4
-4
@@ -17,10 +17,10 @@
|
||||
|
||||
## Next action
|
||||
|
||||
**Surge, 2026-11-02 → 11-07 — six days, all remaining code.** Day 1 is licensing: `IEntitlement`
|
||||
seam + Steam DLC impl + fake, `HasProOutput` on the seam, greyed tier entries, licence UI's four
|
||||
states, system messages, licence ID in About. Days 2–6 are in `TASKS.md` → "The surge".
|
||||
|
||||
**Surge, 2026-11-02 → 11-07 — six days, all remaining code.** Undo/redo, the rail gear and the
|
||||
EDIT alignment landed early on 11-01, so day 6 has room. Day 1 unchanged: licensing —
|
||||
`IEntitlement` + Steam DLC + fake, `HasProOutput` on the seam, greyed tiers, licence UI's four
|
||||
states, system messages, licence ID in About. Days 2–6 in `TASKS.md` → "The surge".
|
||||
No scope beyond that list. No polish before day 6.
|
||||
|
||||
## Landmines
|
||||
|
||||
@@ -22,6 +22,14 @@
|
||||
PreviewMouseLeftButtonDown="Window_PreviewMouseLeftButtonDown"
|
||||
ContextMenuOpening="Window_ContextMenuOpening">
|
||||
|
||||
<!-- Preview undo/redo (creator directive 2026-11-01). Window-level so the keys work wherever
|
||||
focus sits; the commands themselves refuse unless EditingActive, because undo with the
|
||||
preview showing program output would rewrite a scene nobody is looking at. -->
|
||||
<Window.InputBindings>
|
||||
<KeyBinding Gesture="Ctrl+Z" Command="{Binding UndoPreviewCommand}"/>
|
||||
<KeyBinding Gesture="Ctrl+Shift+Z" Command="{Binding RedoPreviewCommand}"/>
|
||||
</Window.InputBindings>
|
||||
|
||||
<Window.DataContext>
|
||||
<vm:MainViewModel/>
|
||||
</Window.DataContext>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) |
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -18,6 +18,7 @@ public partial class MainViewModel
|
||||
{
|
||||
var scene = StagedScene;
|
||||
if (scene == null) return;
|
||||
PushPreviewUndo();
|
||||
|
||||
var sourceType = parameter is SourceType typed
|
||||
? typed
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<Scene> { 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> { 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> { 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<Scene> { 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<Scene> { 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<Scene> { NewScene(element) };
|
||||
var history = new PreviewHistory();
|
||||
|
||||
Assert.False(history.Undo(scenes));
|
||||
Assert.Equal(5, element.X);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user