From 366af0c34f15c0528f141e87daaa68ea076d0186 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 20:25:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20D.2b=20inventory=20finish=20?= =?UTF-8?q?=E2=80=94=20UiItemList=20clip+scroll=20via=20UiScrollable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grid mode now drives a shared UiScrollable from its content + clips whole rows to the panel (cells offset by -ScrollY, Visible toggled by whole-row clip, mirroring UiText). Fill mode (toolbar single cell) unchanged. LayoutCells made internal for tests; RowCount helper added. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/UiItemList.cs | 40 ++++++++++++-- .../UI/UiItemListScrollTests.cs | 54 +++++++++++++++++++ 2 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 tests/AcDream.App.Tests/UI/UiItemListScrollTests.cs diff --git a/src/AcDream.App/UI/UiItemList.cs b/src/AcDream.App/UI/UiItemList.cs index f550e53c..8cfef904 100644 --- a/src/AcDream.App/UI/UiItemList.cs +++ b/src/AcDream.App/UI/UiItemList.cs @@ -13,6 +13,10 @@ public sealed class UiItemList : UiElement { private readonly List _cells = new(); + /// Vertical scroll model for grid mode (clip+scroll). Bound to the gutter + /// UiScrollbar by the panel controller. Inert in fill mode (single toolbar cell). + public UiScrollable Scroll { get; } = new(); + public UiItemList(Func? spriteResolve = null) { SpriteResolve = spriteResolve; @@ -63,6 +67,14 @@ public sealed class UiItemList : UiElement /// Fixed cell height in grid mode (pairs with CellWidth). public float CellHeight { get; set; } + /// Whole rows needed for cells in a grid of + /// columns. + public static int RowCount(int cellCount, int columns) + { + int cols = columns < 1 ? 1 : columns; + return (cellCount + cols - 1) / cols; + } + /// Row-major pixel offset of cell in a grid of /// columns at the given cell pitch. internal static (float x, float y) CellOffset(int index, int columns, float cellW, float cellH) @@ -72,24 +84,42 @@ public sealed class UiItemList : UiElement } /// Position every cell per the current mode: fill (CellWidth<=0) sizes the single - /// cell to the list; grid (CellWidth>0) tiles cells row-major at the cell pitch. - private void LayoutCells() + /// cell to the list; grid (CellWidth>0) tiles cells row-major, offset by the scroll position + /// with whole-row vertical clipping (cells fully outside the view are hidden). + internal void LayoutCells() { if (CellWidth <= 0f) { + // Fill mode (the toolbar single cell): size the one cell to the list. if (_cells.Count > 0) { var c = _cells[0]; - c.Left = 0; c.Top = 0; c.Width = Width; c.Height = Height; + c.Left = 0; c.Top = 0; c.Width = Width; c.Height = Height; c.Visible = true; } return; } + int cols = Columns < 1 ? 1 : Columns; + int cellH = (int)MathF.Round(CellHeight); + + // Drive the shared scroll model from the current geometry, then re-clamp the offset to + // the (possibly changed) max. ContentHeight/ViewHeight must be set BEFORE reading ScrollY + // so the clamp uses the right max. + Scroll.LineHeight = cellH > 0 ? cellH : 1; + Scroll.ContentHeight = RowCount(_cells.Count, cols) * cellH; + Scroll.ViewHeight = (int)MathF.Floor(Height); + Scroll.SetScrollY(Scroll.ScrollY); // re-clamp to the new max + float scrollY = Scroll.ScrollY; + for (int i = 0; i < _cells.Count; i++) { - var (x, y) = CellOffset(i, cols, CellWidth, CellHeight); + var (x, baseY) = CellOffset(i, cols, CellWidth, CellHeight); + float top = baseY - scrollY; var cell = _cells[i]; - cell.Left = x; cell.Top = y; cell.Width = CellWidth; cell.Height = CellHeight; + cell.Left = x; cell.Top = top; cell.Width = CellWidth; cell.Height = CellHeight; + // Whole-row vertical clip (no scissor — mirrors UiText.cs:198). A row fully inside + // [0, Height] draws; a partially-scrolled row is hidden. + cell.Visible = top >= -0.5f && top + CellHeight <= Height + 0.5f; } } diff --git a/tests/AcDream.App.Tests/UI/UiItemListScrollTests.cs b/tests/AcDream.App.Tests/UI/UiItemListScrollTests.cs new file mode 100644 index 00000000..48af8e79 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiItemListScrollTests.cs @@ -0,0 +1,54 @@ +using AcDream.App.UI; +using Xunit; + +namespace AcDream.App.Tests.UI; + +public sealed class UiItemListScrollTests +{ + private static UiItemList Grid(int items) + { + var list = new UiItemList { Columns = 6, CellWidth = 32, CellHeight = 32, Width = 192, Height = 96 }; + list.Flush(); // drop the default single cell + for (int i = 0; i < items; i++) list.AddItem(new UiItemSlot()); + list.LayoutCells(); // drive the scroll model + position cells + return list; + } + + [Fact] + public void RowCount_ceils_to_whole_rows() + { + Assert.Equal(0, UiItemList.RowCount(0, 6)); + Assert.Equal(1, UiItemList.RowCount(1, 6)); + Assert.Equal(7, UiItemList.RowCount(41, 6)); + } + + [Fact] + public void Grid_drives_scroll_model_from_content() + { + var list = Grid(30); // 5 rows × 32 = 160 content, 96 view + Assert.Equal(160, list.Scroll.ContentHeight); + Assert.Equal(96, list.Scroll.ViewHeight); + Assert.Equal(64, list.Scroll.MaxScroll); + Assert.True(list.Scroll.HasOverflow); + } + + [Fact] + public void At_top_first_three_rows_visible_rest_clipped() + { + var list = Grid(30); + Assert.True(list.GetItem(0)!.Visible); // row 0, top 0 + Assert.True(list.GetItem(12)!.Visible); // row 2, top 64 (+32 == 96) + Assert.False(list.GetItem(18)!.Visible); // row 3, top 96 (off the bottom) + } + + [Fact] + public void Scrolled_one_row_shifts_window_and_clips_top_row() + { + var list = Grid(30); + list.Scroll.SetScrollY(32); + list.LayoutCells(); + Assert.False(list.GetItem(0)!.Visible); // row 0 scrolled above + Assert.Equal(0f, list.GetItem(6)!.Top); // row 1 now at the view top + Assert.True(list.GetItem(18)!.Visible); // row 3 now visible + } +}