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:
+22
-13
@@ -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
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user