From 62b5e1d8160ba53a9e3867db76c2631b2558f812 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 23:16:59 +0000 Subject: [PATCH 1/6] fix: tile Up/Down stay in visual column (0.8.5-beta1) 0.8.4 stepped by a width-based column count that came out one low once the wrap viewport already excluded the scrollbar (4 visual columns, nav used 3). Pick by layout X and never undercut columns seen on screen. Co-authored-by: Paul Smith --- CHANGELOG.md | 11 ++ src/Aperture.App/Aperture.App.csproj | 2 +- src/Aperture.App/MainWindow.xaml.cs | 162 +++++++++++---- src/Aperture.App/TileSpatialNavigation.cs | 184 ++++++++++++++---- .../TileSpatialNavigationTests.cs | 110 +++++++++++ 5 files changed, 391 insertions(+), 78 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d96803c..f7438b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to Aperture Image Viewer are documented here. The format fol ## [Unreleased] +## [0.8.5-beta1] - 2026-08-21 + +### Fixed +- **Tile-pane Up/Down** stay in the same *visual* column at every zoom. 0.8.4 + stepped by a width-based column count that came out one low once the + scrollbar (and padding) were already excluded from the wrap viewport — a + 4-column grid walked as 3, so selection moved diagonally. The picker now + uses layout X (and a column count that cannot undercut columns already on + screen). PageUp/PageDown still move one viewport in that same column; + Left/Right are unchanged. Headers are not stops. + ## [0.8.4-beta1] - 2026-08-21 ### Fixed diff --git a/src/Aperture.App/Aperture.App.csproj b/src/Aperture.App/Aperture.App.csproj index 874e3f9..7bd87e1 100644 --- a/src/Aperture.App/Aperture.App.csproj +++ b/src/Aperture.App/Aperture.App.csproj @@ -16,7 +16,7 @@ true Assets\aperture.ico Aperture - 0.8.4-beta1 + 0.8.5-beta1 Aperture Image Viewer Aperture Image Viewer A fast local image & video browser for Windows. diff --git a/src/Aperture.App/MainWindow.xaml.cs b/src/Aperture.App/MainWindow.xaml.cs index 39aa55c..5da8aaf 100644 --- a/src/Aperture.App/MainWindow.xaml.cs +++ b/src/Aperture.App/MainWindow.xaml.cs @@ -1089,13 +1089,50 @@ private bool TryBuildCells( if (items.Count == 0) return false; - var columns = VisualColumnCount(); - var (tileW, tileH) = MeasureTileSize(); + var realized = CollectRealizedByItem(); + var (tileW, tileH, slotW, _) = MeasureTileSlot(realized.Values); + var viewportW = WrapViewportWidth(); + var observedXs = TileSpatialNavigation.DistinctColumnXs( + realized.Values.Select(c => c.X).ToList(), slop: Math.Max(tileW * 0.5, 1)); + var fitted = TileSpatialNavigation.FitColumns(viewportW, slotW); + var columns = TileSpatialNavigation.ResolveColumnCount(observedXs.Count, fitted); + + IReadOnlyList? columnXs = observedXs.Count == columns + ? observedXs + : TileSpatialNavigation.UniformColumnXs(viewportW, slotW, tileW); + var headerH = layout.HasHeaders ? MeasureSectionHeaderHeight() : 0; - cells = TileSpatialNavigation.LayoutWrap(layout.SectionCounts, columns, tileW, tileH, headerH); + cells = TileSpatialNavigation.LayoutWrap( + layout.SectionCounts, columns, tileW, tileH, headerH, columnXs); + + if (realized.Count > 0) + { + var byIndex = new Dictionary(); + foreach (var (item, measured) in realized) + { + var idx = IndexOfItem(items, item); + if (idx < 0) + continue; + // Overlay X/size only. Realized Y is viewport-relative; mixing it + // with the document-space wrap model skips rows. Column is X. + byIndex[idx] = cells[idx] with { X = measured.X, Width = measured.Width, Height = measured.Height }; + } + cells = TileSpatialNavigation.OverlayMeasured(cells, byIndex); + } + return cells.Count > 0; } + private static int IndexOfItem(IReadOnlyList items, IGridItem item) + { + for (var i = 0; i < items.Count; i++) + { + if (ReferenceEquals(items[i], item)) + return i; + } + return -1; + } + private int? CurrentCellIndex(IReadOnlyList items) { if (ViewModel?.SelectedItem is not IGridItem sel) @@ -1125,9 +1162,13 @@ private static void CollectRealizedTileRects( if (node is System.Windows.Controls.ListBoxItem { IsVisible: true, DataContext: IGridItem item } li) { // Origin is the ListBox: coordinates are viewport-relative (scroll already applied). + // Use the wrap slot (margin included) so X matches TryBuildCells / Uniform columns. var tl = li.TranslatePoint(new Point(0, 0), origin); + var slotW = li.ActualWidth + li.Margin.Left + li.Margin.Right; + var slotH = li.ActualHeight + li.Margin.Top + li.Margin.Bottom; cells.Add(new TileSpatialNavigation.Cell( - cells.Count, Row: 0, Col: 0, tl.X, tl.Y, li.ActualWidth, li.ActualHeight)); + cells.Count, Row: 0, Col: 0, + tl.X - li.Margin.Left, tl.Y - li.Margin.Top, slotW, slotH)); items.Add(item); return; } @@ -1137,32 +1178,87 @@ private static void CollectRealizedTileRects( CollectRealizedTileRects(VisualTreeHelper.GetChild(node, i), origin, cells, items); } - private int VisualColumnCount() + /// + /// Width the wrap panel actually laid out against. ScrollViewer.ViewportWidth + /// already excludes the vertical scrollbar — do not subtract padding/12 again + /// (that was the 0.8.4 off-by-one: 4 visual columns, nav used 3). + /// + private double WrapViewportWidth() { - var realized = new List(); - CollectRealizedItemRects(ItemsList, ItemsList, realized); - if (realized.Count > 0) + if (FindDescendant(ItemsList) + is { ActualWidth: > 1 } panel) + return panel.ActualWidth; + + var sv = GridScroll(); + if (sv is { ViewportWidth: > 1 }) + return sv.ViewportWidth; + + var w = ItemsList.ActualWidth - ItemsList.Padding.Left - ItemsList.Padding.Right; + if (sv?.ComputedVerticalScrollBarVisibility == System.Windows.Visibility.Visible) + w -= SystemParameters.VerticalScrollBarWidth; + return Math.Max(1, w); + } + + private (double Width, double Height, double SlotWidth, double SlotHeight) MeasureTileSlot( + IEnumerable? realized = null) + { + if (realized is not null) + { + foreach (var c in realized) + return (c.Width, c.Height, c.Width, c.Height); + } + + if (FindFirstTileItem(ItemsList) is { } li && li.ActualWidth > 0) { - var itemW = realized.Max(r => r.Width); - var fitted = (int)((ItemsList.ActualWidth - 12) / Math.Max(1, itemW)); - var fullestRow = realized - .GroupBy(r => Math.Round(r.Y / 8.0) * 8.0) - .Select(g => g.Count()) - .DefaultIfEmpty(1) - .Max(); - return Math.Max(1, Math.Max(fullestRow, fitted)); + var w = li.ActualWidth; + var h = li.ActualHeight; + return (w, h, w + li.Margin.Left + li.Margin.Right, h + li.Margin.Top + li.Margin.Bottom); } - return ColumnsPerRow(); + + var outer = (ViewModel?.TileSize ?? 200) + 20; + return (outer, outer + 18, outer, outer + 18); } - private (double Width, double Height) MeasureTileSize() + private static System.Windows.Controls.ListBoxItem? FindFirstTileItem(DependencyObject node) { - var realized = new List(); - CollectRealizedItemRects(ItemsList, ItemsList, realized); - if (realized.Count > 0) - return (realized[0].Width, realized[0].Height); - var outer = (ViewModel?.TileSize ?? 200) + 20; - return (outer, outer + 18); + if (node is System.Windows.Controls.ListBoxItem { IsVisible: true, DataContext: IGridItem } li) + return li; + + var count = VisualTreeHelper.GetChildrenCount(node); + for (var i = 0; i < count; i++) + { + if (FindFirstTileItem(VisualTreeHelper.GetChild(node, i)) is { } found) + return found; + } + return null; + } + + private Dictionary CollectRealizedByItem() + { + var map = new Dictionary(); + CollectRealizedByItem(ItemsList, ItemsList, map); + return map; + } + + private static void CollectRealizedByItem( + DependencyObject node, + UIElement origin, + Dictionary map) + { + if (node is System.Windows.Controls.ListBoxItem { IsVisible: true, DataContext: IGridItem item } li) + { + var tl = li.TranslatePoint(new Point(0, 0), origin); + // Slot the wrap used: arranged width includes margin. + var slotW = li.ActualWidth + li.Margin.Left + li.Margin.Right; + var slotH = li.ActualHeight + li.Margin.Top + li.Margin.Bottom; + map[item] = new TileSpatialNavigation.Cell( + map.Count, Row: 0, Col: 0, tl.X - li.Margin.Left, tl.Y - li.Margin.Top, slotW, slotH); + return; + } + + var count = VisualTreeHelper.GetChildrenCount(node); + for (var i = 0; i < count; i++) + CollectRealizedByItem(VisualTreeHelper.GetChild(node, i), origin, map); } private double MeasureSectionHeaderHeight() @@ -1188,19 +1284,6 @@ private double MeasureSectionHeaderHeight() return null; } - private static void CollectRealizedItemRects(DependencyObject node, UIElement origin, List rects) - { - if (node is System.Windows.Controls.ListBoxItem { IsVisible: true, DataContext: IGridItem } li) - { - rects.Add(new Rect(li.TranslatePoint(new Point(0, 0), origin), li.RenderSize)); - return; - } - - var count = VisualTreeHelper.GetChildrenCount(node); - for (var i = 0; i < count; i++) - CollectRealizedItemRects(VisualTreeHelper.GetChild(node, i), origin, rects); - } - /// Home/End: move the cursor to the first/last stop and scroll the grid to that extreme. private void JumpCursor(bool toEnd) { @@ -1278,8 +1361,7 @@ private static void CollectRealizedItems(DependencyObject root, System.Collectio private int ColumnsPerRow() { - var tileOuter = (ViewModel?.TileSize ?? 200) + 20; // tile + margin/padding/spacing - var columns = (int)((ItemsList.ActualWidth - 12) / Math.Max(1, tileOuter)); - return Math.Max(1, columns); + var (_, _, slotW, _) = MeasureTileSlot(); + return TileSpatialNavigation.FitColumns(WrapViewportWidth(), slotW); } } diff --git a/src/Aperture.App/TileSpatialNavigation.cs b/src/Aperture.App/TileSpatialNavigation.cs index a3f0477..5c2cb6d 100644 --- a/src/Aperture.App/TileSpatialNavigation.cs +++ b/src/Aperture.App/TileSpatialNavigation.cs @@ -3,7 +3,8 @@ namespace Aperture.App; /// /// Explorer-style 2D pick over a wrapping, date-sectioned tile grid. /// Operates on layout rectangles so Up/Down / PageUp/PageDown follow -/// visual columns rather than ListBox item index. +/// visual X (the column on screen) rather than ListBox item index or a +/// stale index % columns guess. /// internal static class TileSpatialNavigation { @@ -20,20 +21,131 @@ internal readonly record struct Cell( public double CenterY => Y + Height / 2; } + /// + /// How many tiles a wrap panel actually fits: + /// floor(availableWidth / slotWidth). + /// is the panel/viewport width the wrap + /// used (scrollbar already excluded). Do not subtract padding again. + /// is the arranged child size including margin. + /// + public static int FitColumns(double availableWidth, double slotWidth) + { + slotWidth = Math.Max(1, slotWidth); + availableWidth = Math.Max(slotWidth, availableWidth); + return Math.Max(1, (int)Math.Floor(availableWidth / slotWidth)); + } + + /// + /// Visual column count. A stale width-based estimate (scrollbar subtracted + /// twice, padding fudge, leftover tile-size guess) can come in one low; + /// never prefer that over columns already seen on screen. + /// + public static int ResolveColumnCount(int columnsFromLayoutX, int fittedFromWidth) => + Math.Max(1, Math.Max(columnsFromLayoutX, fittedFromWidth)); + + /// + /// Cluster left edges into visual columns. Tiles in the same wrap column + /// share an X; a short last row just contributes fewer clusters. + /// + public static IReadOnlyList DistinctColumnXs(IReadOnlyList xs, double slop) + { + if (xs is null || xs.Count == 0) + return []; + + slop = Math.Max(1, slop); + var sorted = xs.OrderBy(x => x).ToList(); + var groups = new List> { [sorted[0]] }; + for (var i = 1; i < sorted.Count; i++) + { + if (Math.Abs(sorted[i] - groups[^1][0]) > slop) + groups.Add([sorted[i]]); + else + groups[^1].Add(sorted[i]); + } + + return groups.Select(g => g.Average()).ToList(); + } + + /// + /// Uniform wrap origins (start + between + end leftover), matching + /// VirtualizingWrapPanel SpacingMode.Uniform. + /// + public static IReadOnlyList UniformColumnXs( + double availableWidth, double slotWidth, double tileWidth) + { + var columns = FitColumns(availableWidth, slotWidth); + slotWidth = Math.Max(1, slotWidth); + tileWidth = Math.Max(1, tileWidth); + var unused = Math.Max(0, availableWidth - columns * slotWidth); + var outer = unused / (columns + 1); + var xs = new double[columns]; + for (var i = 0; i < columns; i++) + xs[i] = outer + i * (slotWidth + outer); + return xs; + } + + /// + /// Relabel Row/Col as index % staleColumns while keeping visual X/Y. + /// That is the 0.8.4 path when the window under-counted columns (scrollbar / + /// padding). Pickers that still use Col/Row walk diagonally; X-based pick + /// must not. + /// + public static IReadOnlyList WithIndexColumns(IReadOnlyList cells, int staleColumns) + { + ArgumentNullException.ThrowIfNull(cells); + staleColumns = Math.Max(1, staleColumns); + var relabeled = new Cell[cells.Count]; + for (var i = 0; i < cells.Count; i++) + { + var c = cells[i]; + relabeled[i] = c with { Col = c.Index % staleColumns, Row = c.Index / staleColumns }; + } + return relabeled; + } + + /// + /// Copy measured X/Y/size onto the matching model cells. Unrealized tiles + /// keep the wrap model so virtualization cannot invent a wrong column. + /// + public static IReadOnlyList OverlayMeasured( + IReadOnlyList model, IReadOnlyDictionary measured) + { + ArgumentNullException.ThrowIfNull(model); + if (measured is null || measured.Count == 0) + return model; + + var next = new Cell[model.Count]; + for (var i = 0; i < model.Count; i++) + { + var c = model[i]; + next[i] = measured.TryGetValue(c.Index, out var m) + ? c with { X = m.X, Y = m.Y, Width = m.Width, Height = m.Height } + : c; + } + return next; + } + /// /// Wrap tiles into a column grid. Each section starts on a new row after an /// optional header gap (headers are not cells — crossing them is fine). /// A zero-count section still consumes so /// Page distance matches a collapsed date bucket. + /// + /// When is set, X comes from those origins + /// (actual wrap / Uniform spacing) instead of col * tileWidth. + /// /// public static IReadOnlyList LayoutWrap( IReadOnlyList sectionCounts, int columns, double tileWidth, double tileHeight, - double headerHeight) + double headerHeight, + IReadOnlyList? columnXs = null) { ArgumentNullException.ThrowIfNull(sectionCounts); + if (columnXs is { Count: > 0 }) + columns = columnXs.Count; columns = Math.Max(1, columns); tileWidth = Math.Max(1, tileWidth); tileHeight = Math.Max(1, tileHeight); @@ -59,7 +171,8 @@ public static IReadOnlyList LayoutWrap( y += tileHeight; } - cells.Add(new Cell(index, row, col, col * tileWidth, y, tileWidth, tileHeight)); + var x = columnXs is { Count: > 0 } ? columnXs[col] : col * tileWidth; + cells.Add(new Cell(index, row, col, x, y, tileWidth, tileHeight)); index++; } @@ -72,8 +185,9 @@ public static IReadOnlyList LayoutWrap( /// /// One visual row up ( = -1) or down (+1) in the same - /// column. Empty cell (short last row, end of a section): nearest tile in - /// that column, then nearest in the adjacent row. + /// screen column (layout X), not index % columns. Empty cell + /// (short last row, end of a section): nearest tile in that column, then + /// nearest in the adjacent row. /// public static int? PickVertical(IReadOnlyList cells, int currentIndex, int sign) { @@ -81,30 +195,28 @@ public static IReadOnlyList LayoutWrap( return null; sign = Math.Sign(sign); - var targetRow = cur.Row + sign; - var adjacent = cells.Where(c => c.Row == targetRow).ToList(); - - if (CellInColumn(adjacent, cur.Col) is { } sameColOnRow) - return sameColOnRow.Index; - - var further = cells - .Where(c => c.Col == cur.Col && c.Row * sign > cur.Row * sign) - .OrderBy(c => Math.Abs(c.Row - cur.Row)) - .ThenBy(c => c.Index) - .FirstOrDefault(); - if (further.Width > 0) - return further.Index; + var inColumn = cells + .Where(c => c.Index != cur.Index && SameColumnX(c, cur) && FurtherY(c, cur, sign)) + .ToList(); + if (inColumn.Count > 0) + return inColumn.MinBy(c => (Math.Abs(c.CenterY - cur.CenterY), c.Index)).Index; - if (adjacent.Count > 0) - return adjacent.MinBy(c => (Math.Abs(c.Col - cur.Col), c.Index)).Index; + var others = cells + .Where(c => c.Index != cur.Index && FurtherY(c, cur, sign)) + .ToList(); + if (others.Count == 0) + return null; - return null; + var nextY = others.MinBy(c => Math.Abs(c.CenterY - cur.CenterY)).CenterY; + var rowSlop = Math.Max(cur.Height * 0.5, 1); + var adjacent = others.Where(c => Math.Abs(c.CenterY - nextY) <= rowSlop).ToList(); + return adjacent.MinBy(c => (Math.Abs(c.CenterX - cur.CenterX), c.Index)).Index; } /// - /// After moving the viewport by one page, pick the tile in the same column - /// closest to the same vertical position within the viewport. Not "skip N - /// items" and not a jump onto a section header. + /// After moving the viewport by one page, pick the tile in the same visual + /// column closest to the same vertical position within the viewport. Not + /// "skip N items" and not a jump onto a section header. /// public static int? PickPage( IReadOnlyList cells, @@ -123,15 +235,13 @@ public static IReadOnlyList LayoutWrap( var targetY = newViewportTop + yInViewport; var sameCol = cells - .Where(c => c.Col == cur.Col && c.Index != cur.Index) - .Where(c => sign > 0 ? c.CenterY > cur.CenterY : c.CenterY < cur.CenterY) + .Where(c => c.Index != cur.Index && SameColumnX(c, cur) && FurtherY(c, cur, sign)) .ToList(); if (sameCol.Count > 0) return ClosestToY(sameCol, targetY, sign).Index; var others = cells - .Where(c => c.Index != cur.Index) - .Where(c => sign > 0 ? c.CenterY > cur.CenterY : c.CenterY < cur.CenterY) + .Where(c => c.Index != cur.Index && FurtherY(c, cur, sign)) .ToList(); if (others.Count == 0) return null; @@ -139,7 +249,7 @@ public static IReadOnlyList LayoutWrap( var rowY = others.MinBy(c => Math.Abs(c.CenterY - targetY)).CenterY; var slop = Math.Max(cur.Height * 0.5, 1); var row = others.Where(c => Math.Abs(c.CenterY - rowY) <= slop).ToList(); - return row.MinBy(c => (Math.Abs(c.Col - cur.Col), c.Index)).Index; + return row.MinBy(c => (Math.Abs(c.CenterX - cur.CenterX), c.Index)).Index; } /// @@ -176,17 +286,17 @@ private static bool TryCurrent(IReadOnlyList cells, int currentIndex, out return true; } - private static Cell? CellInColumn(List row, int col) + private static bool SameColumnX(Cell a, Cell b) { - foreach (var c in row) - { - if (c.Col == col) - return c; - } - - return null; + var slop = Math.Max(Math.Max(a.Width, b.Width) * 0.5, 1); + return Math.Abs(a.CenterX - b.CenterX) <= slop; } + private static bool FurtherY(Cell candidate, Cell current, int sign) => + sign > 0 + ? candidate.CenterY > current.CenterY + 1 + : candidate.CenterY < current.CenterY - 1; + private static Cell ClosestToY(List cells, double targetY, int sign) => cells.MinBy(c => { diff --git a/tests/Aperture.Core.Tests/TileSpatialNavigationTests.cs b/tests/Aperture.Core.Tests/TileSpatialNavigationTests.cs index 70535bd..1f81962 100644 --- a/tests/Aperture.Core.Tests/TileSpatialNavigationTests.cs +++ b/tests/Aperture.Core.Tests/TileSpatialNavigationTests.cs @@ -162,4 +162,114 @@ public void CollapsedSection_ConsumesHeaderGap_ButNoCells() // Down from last tile of section 0 (index 1, col 1) crosses the collapsed bucket to col 1 of section 2. Assert.Equal(5, TileSpatialNavigation.PickVertical(cells, 1, +1)); } + + // --- 0.8.5: visual X, not a stale column count (Paul's recording) --- + + [Theory] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + [InlineData(6)] + public void Down_FromColumn0_StaysInColumn0_NotColumn1(int columns) + { + var cells = TileSpatialNavigation.LayoutWrap([columns * 3], columns, tileWidth: 100, tileHeight: 100, headerHeight: 0); + var pick = TileSpatialNavigation.PickVertical(cells, 0, +1); + Assert.Equal(columns, pick); // next row, same visual column + Assert.NotEqual(columns + 1, pick); // must not slide into column 1 + Assert.Equal(cells[0].X, cells[pick!.Value].X); + } + + [Fact] + public void Up_FourColumnWrap_DoesNotUseStaleThree() + { + // Paul's 4-column zoom: Need a Hint at visual row 3, col 3 (1-based) + // is index 10. Up must stay in that column (index 6), not land on + // index 7 — Bar Chart, one tile right. 0.8.4 subtracted 3 because + // (ActualWidth-12)/slot ignored that the viewport already excluded + // the scrollbar, so calculatedColumns = 3. + const int visualColumns = 4; + const int wrongColumns = 3; + var visual = TileSpatialNavigation.LayoutWrap( + [visualColumns * 5], visualColumns, tileWidth: 100, tileHeight: 100, headerHeight: 0); + var staleLabels = TileSpatialNavigation.WithIndexColumns(visual, wrongColumns); + + const int needAHint = 10; // row 2, col 2 (0-based) on a 4-col wrap + const int barChartDiagonal = needAHint - wrongColumns; // 7 — today's math + const int sameColumnUp = needAHint - visualColumns; // 6 + + Assert.Equal(200, visual[needAHint].X); + Assert.Equal(visual[needAHint].X, visual[sameColumnUp].X); + Assert.NotEqual(visual[needAHint].X, visual[barChartDiagonal].X); + + // Col/Row look like a 3-column wrap (the 0.8.4 picker). X is still 4-col. + Assert.Equal(1, staleLabels[needAHint].Col); + Assert.Equal(7, StepByColumnCount(needAHint, -wrongColumns)); + + var pick = TileSpatialNavigation.PickVertical(staleLabels, needAHint, -1); + Assert.Equal(sameColumnUp, pick); + Assert.NotEqual(barChartDiagonal, pick); + Assert.Equal(visual[needAHint].CenterX, visual[pick!.Value].CenterX); + } + + [Fact] + public void Up_SixColumnWrap_DoesNotSkipARow() + { + // 6-column zoom: green Wordle at row 6, col 3 (1-based) is index 32. + // One Up must be the tile immediately above (index 26), not a 12-item + // jump that skips the in-between row. + var cells = TileSpatialNavigation.LayoutWrap([42], columns: 6, tileWidth: 100, tileHeight: 100, headerHeight: 0); + const int green = 5 * 6 + 2; // row 5, col 2 + Assert.Equal(26, TileSpatialNavigation.PickVertical(cells, green, -1)); + Assert.Equal(20, TileSpatialNavigation.PickVertical(cells, 26, -1)); + Assert.NotEqual(20, TileSpatialNavigation.PickVertical(cells, green, -1)); + Assert.Equal(cells[green].X, cells[26].X); + } + + [Fact] + public void Up_SixColumnWrap_StaleFiveStillUsesVisualX() + { + var visual = TileSpatialNavigation.LayoutWrap([42], columns: 6, tileWidth: 100, tileHeight: 100, headerHeight: 0); + var stale = TileSpatialNavigation.WithIndexColumns(visual, staleColumns: 5); + const int green = 32; + var pick = TileSpatialNavigation.PickVertical(stale, green, -1); + Assert.Equal(26, pick); + Assert.NotEqual(green - 5, pick); + Assert.Equal(visual[green].X, visual[pick!.Value].X); + } + + [Fact] + public void ResolveColumnCount_PrefersVisualFour_OverStaleFittedThree() + { + // Viewport already excludes the scrollbar; subtracting padding again + // (or using TileSize+20) yields 3. The wrap fitted 4. + Assert.Equal(4, TileSpatialNavigation.FitColumns(availableWidth: 860, slotWidth: 215)); + Assert.Equal(3, TileSpatialNavigation.FitColumns(availableWidth: 860 - 12, slotWidth: 215 + 20)); + Assert.Equal(4, TileSpatialNavigation.ResolveColumnCount(columnsFromLayoutX: 4, fittedFromWidth: 3)); + Assert.Equal(6, TileSpatialNavigation.ResolveColumnCount(columnsFromLayoutX: 5, fittedFromWidth: 6)); + } + + [Fact] + public void DistinctColumnXs_CountsVisualColumns_NotFlatIndex() + { + var visual = TileSpatialNavigation.LayoutWrap([12], columns: 4, tileWidth: 100, tileHeight: 100, headerHeight: 0); + var xs = TileSpatialNavigation.DistinctColumnXs(visual.Select(c => c.X).ToList(), slop: 50); + Assert.Equal(4, xs.Count); + Assert.Equal(4, TileSpatialNavigation.ResolveColumnCount(xs.Count, fittedFromWidth: 3)); + } + + [Fact] + public void Down_DateSectioned_SameVisualX_AtTwoColumnCounts() + { + foreach (var columns in new[] { 4, 6 }) + { + var cells = TileSpatialNavigation.LayoutWrap( + [columns + 2, columns * 2], columns, tileWidth: 100, tileHeight: 100, headerHeight: 50); + var pick = TileSpatialNavigation.PickVertical(cells, 0, +1); + Assert.Equal(columns, pick); + Assert.Equal(cells[0].X, cells[pick!.Value].X); + Assert.NotEqual(columns + 1, pick); + } + } + + private static int StepByColumnCount(int index, int delta) => index + delta; } From ef6eb2543b14f123117791817806858f1d3de3f9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 23:19:18 +0000 Subject: [PATCH 2/6] fix: parse DistinctColumnXs without collection-init ambiguity WPF codegen rejected `{ [sorted[0]] }` as a dictionary initializer. Co-authored-by: Paul Smith --- src/Aperture.App/TileSpatialNavigation.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Aperture.App/TileSpatialNavigation.cs b/src/Aperture.App/TileSpatialNavigation.cs index 5c2cb6d..d996c10 100644 --- a/src/Aperture.App/TileSpatialNavigation.cs +++ b/src/Aperture.App/TileSpatialNavigation.cs @@ -54,7 +54,8 @@ public static IReadOnlyList DistinctColumnXs(IReadOnlyList xs, d slop = Math.Max(1, slop); var sorted = xs.OrderBy(x => x).ToList(); - var groups = new List> { [sorted[0]] }; + var groups = new List>(); + groups.Add([sorted[0]]); for (var i = 1; i < sorted.Count; i++) { if (Math.Abs(sorted[i] - groups[^1][0]) > slop) From 35c9410ecf68397428c2c9fcd7032265c09dcbe3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 23:19:53 +0000 Subject: [PATCH 3/6] fix: DistinctColumnXs without collection expressions WPF codegen on WKS-02 rejects [] / collection-init sugar (CS1003/CS1525). Use Array.Empty and explicit List only. No LangVersion bump. Co-authored-by: Paul Smith --- src/Aperture.App/TileSpatialNavigation.cs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/Aperture.App/TileSpatialNavigation.cs b/src/Aperture.App/TileSpatialNavigation.cs index d996c10..12bc727 100644 --- a/src/Aperture.App/TileSpatialNavigation.cs +++ b/src/Aperture.App/TileSpatialNavigation.cs @@ -50,18 +50,27 @@ public static int ResolveColumnCount(int columnsFromLayoutX, int fittedFromWidth public static IReadOnlyList DistinctColumnXs(IReadOnlyList xs, double slop) { if (xs is null || xs.Count == 0) - return []; + return Array.Empty(); slop = Math.Max(1, slop); var sorted = xs.OrderBy(x => x).ToList(); var groups = new List>(); - groups.Add([sorted[0]]); + var first = new List(); + first.Add(sorted[0]); + groups.Add(first); for (var i = 1; i < sorted.Count; i++) { - if (Math.Abs(sorted[i] - groups[^1][0]) > slop) - groups.Add([sorted[i]]); + var last = groups[groups.Count - 1]; + if (Math.Abs(sorted[i] - last[0]) > slop) + { + var next = new List(); + next.Add(sorted[i]); + groups.Add(next); + } else - groups[^1].Add(sorted[i]); + { + last.Add(sorted[i]); + } } return groups.Select(g => g.Average()).ToList(); From 7f49b8a0f689666a3c04f9a45444313d17f1a940 Mon Sep 17 00:00:00 2001 From: Paul Smith Date: Fri, 21 Aug 2026 19:35:25 -0500 Subject: [PATCH 4/6] fix: tree keyboard focus and non-blocking folder arrows (0.8.6-beta1) Tab/Shift+Tab cycle only the folder tree and the tile list; the ribbon and Everything are not stops. Click or Tab onto the tree focuses it so Up/Down move folders immediately, without changing the destination selection. Tree selection commits first. Tile rebuild is last-one-wins off the UI thread, generation-stamped at request time, and applied at Background so queued arrows are not gated on grid render. The existing Loading spinner covers a slow rebuild after the node has already moved. UNVERIFIED: live WPF Tab/arrow/spinner timing on a large library (unit tests cover the focus policy, not dispatcher interleaving). --- CHANGELOG.md | 10 +++ src/Aperture.App/Aperture.App.csproj | 2 +- src/Aperture.App/LibraryPaneFocus.cs | 46 +++++++++- src/Aperture.App/MainWindow.xaml | 16 +++- src/Aperture.App/MainWindow.xaml.cs | 85 +++++++++++++++++-- src/Aperture.App/ViewModels/MainViewModel.cs | 60 ++++++++++--- .../LibraryPaneFocusTests.cs | 58 ++++++++++++- 7 files changed, 244 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7438b6..3607b22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to Aperture Image Viewer are documented here. The format fol ## [Unreleased] +## [0.8.6-beta1] - 2026-08-21 + +### Fixed +- **Folder-tree keyboard** — Tab / Shift+Tab cycle only the folder tree and the tile + list (ribbon and the Everything button stay out). Tabbing in does not change the + destination selection. Clicking or Tabbing to the tree gives it keyboard focus so + arrows move folders immediately. Arrowing the tree commits the selected node at + once; the tile pane rebuilds in the background and shows the existing Loading… + spinner if that rebuild is slow, instead of gating the next arrow on grid render. + ## [0.8.5-beta1] - 2026-08-21 ### Fixed diff --git a/src/Aperture.App/Aperture.App.csproj b/src/Aperture.App/Aperture.App.csproj index 7bd87e1..616b907 100644 --- a/src/Aperture.App/Aperture.App.csproj +++ b/src/Aperture.App/Aperture.App.csproj @@ -16,7 +16,7 @@ true Assets\aperture.ico Aperture - 0.8.5-beta1 + 0.8.6-beta1 Aperture Image Viewer Aperture Image Viewer A fast local image & video browser for Windows. diff --git a/src/Aperture.App/LibraryPaneFocus.cs b/src/Aperture.App/LibraryPaneFocus.cs index bbd7ca0..6730339 100644 --- a/src/Aperture.App/LibraryPaneFocus.cs +++ b/src/Aperture.App/LibraryPaneFocus.cs @@ -8,9 +8,10 @@ namespace Aperture.App; /// -/// Keyboard focus for the library contents/tile pane. Clicking a tile already -/// focuses that ; clicking the empty canvas did not, -/// so arrow / PageUp / PageDown / scroll keys stayed on the folder tree. +/// Keyboard focus for the library: folder tree ↔ tile pane. Clicking a tile +/// already focuses that ; clicking the empty canvas +/// did not, so arrow / PageUp / PageDown / scroll keys stayed on the folder +/// tree. Tab onto either pane must not change its selection. /// internal static class LibraryPaneFocus { @@ -47,6 +48,45 @@ public static bool ShouldFocusContentsPane(DependencyObject? originalSource, IIn public static bool ShouldKeepExistingSelection(bool fromOutside, bool newFocusIsItem, bool newFocusIsSelected) => fromOutside && newFocusIsItem && !newFocusIsSelected; + internal enum TreeFocusAction + { + /// Leave WPF's focus target alone (arrows inside the tree, or a click). + Leave, + /// Focus the already-selected folder so arrows move from there. + FocusSelectedItem, + /// + /// Keep focus on the TreeView — "Everything" is showing; do not select the first folder. + /// + FocusTree, + } + + /// + /// Where keyboard focus should land when it arrives on the folder tree. + /// Tab must not change the selected folder; click must not yank focus back + /// to a previous node while the new item's IsSelected is still catching up. + /// + public static TreeFocusAction OnTreeKeyboardArrival( + bool fromOutside, bool mousePressed, + bool newFocusIsItem, bool newFocusIsSelected, + bool hasSelectedItem) + { + if (!fromOutside) + return TreeFocusAction.Leave; + + // Click path focuses the item in PreviewMouseDown; IsSelected may still be false. + if (mousePressed) + return TreeFocusAction.Leave; + + if (newFocusIsItem && newFocusIsSelected) + return TreeFocusAction.Leave; + + if (hasSelectedItem) + return TreeFocusAction.FocusSelectedItem; + + // Tab onto the tree (or onto the first folder) while Everything is showing. + return TreeFocusAction.FocusTree; + } + public static Hit Classify(DependencyObject? originalSource, IInputElement? currentFocus) { // A dialog keeps focus even if the click somehow reached the pane diff --git a/src/Aperture.App/MainWindow.xaml b/src/Aperture.App/MainWindow.xaml index c1e8f0a..30ed633 100644 --- a/src/Aperture.App/MainWindow.xaml +++ b/src/Aperture.App/MainWindow.xaml @@ -497,12 +497,15 @@ + Width="120" VerticalAlignment="Center" VerticalContentAlignment="Center" Height="30" + IsTabStop="False" /> - - + +