fix(reorder): save layer list AND preview layout immediately at drop; recording save dialog +10% (276→304)

Reorder: OnSceneElementsReordered now calls SaveLayoutNow() (was debounced
ScheduleSave) so a drag-drop persists instantly — one write stores the new
layer list (Source.SortOrder) and each layer's preview geometry (X/Y/W/H) in
the same rows. Reference: WPF ListBox drag-reorder requires an explicit persist
at drop; a debounce window lets a crash lose the drop.

Recording save dialog (RenameRecordingDialog.xaml): height 276→304 so the
Cancel/Save button row is no longer obscured; Save button named
SaveRecordingButton so the sizing test can assert it sits inside the client area.

Tests: deterministic seam test now asserts the dragged layer's geometry (preview
layout) is persisted with the new order; sizing test asserts 304 + textbox and
button-row bottoms within client area. 308/308 green.
This commit is contained in:
2026-09-18 10:20:25 -07:00
parent 2ac00db0da
commit f91bf8715d
6 changed files with 107 additions and 30 deletions
+22 -13
View File
@@ -55,31 +55,40 @@ the availability layer. TASK 26's app-wide gate is **superseded** by creator dir
## ✅ Committed — layer-list drag-to-reorder persists (creator-reported, 2026-09-18) ## ✅ Committed — layer-list drag-to-reorder persists (creator-reported, 2026-09-18)
The drag code in `Controls/LeftPanel.xaml.cs` reordered `StagedScene.Elements` directly `StagedScene.Elements` order (RemoveAt/Insert), bypassing SceneGraph's mutation surface, so the
(`RemoveAt`/`Insert` in `List_PreviewMouseMove`), bypassing `SceneGraph`'s mutation surface — the drop must be trapped and persisted. Fix (c117885): the drag sets `_dragReordered` and, on mouse
collection changed visually but nothing ever scheduled a save, so the new z-order was lost on release, `EndListDrag` funnels the drop through `MainViewModel.OnSceneElementsReordered()`
restart. Fix: the drag sets `_dragReordered` and `EndListDrag` funnels the drop through the new (Sources.cs) = `SceneGraph.InvalidateBake(staged) + SaveLayoutNow()` — an IMMEDIATE save at drop
`MainViewModel.OnSceneElementsReordered()` (Sources.cs) = `SceneGraph.InvalidateBake(staged) + (no debounce window, added 2026-09-18 after the creator re-reported it "doesn't save"; the single
ScheduleSave()`, i.e. the same background-save path every other mutation uses. **Good Dog test** write stores BOTH the **layer list** = Source `SortOrder` AND the **preview layout** = each layer's
`LayerReorderPersistenceTests` (real App + temp DB): reproduces the exact code-behind mutation, `X`/`Y`/`Width`/`Height` in the same Source rows; `LayoutStore.Save` deletes+reinserts Source rows
calls the trap, pumps the dispatcher until the debounced save lands, asserts the DB `Source` in `scene.Elements` order, `LayoutStore.Load` reads them back `ORDER BY SortOrder`).
**Good Dog tests** `LayerReorderPersistenceTests` (real App + temp DB): (1) reproduces the exact
code-behind mutation
calls the trap, pumps the dispatcher until the save lands, asserts the DB `Source`
SortOrder matches the in-memory `Elements` order. SortOrder matches the in-memory `Elements` order.
**Creator later insisted the reorder "doesn't save" even after the build — settled 2026-09-18 with a **Creator later insisted the reorder "doesn't save" even after the build — settled 2026-09-18 with a
REAL-gesture test.** My first test short-circuited `EndListDrag` — it never exercised the mouse REAL-gesture test.** My first test short-circuited `EndListDrag` — it never exercised the mouse
handlers (impossible with synthetic events: `e.GetPosition` reads the physical cursor). Added handlers (impossible with synthetic events: `e.GetPosition` reads the physical cursor). Added
`RealMouseDrag_OnTheLayerList_PersistsTheReorder`: real `SetCursorPos` + `mouse_event`/`SendInput` `RealMouseDrag_OnTheLayerList_PersistsTheReorder`: real `SetCursorPos` + `mouse_event`/`SendInput`
against the shown MainWindow physically drag row 3 (ImgC) onto row 1 (ImgA), pumping between steps, against the shown MainWindow physically drag row 3 (ImgC) onto row 1 (ImgA), pumping between steps,
then asserts the DB lands at `[Background, ImgC, ImgA, ImgB]` via the debounced save. **Both pass.** then asserts the DB lands at `[Background, ImgC, ImgA, ImgB]` — AND that ImgC's geometry
(X/Y/W/H, the preview layout) is intact in the same rows. **Both pass.**
The mouse-down/move/up → `_dragReordered` → `EndListDrag` → `OnSceneElementsReordered` → DB seam is The mouse-down/move/up → `_dragReordered` → `EndListDrag` → `OnSceneElementsReordered` → DB seam is
now proven with real input; a real user drag working differently would mean a stale binary (the now proven with real input; a real user drag working differently would mean a stale binary (the
Debug exe from 08:42 postdates fix commit 08:18) or a different deployment. Debug exe from 08:42 postdates fix commit 08:18) or a different deployment.
## ✅ Committed — Rename Recording dialog taller + full-screen hook unhooked on shutdown (2026-09-18) ## ✅ Committed — Rename Recording dialog taller + full-screen hook unhooked on shutdown (2026-09-18)
- `RenameRecordingDialog.xaml` `Height` 230 → **276** (+20%, creator: textbox cut off). The fix is - **WHERE/HOW:** the end-of-recording save dialog is `RenameRecordingDialog.xaml` (repo root — the
real: content naturally needs ~225px, the old client area at 230 was ~193px (clipped), at 276 it's dialog that pops up with a default file name when a recording is ended so it can be saved/renamed;
~239px (fits). **Good Dog test** `RenameRecordingDialogSizingTests`: shows the real dialog in the title "Rename Recording"). Its fixed `Height` attribute controls everything: 230 → **276** (+20%,
RealApp host and asserts the file-name textbox bottom edge is inside the client area. The dialog's textbox clipped at 230) → **304** (+10% more, creator: the +20% revealed the Cancel/Save button
row had ALSO been obscured). The Save button is named `SaveRecordingButton`.
**Good Dog test** `RenameRecordingDialogSizingTests`: shows the real dialog in the RealApp host and
asserts BOTH the file-name textbox bottom AND the Save button bottom sit inside the client area
(client area ≈ Height − ~37px of chrome; measure via `TransformToAncestor` against the dialog's
content root). The dialog's
`Icon` was changed from root-relative `/Assets/...` to the explicit `/ytLive;component/...` (the `Icon` was changed from root-relative `/Assets/...` to the explicit `/ytLive;component/...` (the
form `MainWindow.xaml` already uses) — root-relative only resolves in production via form `MainWindow.xaml` already uses) — root-relative only resolves in production via
`Application.ResourceAssembly`, so dialogs couldn't be constructed by tests otherwise. The other `Application.ResourceAssembly`, so dialogs couldn't be constructed by tests otherwise. The other
+22
View File
@@ -36,6 +36,28 @@ one rejected call inside a fallback ladder aborts the whole ladder and every unt
Applied in `MediaCaptureFrameSource`'s reader-subtype ladder (2026-09-15, webcam-take fix). Applied in `MediaCaptureFrameSource`'s reader-subtype ladder (2026-09-15, webcam-take fix).
### "SAVE-RECORDING DIALOG IS CLIPPING CONTENT" — WHERE + HOW (RECIPE)
**WHERE:** the end-of-recording modal (creator: "when I end a recording the app throws up a
save-recording dialog with a default filename") is **`RenameRecordingDialog.xaml` at the repo ROOT**
(title "Rename Recording", `x:Class="ytLive.RenameRecordingDialog"`). It is NOT one of the
`*Dialog.xaml` files under `Controls/` — grep for the window title, not the filename, when the
user names a dialog by what it does. Only fix dialogs the user actually named; do not "while I'm
here" bump sibling dialogs.
**HOW:** the dialog is `ResizeMode="NoResize"` with a fixed `Height` attribute; content client area
≈ `Height − ~37px` of window chrome. Sizing/margin changes pushed content down in Z-order until it
clipped — first the file-name textbox (230 too short), and after the +20% bump (→276) the creator
found the Cancel/Save button row had ALSO been obscured. Fixed `Height` to 304 (+10% more),
`x:Name` the last interactive control (`SaveRecordingButton`) so a test can measure it.
**Verify (never eyeball-predict):** `RenameRecordingDialogSizingTests` (RealApp host) instantiates
the real dialog, `Show()` + `UpdateLayout()`, then asserts the BOTTOM EDGE of each named control
(`TransformToAncestor` against the content root → `TransformBounds(RenderSize).Bottom`) is ≤ the
client `ActualHeight`. Every such dialog fix ships with that assertion for every control that was
reported obscured. (Client-area math means the button row needs MORE slack than the textbox: its
bottom sits deeper because of the `Margin="0,20,0,0"` above it.)
### SPIN GUARD → RESOLVED — web overlay transparency + bounding box (RECIPE) ### SPIN GUARD → RESOLVED — web overlay transparency + bounding box (RECIPE)
**THE ONE ROOT CAUSE THAT EXPLAINS EVERY FAILED TAKE:** WebView2's `CapturePreviewAsync` **THE ONE ROOT CAUSE THAT EXPLAINS EVERY FAILED TAKE:** WebView2's `CapturePreviewAsync`
+2 -2
View File
@@ -1,7 +1,7 @@
<Window x:Class="ytLive.RenameRecordingDialog" <Window x:Class="ytLive.RenameRecordingDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Rename Recording" Height="276" Width="460" Title="Rename Recording" Height="304" Width="460"
Icon="/ytLive;component/Assets/llamacasty-icon.png" Icon="/ytLive;component/Assets/llamacasty-icon.png"
WindowStartupLocation="CenterOwner" ResizeMode="NoResize" WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
ShowInTaskbar="False" Background="#1a1a2e"> ShowInTaskbar="False" Background="#1a1a2e">
@@ -32,7 +32,7 @@
Margin="0,20,0,0"> Margin="0,20,0,0">
<Button Content="Cancel" Click="Cancel_Click" <Button Content="Cancel" Click="Cancel_Click"
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/> Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
<Button Content="Save" Click="Save_Click" Style="{StaticResource YtButton}" MinWidth="90"/> <Button Content="Save" Click="Save_Click" Style="{StaticResource YtButton}" MinWidth="90" x:Name="SaveRecordingButton"/>
</StackPanel> </StackPanel>
</Grid> </Grid>
</Window> </Window>
+5 -2
View File
@@ -87,11 +87,14 @@ public partial class MainViewModel
/// <summary>Drag-to-reorder in the layer list mutates <see cref="Scene.Elements"/> /// <summary>Drag-to-reorder in the layer list mutates <see cref="Scene.Elements"/>
/// directly in code-behind, bypassing SceneGraph's mutation surface. Trap it here /// directly in code-behind, bypassing SceneGraph's mutation surface. Trap it here
/// so the new z-order persists like any other layout change.</summary> /// so the new z-order persists like any other layout change. Saves IMMEDIATELY at
/// drop — no debounce window — because a reorder is one discrete event: the write
/// stores BOTH the layer list (new SortOrder) and the preview layout (each layer's
/// X/Y/W/H) in the same Source rows.</summary>
public void OnSceneElementsReordered() public void OnSceneElementsReordered()
{ {
if (StagedScene != null) _sceneGraph.InvalidateBake(StagedScene); if (StagedScene != null) _sceneGraph.InvalidateBake(StagedScene);
ScheduleSave(); SaveLayoutNow();
} }
// Duplicate resource names get an incrementing suffix with no space: Image, // Duplicate resource names get an incrementing suffix with no space: Image,
+38 -4
View File
@@ -21,7 +21,8 @@ namespace ytLive.Tests;
/// directly (RemoveAt/Insert), bypassing SceneGraph's mutation surface, so the /// directly (RemoveAt/Insert), bypassing SceneGraph's mutation surface, so the
/// drop must be trapped as a data change and persisted. Real-App + temp-DB /// drop must be trapped as a data change and persisted. Real-App + temp-DB
/// pattern (see WebcamOutputKeyTests); reuses the exact code-behind mutation and /// pattern (see WebcamOutputKeyTests); reuses the exact code-behind mutation and
/// asserts the new order is written to the DB after the debounced background save. /// asserts the new order AND each layer's geometry (preview layout) are written
/// to the DB — immediately at drop, no debounce window.
/// </summary> /// </summary>
[Collection("RealApp")] [Collection("RealApp")]
public sealed class LayerReorderPersistenceTests public sealed class LayerReorderPersistenceTests
@@ -97,8 +98,14 @@ public sealed class LayerReorderPersistenceTests
Assert.True(PumpUntil(() => SourceNamesInDb(tempDb).SequenceEqual(expected), Assert.True(PumpUntil(() => SourceNamesInDb(tempDb).SequenceEqual(expected),
TimeSpan.FromSeconds(10)), TimeSpan.FromSeconds(10)),
"a real mouse drag-and-drop must be persisted by the debounced background save"); "a real mouse drag-and-drop must be persisted by the background save");
Assert.Equal(expected, SourceNamesInDb(tempDb)); Assert.Equal(expected, SourceNamesInDb(tempDb));
// The same drop must persist the PREVIEW LAYOUT too — each layer's
// geometry (X/Y/W/H) rides in the same Source rows as the SortOrder.
var dragged = vm.StagedScene.Elements.OfType<Source>().Single(s => s.Name == "ImgC");
Assert.Equal((dragged.X, dragged.Y, dragged.Width, dragged.Height),
GeometryInDb(tempDb, "ImgC"));
} }
finally finally
{ {
@@ -173,8 +180,15 @@ public sealed class LayerReorderPersistenceTests
Assert.True(PumpUntil(() => SourceNamesInDb(tempDb).SequenceEqual(expected), Assert.True(PumpUntil(() => SourceNamesInDb(tempDb).SequenceEqual(expected),
TimeSpan.FromSeconds(10)), TimeSpan.FromSeconds(10)),
"the drag reorder must be persisted by the debounced background save"); "the drag reorder must be persisted at drop (immediate save)");
Assert.Equal(expected, SourceNamesInDb(tempDb)); Assert.Equal(expected, SourceNamesInDb(tempDb));
// The drop must persist the dragged layer's PREVIEW LAYOUT too — its
// X/Y/W/H ride in the same Source row, so one immediate save covers
// both the new layer list and the preview geometry.
var draggedSource = vm.StagedScene.Elements.OfType<Source>().Single(s => s.Name == dragged.Name);
Assert.Equal((draggedSource.X, draggedSource.Y, draggedSource.Width, draggedSource.Height),
GeometryInDb(tempDb, dragged.Name));
} }
finally finally
{ {
@@ -221,11 +235,31 @@ public sealed class LayerReorderPersistenceTests
} }
catch (SqliteException) catch (SqliteException)
{ {
// The debounced save may hold the write lock mid-transaction — not yet. // The save may hold the write lock mid-transaction — not yet.
return []; return [];
} }
} }
private static (double X, double Y, double Width, double Height) GeometryInDb(string path, string name)
{
try
{
using var connection = new SqliteConnection($"Data Source={path}");
connection.Open();
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT X, Y, Width, Height FROM Source WHERE SceneId = 'scene-live' AND Name = $name;";
cmd.Parameters.AddWithValue("$name", name);
using var reader = cmd.ExecuteReader();
reader.Read();
return (reader.GetDouble(0), reader.GetDouble(1), reader.GetDouble(2), reader.GetDouble(3));
}
catch (SqliteException)
{
// The save may hold the write lock mid-transaction — not yet.
return (double.NaN, double.NaN, double.NaN, double.NaN);
}
}
private static void SeedThreeLayers(string path) private static void SeedThreeLayers(string path)
{ {
using var connection = new SqliteConnection($"Data Source={path}"); using var connection = new SqliteConnection($"Data Source={path}");
@@ -4,10 +4,12 @@ using Xunit;
namespace ytLive.Tests; namespace ytLive.Tests;
/// <summary> /// <summary>
/// The Rename Recording modal (creator-reported, 2026-09-18) cut off the file-name /// The Rename Recording modal (the creator-reported dialog shown when a recording
/// textbox: the fixed window was too short for its stacked content. The height is /// is ended, so it can be saved under a readable name) cut off its file-name textbox
/// bumped 20% (230 → 276); this test renders the real dialog and asserts the textbox /// — and once the height was bumped 20% the Cancel/Save button row was revealed as
/// bottom edge lands inside the client area instead of clipping. /// previously obscured too. Height raised 230 → 304 (+20% then another +10%); this
/// test renders the real dialog and asserts the textbox bottom edge AND the button
/// row sit inside the client area instead of clipping.
/// </summary> /// </summary>
[Collection("RealApp")] [Collection("RealApp")]
public sealed class RenameRecordingDialogSizingTests public sealed class RenameRecordingDialogSizingTests
@@ -17,7 +19,7 @@ public sealed class RenameRecordingDialogSizingTests
public RenameRecordingDialogSizingTests(RealAppHost app) => _app = app; public RenameRecordingDialogSizingTests(RealAppHost app) => _app = app;
[Fact] [Fact]
public void DialogHeight_Bumps20Percent_AndNameBoxIsNotClipped() public void DialogHeight_CoversTextboxAndButtonRow_WithoutClipping()
{ {
_app.Run(Run); _app.Run(Run);
} }
@@ -27,7 +29,7 @@ public sealed class RenameRecordingDialogSizingTests
var dialog = new RenameRecordingDialog("rec-2026-09-18"); var dialog = new RenameRecordingDialog("rec-2026-09-18");
try try
{ {
Assert.Equal(276, dialog.Height); Assert.Equal(304, dialog.Height);
dialog.Show(); dialog.Show();
dialog.UpdateLayout(); dialog.UpdateLayout();
@@ -35,11 +37,18 @@ public sealed class RenameRecordingDialogSizingTests
var box = dialog.NameBox; var box = dialog.NameBox;
Assert.True(box.ActualWidth > 0, "file-name textbox must have a real layout"); Assert.True(box.ActualWidth > 0, "file-name textbox must have a real layout");
var client = (FrameworkElement)dialog.Content; var client = (FrameworkElement)dialog.Content;
var bottom = box.TransformToAncestor(client) var boxBottom = box.TransformToAncestor(client)
.TransformBounds(new Rect(box.RenderSize)).Bottom; .TransformBounds(new Rect(box.RenderSize)).Bottom;
Assert.True(bottom <= client.ActualHeight, Assert.True(boxBottom <= client.ActualHeight,
$"textbox bottom ({bottom:0.##}) must sit inside the client area " + $"textbox bottom ({boxBottom:0.##}) must sit inside the client area " +
$"({client.ActualHeight:0.##}) — the dialog is clipping the name box"); $"({client.ActualHeight:0.##}) — the dialog is clipping the name box");
var save = (FrameworkElement)client.FindName("SaveRecordingButton")!;
var saveBottom = save.TransformToAncestor(client)
.TransformBounds(new Rect(save.RenderSize)).Bottom;
Assert.True(saveBottom <= client.ActualHeight,
$"Save button bottom ({saveBottom:0.##}) must sit inside the client area " +
$"({client.ActualHeight:0.##}) — the button row is obscured");
} }
finally finally
{ {