diff --git a/Controls/LeftPanel.xaml.cs b/Controls/LeftPanel.xaml.cs index f67187d..219d116 100644 --- a/Controls/LeftPanel.xaml.cs +++ b/Controls/LeftPanel.xaml.cs @@ -20,6 +20,7 @@ public partial class LeftPanel : UserControl private int _dragIndex = -1; private bool _isDragging; private object? _lastHoveredItem; + private bool _dragReordered; public LeftPanel() { @@ -159,6 +160,7 @@ public partial class LeftPanel : UserControl _dragStartPoint = e.GetPosition(listBox); _isDragging = false; _lastHoveredItem = null; + _dragReordered = false; } private void List_PreviewMouseMove(object sender, MouseEventArgs e) @@ -196,6 +198,7 @@ public partial class LeftPanel : UserControl items.Insert(targetIndex, dragged); _dragIndex = targetIndex; _lastHoveredItem = targetItem; + _dragReordered = true; } private void List_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e) @@ -207,9 +210,13 @@ public partial class LeftPanel : UserControl { if (_isDragging) listBox.ReleaseMouseCapture(); + var reordered = _dragReordered; _dragIndex = -1; _isDragging = false; _lastHoveredItem = null; + _dragReordered = false; + if (reordered && DataContext is MainViewModel vm) + vm.OnSceneElementsReordered(); } private static ListBoxItem? FindItemContainerAt(ListBox listBox, Point position) diff --git a/HANDOFF.md b/HANDOFF.md index 49259ef..00f8a18 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,22 +1,17 @@ -# HANDOFF — 2026-09-17 (webcam attainability refinement committed locally at `75723ac` — no push) +# HANDOFF — 2026-09-18 (layer drag-reorder persistence fix committed locally — no push) ## Branch / Commit State -`main` HEAD = **`75723ac` fix(webcam): offer the Web Cam row only when a camera is attainable -(live lock), not merely selected** (local). Below it: +`main` HEAD = **layer drag-reorder now traps + persists** (local, this session — the new +`LayerReorderPersistenceTests`). Below it: +- `75723ac` fix(webcam): offer the Web Cam row only when a camera is attainable (live lock) - `9d00955` feat(webcam): app-default gate slice — per-scene offer, Add places default directly, identity survives removal - `1e4017d` feat(webcam): resource lifecycle startup slice — poll-on-start, single-cam lock, persistent Layers alert -- `94a934f` reader-output-subtype ladder + WinRT per-call wrap rule -- `64a5a6d` slice-18 C4 blit-on-change composite cache -- `b37b8a3` slice-17 overlap GPU readbacks with monotonic publish gate -**No push yet** — still awaiting the device take + user greenlight. Working tree clean. - -> Correcting the previous handoff, which still listed HEAD as `1e4017d`: the gate slice -> (`9d00955`) and its attainability refinement (`75723ac`) are both committed now. +**No push yet.** Working tree clean. ## ⚠️ Branding (2026-09-14, creator-corrected): product = **llamacasty**, internals = ytLive @@ -63,6 +58,18 @@ the availability layer. TASK 26's app-wide gate is **superseded** by creator dir **Task docs:** `TASKS.md` Open items + `ai.md` Webcam section updated (supersession recorded). +## ✅ Committed — layer-list drag-to-reorder persists (creator-reported, 2026-09-18) + +The drag code in `Controls/LeftPanel.xaml.cs` reordered `StagedScene.Elements` directly +(`RemoveAt`/`Insert` in `List_PreviewMouseMove`), bypassing `SceneGraph`'s mutation surface — the +collection changed visually but nothing ever scheduled a save, so the new z-order was lost on +restart. Fix: the drag sets `_dragReordered` and `EndListDrag` funnels the drop through the new +`MainViewModel.OnSceneElementsReordered()` (Sources.cs) = `SceneGraph.InvalidateBake(staged) + +ScheduleSave()`, i.e. the same background-save path every other mutation uses. **Good Dog test** +`LayerReorderPersistenceTests` (real App + temp DB): reproduces the exact code-behind mutation, +calls the trap, pumps the dispatcher until the debounced save lands, asserts the DB `Source` +SortOrder matches the in-memory `Elements` order. + ## ✅ Earlier committed — webcam resource lifecycle (startup slice, `1e4017d`) Startup poll + tri-state (`ValidateWebcamResourceStartupAsync` after `LoadLayout`): 0 → run on, @@ -77,6 +84,9 @@ claim — `CameraConflictProbe` reads process names only, no device handles; do ## ⚠️ Open items +- **RenameRecordingDialog too short (creator-reported, QUEUED next):** the recording save/confirm + dialog cuts off the file-name textbox — increase its height by ~20% (+ its one integration test, + Good Dog). - **Webcam take fix 2 of 2 — the 09:16 crash is UNFIXED (hard, untested):** hypothesis — native WMF death when `MediaCapture.Failed` fires mid-stream and `SafeStopAsync` (fire-and-forget) disposes reader+capture while the frame thread sits in `TryAcquireLatestFrame`/`Marshal.Copy` (outside the @@ -133,6 +143,9 @@ claim — `CameraConflictProbe` reads process names only, no device handles; do - ffmpeg/ffprobe: `/mnt/c/Program Files/Krita (x64)/bin/` with Windows paths. - `MyMistakes.md`: WINRT resource-allocation RECIPE, freeze-audit RECIPE, A/V sync recipe, deadline pacing, CoreMessaging DQ, WGC-CLIP + slice blocks. Grep before re-deriving. +- **Scene.Elements layer tests:** any seeded scene with `HasBackground=1` loads a healed + `Background` element pinned at `Elements[0]` (created by `EnsureBackground` on `StagedScene` set) — + compute expected layer orders from the live collection, never hardcode indices. - `C:\tmpout` is for ffmpeg evidence artifacts; keep them out of the repo. ## Next step diff --git a/ViewModels/MainViewModel.Sources.cs b/ViewModels/MainViewModel.Sources.cs index b90705a..e2dee70 100644 --- a/ViewModels/MainViewModel.Sources.cs +++ b/ViewModels/MainViewModel.Sources.cs @@ -85,6 +85,15 @@ public partial class MainViewModel UpdateActiveBackground(); } + /// Drag-to-reorder in the layer list mutates + /// directly in code-behind, bypassing SceneGraph's mutation surface. Trap it here + /// so the new z-order persists like any other layout change. + public void OnSceneElementsReordered() + { + if (StagedScene != null) _sceneGraph.InvalidateBake(StagedScene); + ScheduleSave(); + } + // Duplicate resource names get an incrementing suffix with no space: Image, // Image2, Image3… The next free number is derived from the names actually in // the scene, so deleting a middle resource never collides with a survivor. diff --git a/ytLive.Tests/LayerReorderPersistenceTests.cs b/ytLive.Tests/LayerReorderPersistenceTests.cs new file mode 100644 index 0000000..0e495c7 --- /dev/null +++ b/ytLive.Tests/LayerReorderPersistenceTests.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Windows.Threading; +using Microsoft.Data.Sqlite; +using Xunit; +using ytLive.Models; +using ytLive.Services; +using ytLive.ViewModels; + +namespace ytLive.Tests; + +/// +/// Layer-list drag-to-reorder: the code-behind reorders StagedScene.Elements +/// directly (RemoveAt/Insert), bypassing SceneGraph's mutation surface, so the +/// drop must be trapped as a data change and persisted. Real-App + temp-DB +/// pattern (see WebcamOutputKeyTests); reuses the exact code-behind mutation and +/// asserts the new order is written to the DB after the debounced background save. +/// +[Collection("RealApp")] +public sealed class LayerReorderPersistenceTests +{ + private readonly RealAppHost _app; + + public LayerReorderPersistenceTests(RealAppHost app) => _app = app; + + [Fact] + public void DragReorder_SurvivesTheDebouncedBackgroundSave() + { + _app.Run(Run); + } + + private void Run() + { + var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-reorder-{Guid.NewGuid():N}.db"); + MainViewModel.LayoutPathOverride = tempDb; + var window = default(MainWindow); + try + { + using (var schema = new LayoutStore(tempDb)) { } + SqliteConnection.ClearAllPools(); + SeedThreeLayers(tempDb); + + window = new MainWindow(); + var vm = (MainViewModel)window.DataContext; + vm.StagedScene = vm.Scenes.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Live)); + Assert.True(vm.StagedScene!.Elements.OfType().Count() >= 4, + "3 seeded layers + the healed Background must load"); + + // Mirrors Controls/LeftPanel.List_PreviewMouseMove: drag the last layer + // to the front of the user layers (the healed Background stays pinned + // at index 0, so index 1 is the first user layer). + var elements = vm.StagedScene.Elements; + var dragged = elements[^1]; + elements.RemoveAt(elements.Count - 1); + elements.Insert(1, dragged); + var expected = vm.StagedScene.Elements.OfType().Select(s => s.Name).ToArray(); + + vm.OnSceneElementsReordered(); + + Assert.True(PumpUntil(() => SourceNamesInDb(tempDb).SequenceEqual(expected), + TimeSpan.FromSeconds(10)), + "the drag reorder must be persisted by the debounced background save"); + Assert.Equal(expected, SourceNamesInDb(tempDb)); + } + finally + { + if (window != null) window.Close(); + MainViewModel.LayoutPathOverride = null; + SqliteConnection.ClearAllPools(); + try { File.Delete(tempDb); } catch { /* best-effort cleanup */ } + } + } + + /// Pumps the app dispatcher (so the VM's debounce timer fires) until + /// or the deadline passes. True when the condition + /// became true, false on deadline. + private static bool PumpUntil(Func isDone, TimeSpan timeout) + { + var frame = new DispatcherFrame(); + var poll = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(100) }; + var deadline = DateTime.UtcNow + timeout; + poll.Tick += (_, _) => + { + if (isDone() || DateTime.UtcNow >= deadline) + { + poll.Stop(); + frame.Continue = false; + } + }; + poll.Start(); + Dispatcher.PushFrame(frame); + return isDone(); + } + + private static string[] SourceNamesInDb(string path) + { + try + { + using var connection = new SqliteConnection($"Data Source={path}"); + connection.Open(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT Name FROM Source WHERE SceneId = 'scene-live' ORDER BY SortOrder;"; + using var reader = cmd.ExecuteReader(); + var names = new List(); + while (reader.Read()) names.Add(reader.GetString(0)); + return names.ToArray(); + } + catch (SqliteException) + { + // The debounced save may hold the write lock mid-transaction — not yet. + return []; + } + } + + private static void SeedThreeLayers(string path) + { + using var connection = new SqliteConnection($"Data Source={path}"); + connection.Open(); + + var scenes = new (string Id, string Name)[] + { + ("scene-starting", "Starting"), + ("scene-live", "Live"), + ("scene-brb", "BRB"), + ("scene-chat", "Chat"), + ("scene-ending", "Ending"), + }; + foreach (var (id, name) in scenes) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = "INSERT INTO Scene (Id, Name, HasBackground, SortOrder) VALUES ($id, $name, 1, $order);"; + cmd.Parameters.AddWithValue("$id", id); + cmd.Parameters.AddWithValue("$name", name); + cmd.Parameters.AddWithValue("$order", Array.IndexOf(scenes, (id, name))); + cmd.ExecuteNonQuery(); + } + + var layers = new[] { "ImgA", "ImgB", "ImgC" }; + using (var source = connection.CreateCommand()) + { + source.CommandText = + @"INSERT INTO Source (Id, SceneId, Type, Name, IsEnabled, X, Y, Width, Height, IsBackground, SortOrder) + VALUES ($id, 'scene-live', 'Image', $name, 1, 0, 0, 640, 480, 0, $sort);"; + var idP = source.Parameters.Add("$id", SqliteType.Text); + var nameP = source.Parameters.Add("$name", SqliteType.Text); + var sortP = source.Parameters.Add("$sort", SqliteType.Integer); + for (var i = 0; i < layers.Length; i++) + { + idP.Value = $"img-{i}"; + nameP.Value = layers[i]; + sortP.Value = i; + source.ExecuteNonQuery(); + } + } + } +} \ No newline at end of file