Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/Aperture.App/Aperture.App.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
<UseWPF>true</UseWPF>
<ApplicationIcon>Assets\aperture.ico</ApplicationIcon>
<AssemblyName>Aperture</AssemblyName>
<Version>0.8.2-beta1</Version>
<Version>0.8.3-beta1</Version>
<AssemblyTitle>Aperture Image Viewer</AssemblyTitle>
<Product>Aperture Image Viewer</Product>
<Description>A fast local image &amp; video browser for Windows.</Description>
Expand Down
111 changes: 111 additions & 0 deletions src/Aperture.App/LibraryPaneFocus.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Keyboard focus for the library contents/tile pane. Clicking a tile already
/// focuses that <see cref="ListBoxItem"/>; clicking the empty canvas did not,
/// so arrow / PageUp / PageDown / scroll keys stayed on the folder tree.
/// </summary>
internal static class LibraryPaneFocus
{
internal enum Hit
{
/// <summary>Empty pane background, scrollbar, padding — claim the grid.</summary>
Canvas,
/// <summary>A media tile already focuses its <see cref="ListBoxItem"/>.</summary>
Tile,
/// <summary>A date-section header already focuses its <see cref="ToggleButton"/>.</summary>
SectionHeader,
/// <summary>Search, rename, notes — leave the caret where it is.</summary>
TextInput,
/// <summary>A dialog owns the click or the current focus.</summary>
Dialog,
}

/// <summary>
/// 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.
/// </summary>
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);
}
11 changes: 8 additions & 3 deletions src/Aperture.App/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -753,12 +753,16 @@

<!-- Thumbnail grid + preview pane; cells/splitter configured in code (ConfigurePreviewLayout) -->
<Grid x:Name="ContentGrid" Grid.Column="2">
<!-- Browser grid (real resizable column/row so the wrap panel reflows when the preview opens) -->
<Grid x:Name="BrowserHost">
<!-- Browser grid (real resizable column/row so the wrap panel reflows when the preview opens).
Transparent background so empty canvas is hit-testable; a click there focuses the
contents pane (same as a tile click) so arrows / PageUp / PageDown apply to the grid. -->
<Grid x:Name="BrowserHost" Background="Transparent"
PreviewMouseLeftButtonDown="OnContentsPanePreviewLeftDown">
<ListBox x:Name="ItemsList"
ItemsSource="{Binding ItemsView}"
SelectedItem="{Binding SelectedItem, Mode=TwoWay}"
SelectionMode="Extended"
Focusable="True"
SelectionChanged="OnGridSelectionChanged"
PreviewMouseRightButtonDown="OnGridRightButtonDown"
PreviewMouseLeftButtonDown="OnGridPreviewLeftDown"
Expand All @@ -781,7 +785,8 @@
</ListBox.InputBindings>
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<vwp:VirtualizingWrapPanel Orientation="Horizontal" SpacingMode="Uniform" />
<vwp:VirtualizingWrapPanel Orientation="Horizontal" SpacingMode="Uniform"
Background="Transparent" />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.GroupStyle>
Expand Down
13 changes: 13 additions & 0 deletions src/Aperture.App/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,19 @@ private void OnTileBadgeClick(object sender, RoutedEventArgs e)
}
}

/// <summary>
/// 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.
/// </summary>
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;
Expand Down
38 changes: 38 additions & 0 deletions tests/Aperture.Core.Tests/LibraryPaneFocusTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using Aperture.App;

namespace Aperture.Core.Tests;

/// <summary>
/// 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.
/// </summary>
public class LibraryPaneFocusTests
{
[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() =>
Assert.Equal(LibraryPaneFocus.Hit.Canvas, LibraryPaneFocus.Classify(null, null));
}
Loading