From 81f27b4771cb3d73fd35fd3037add8176955c3a4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 21:36:35 +0000 Subject: [PATCH 1/2] fix: focus the tile pane when clicking its empty canvas A tile click already focused the ListBoxItem, so arrows and PageUp/PageDown moved the grid. A click on the empty canvas did not, and the folder tree kept keyboard focus. Claim focus at the contents-pane level on canvas clicks; leave tiles, section headers, text boxes, and dialogs alone. Version 0.8.3-beta1 so merge re-exercises auto-tag/release. Co-authored-by: Paul Smith --- CHANGELOG.md | 7 ++ src/Aperture.App/Aperture.App.csproj | 2 +- src/Aperture.App/LibraryPaneFocus.cs | 111 ++++++++++++++++++ src/Aperture.App/MainWindow.xaml | 11 +- src/Aperture.App/MainWindow.xaml.cs | 13 ++ .../LibraryPaneFocusTests.cs | 27 +++++ 6 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 src/Aperture.App/LibraryPaneFocus.cs create mode 100644 tests/Aperture.Core.Tests/LibraryPaneFocusTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b6427e..bac15df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to Aperture Image Viewer are documented here. The format fol ## [Unreleased] +## [0.8.3-beta1] - 2026-08-21 + +### Fixed +- **Clicking the tile-pane canvas** (empty background, not a tile or section header) now + moves keyboard focus to the contents pane, so arrows, PageUp/PageDown, and scroll keys + apply to the grid instead of staying on the folder tree. + ## [0.8.2-beta1] - 2026-08-21 ### Added diff --git a/src/Aperture.App/Aperture.App.csproj b/src/Aperture.App/Aperture.App.csproj index 7ec9d81..2f98a89 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.2-beta1 + 0.8.3-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 new file mode 100644 index 0000000..6bca3bc --- /dev/null +++ b/src/Aperture.App/LibraryPaneFocus.cs @@ -0,0 +1,111 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Media; +using Aperture.App.ViewModels; + +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. +/// +internal static class LibraryPaneFocus +{ + internal enum Hit + { + /// Empty pane background, scrollbar, padding — claim the grid. + Canvas, + /// A media tile already focuses its . + Tile, + /// A date-section header already focuses its . + SectionHeader, + /// Search, rename, notes — leave the caret where it is. + TextInput, + /// A dialog owns the click or the current focus. + Dialog, + } + + /// + /// True when this click should move keyboard focus onto the contents pane. + /// Tile and section-header clicks already focus themselves; text boxes and + /// dialogs keep focus. + /// + public static bool ShouldFocusContentsPane(Hit hit) => hit is Hit.Canvas; + + public static bool ShouldFocusContentsPane(DependencyObject? originalSource, IInputElement? currentFocus) => + ShouldFocusContentsPane(Classify(originalSource, currentFocus)); + + public static Hit Classify(DependencyObject? originalSource, IInputElement? currentFocus) + { + // A dialog keeps focus even if the click somehow reached the pane + // (modeless / leftover hit-test). Modal dialogs never get here. + if (IsInDialog(originalSource) || IsInDialog(currentFocus as DependencyObject)) + return Hit.Dialog; + + if (IsTextInput(originalSource)) + return Hit.TextInput; + + if (IsTile(originalSource)) + return Hit.Tile; + + if (IsSectionHeader(originalSource)) + return Hit.SectionHeader; + + return Hit.Canvas; + } + + private static bool IsInDialog(DependencyObject? node) + { + if (node is null) + return false; + var window = Window.GetWindow(node); + return window is not null and not MainWindow; + } + + private static bool IsTextInput(DependencyObject? node) + { + while (node is not null) + { + if (node is TextBox or PasswordBox or ComboBox or RichTextBox) + return true; + if (node is ListBoxItem or ListBox or Window) + break; + node = Parent(node); + } + return false; + } + + private static bool IsTile(DependencyObject? node) + { + while (node is not null) + { + if (node is ListBoxItem { DataContext: TileVm }) + return true; + if (node is ListBox or Window) + break; + node = Parent(node); + } + return false; + } + + private static bool IsSectionHeader(DependencyObject? node) + { + while (node is not null) + { + if (node is ToggleButton) + return true; + if (node is ListBoxItem or ListBox or Window) + break; + node = Parent(node); + } + return false; + } + + private static DependencyObject? Parent(DependencyObject node) => + node is Visual or System.Windows.Media.Media3D.Visual3D + ? VisualTreeHelper.GetParent(node) + : LogicalTreeHelper.GetParent(node); +} diff --git a/src/Aperture.App/MainWindow.xaml b/src/Aperture.App/MainWindow.xaml index fd4c9a3..4eb60bf 100644 --- a/src/Aperture.App/MainWindow.xaml +++ b/src/Aperture.App/MainWindow.xaml @@ -753,12 +753,16 @@ - - + + - + diff --git a/src/Aperture.App/MainWindow.xaml.cs b/src/Aperture.App/MainWindow.xaml.cs index 0419d34..5c73b8c 100644 --- a/src/Aperture.App/MainWindow.xaml.cs +++ b/src/Aperture.App/MainWindow.xaml.cs @@ -661,6 +661,19 @@ private void OnTileBadgeClick(object sender, RoutedEventArgs e) } } + /// + /// Empty-canvas click in the tile pane: move keyboard focus onto the grid so + /// arrows / PageUp / PageDown / scroll keys apply here, not the folder tree. + /// Tile and section-header clicks already focus themselves; text boxes and + /// dialogs are left alone. + /// + private void OnContentsPanePreviewLeftDown(object sender, MouseButtonEventArgs e) + { + if (LibraryPaneFocus.ShouldFocusContentsPane( + e.OriginalSource as DependencyObject, Keyboard.FocusedElement)) + ItemsList.Focus(); + } + // --- Drag a tile (or the whole selection) out to the shell as real file(s) --- // Lets you drop images straight onto an email, a folder, or any file-drop target. private Point _tileDragStart; diff --git a/tests/Aperture.Core.Tests/LibraryPaneFocusTests.cs b/tests/Aperture.Core.Tests/LibraryPaneFocusTests.cs new file mode 100644 index 0000000..5c95346 --- /dev/null +++ b/tests/Aperture.Core.Tests/LibraryPaneFocusTests.cs @@ -0,0 +1,27 @@ +using Aperture.App; + +namespace Aperture.Core.Tests; + +/// +/// Contract for when a contents-pane click should move keyboard focus onto the +/// tile grid. Live WPF focus (dispatcher, visual tree, Keyboard.Focus) is not +/// exercised here — verify the click path on a real window: +/// folder in the tree → click empty tile-pane canvas → arrows / PageUp / +/// PageDown / scroll keys move the grid, not the tree. Clicking a folder, +/// a tile, a section header, the search box, or a dialog must stay as today. +/// +public class LibraryPaneFocusTests +{ + [Theory] + [InlineData(LibraryPaneFocus.Hit.Canvas, true)] + [InlineData(LibraryPaneFocus.Hit.Tile, false)] + [InlineData(LibraryPaneFocus.Hit.SectionHeader, false)] + [InlineData(LibraryPaneFocus.Hit.TextInput, false)] + [InlineData(LibraryPaneFocus.Hit.Dialog, false)] + public void ShouldFocusContentsPane_OnlyCanvasClaimsTheGrid(LibraryPaneFocus.Hit hit, bool expected) => + Assert.Equal(expected, LibraryPaneFocus.ShouldFocusContentsPane(hit)); + + [Fact] + public void Classify_NullSourceIsCanvas() => + Assert.Equal(LibraryPaneFocus.Hit.Canvas, LibraryPaneFocus.Classify(null, null)); +} From 7f7af1a72b94ea42c4f2e43d83461debb602746d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 21:38:46 +0000 Subject: [PATCH 2/2] test: avoid public method with internal LibraryPaneFocus.Hit CS0051: xUnit theories are public, so the Hit enum cannot appear in the signature. Split into facts that use the enum only inside the body. Co-authored-by: Paul Smith --- .../LibraryPaneFocusTests.cs | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/tests/Aperture.Core.Tests/LibraryPaneFocusTests.cs b/tests/Aperture.Core.Tests/LibraryPaneFocusTests.cs index 5c95346..46ac10e 100644 --- a/tests/Aperture.Core.Tests/LibraryPaneFocusTests.cs +++ b/tests/Aperture.Core.Tests/LibraryPaneFocusTests.cs @@ -12,14 +12,25 @@ namespace Aperture.Core.Tests; /// public class LibraryPaneFocusTests { - [Theory] - [InlineData(LibraryPaneFocus.Hit.Canvas, true)] - [InlineData(LibraryPaneFocus.Hit.Tile, false)] - [InlineData(LibraryPaneFocus.Hit.SectionHeader, false)] - [InlineData(LibraryPaneFocus.Hit.TextInput, false)] - [InlineData(LibraryPaneFocus.Hit.Dialog, false)] - public void ShouldFocusContentsPane_OnlyCanvasClaimsTheGrid(LibraryPaneFocus.Hit hit, bool expected) => - Assert.Equal(expected, LibraryPaneFocus.ShouldFocusContentsPane(hit)); + [Fact] + public void Canvas_ClaimsTheGrid() => + Assert.True(LibraryPaneFocus.ShouldFocusContentsPane(LibraryPaneFocus.Hit.Canvas)); + + [Fact] + public void Tile_DoesNotClaim() => + Assert.False(LibraryPaneFocus.ShouldFocusContentsPane(LibraryPaneFocus.Hit.Tile)); + + [Fact] + public void SectionHeader_DoesNotClaim() => + Assert.False(LibraryPaneFocus.ShouldFocusContentsPane(LibraryPaneFocus.Hit.SectionHeader)); + + [Fact] + public void TextInput_DoesNotClaim() => + Assert.False(LibraryPaneFocus.ShouldFocusContentsPane(LibraryPaneFocus.Hit.TextInput)); + + [Fact] + public void Dialog_DoesNotClaim() => + Assert.False(LibraryPaneFocus.ShouldFocusContentsPane(LibraryPaneFocus.Hit.Dialog)); [Fact] public void Classify_NullSourceIsCanvas() =>