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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,30 @@ 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.
- **First click** on an unfocused window or pane focuses the clicked folder / tile /
canvas in that same click (Explorer-style). The activating click is not swallowed.
The folder expand arrow and include checkbox do not change the selected folder.

## [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
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.4-beta1</Version>
<Version>0.8.6-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
128 changes: 125 additions & 3 deletions src/Aperture.App/LibraryPaneFocus.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@
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.
/// Keyboard focus for the library: folder tree ↔ 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. Tab onto either pane must not change its selection.
/// </summary>
internal static class LibraryPaneFocus
{
Expand Down Expand Up @@ -47,6 +48,127 @@ public static bool ShouldFocusContentsPane(DependencyObject? originalSource, IIn
public static bool ShouldKeepExistingSelection(bool fromOutside, bool newFocusIsItem, bool newFocusIsSelected) =>
fromOutside && newFocusIsItem && !newFocusIsSelected;

/// <summary>Win32 <c>WM_MOUSEACTIVATE</c> — the click that activates a window.</summary>
internal const int WmMouseActivate = 0x0021;

/// <summary>Activate and deliver the click to the control (Explorer). Not <c>MA_ACTIVATEANDEAT</c>.</summary>
internal const int MaActivate = 1;

/// <summary>
/// True when this is the click that activates the window. Return
/// <see cref="MaActivate"/> so the same click focuses the folder / tile /
/// canvas — an activation-only first click would swallow it.
/// </summary>
public static bool TryPassActivatingClick(int msg, out IntPtr result)
{
if (msg != WmMouseActivate)
{
result = IntPtr.Zero;
return false;
}
result = new IntPtr(MaActivate);
return true;
}

internal enum TreeFocusAction
{
/// <summary>Leave WPF's focus target alone (arrows inside the tree, or a click).</summary>
Leave,
/// <summary>Focus the already-selected folder so arrows move from there.</summary>
FocusSelectedItem,
/// <summary>
/// Keep focus on the TreeView — "Everything" is showing; do not select the first folder.
/// </summary>
FocusTree,
}

/// <summary>What a left-click on the folder tree should do to keyboard focus.</summary>
internal enum TreeClickAction
{
/// <summary>Folder row / name / icon — <c>TreeViewItem.Focus()</c> (which also selects).</summary>
FocusItem,
/// <summary>
/// Expand chevron or include checkbox: do not <c>Focus()</c> the item.
/// That call selects the folder; Explorer and 0.8.5 keep those hits off selection.
/// Alias text boxes keep the caret.
/// </summary>
Leave,
/// <summary>Empty tree chrome — focus the TreeView without changing selection.</summary>
FocusTree,
}

internal enum TreeHit
{
Row,
Expander,
IncludeCheckBox,
TextInput,
Canvas,
}

/// <summary>
/// Same-click folder focus applies to the row only. The expander
/// (<see cref="ToggleButton"/>, <c>Focusable=False</c>) and the root include
/// checkbox must not select the folder.
/// </summary>
public static TreeClickAction OnTreePreviewClick(TreeHit hit) => hit switch
{
TreeHit.Row => TreeClickAction.FocusItem,
TreeHit.Canvas => TreeClickAction.FocusTree,
_ => TreeClickAction.Leave,
};

public static TreeClickAction OnTreePreviewClick(DependencyObject? originalSource) =>
OnTreePreviewClick(ClassifyTreeHit(originalSource));

internal static TreeHit ClassifyTreeHit(DependencyObject? originalSource)
{
var node = originalSource;
while (node is not null)
{
if (node is TextBox or PasswordBox or ComboBox or RichTextBox)
return TreeHit.TextInput;
// CheckBox before ToggleButton — CheckBox is a ToggleButton.
if (node is CheckBox)
return TreeHit.IncludeCheckBox;
if (node is ToggleButton)
return TreeHit.Expander;
if (node is TreeViewItem)
return TreeHit.Row;
if (node is TreeView or Window)
return TreeHit.Canvas;
node = Parent(node);
}
return TreeHit.Canvas;
}

/// <summary>
/// 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 <c>IsSelected</c> is still catching up.
/// </summary>
public static TreeFocusAction OnTreeKeyboardArrival(
bool fromOutside, bool mousePressed,
bool newFocusIsItem, bool newFocusIsSelected,
bool hasSelectedItem)
{
if (!fromOutside)
return TreeFocusAction.Leave;

// Row-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
Expand Down
16 changes: 12 additions & 4 deletions src/Aperture.App/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -497,12 +497,15 @@
<TextBlock Text="Sort" Foreground="{StaticResource Muted}" VerticalAlignment="Center"
Margin="0,0,6,0" FontSize="12" />
<ComboBox ItemsSource="{Binding SortLabels}" SelectedItem="{Binding SortPreset, Mode=TwoWay}"
Width="120" VerticalAlignment="Center" VerticalContentAlignment="Center" Height="30" />
Width="120" VerticalAlignment="Center" VerticalContentAlignment="Center" Height="30"
IsTabStop="False" />
<Rectangle Width="1" Fill="{StaticResource Line}" Margin="8,2,6,2" />
<TextBlock Text="Show" Foreground="{StaticResource Muted}" VerticalAlignment="Center"
Margin="0,0,6,0" FontSize="12" />
<CheckBox Content="Pictures" IsChecked="{Binding ShowImages}" VerticalAlignment="Center" Margin="0,0,8,0" />
<CheckBox Content="Videos" IsChecked="{Binding ShowVideos}" VerticalAlignment="Center" />
<CheckBox Content="Pictures" IsChecked="{Binding ShowImages}" VerticalAlignment="Center" Margin="0,0,8,0"
IsTabStop="False" />
<CheckBox Content="Videos" IsChecked="{Binding ShowVideos}" VerticalAlignment="Center"
IsTabStop="False" />
<Button Content="{Binding PreviewLabel}" Style="{StaticResource ToolButton}" Margin="10,0,0,0"
Command="{Binding CyclePreviewCommand}"
ToolTip="Preview / inspector pane — click to cycle: right → bottom → off" />
Expand Down Expand Up @@ -580,7 +583,8 @@
<TextBlock DockPanel.Dock="Top" Text="LIBRARIES" Foreground="{StaticResource CanvasMuted}"
FontSize="11" FontWeight="SemiBold" Margin="14,12,14,6" />
<Button x:Name="EverythingButton" DockPanel.Dock="Top" Command="{Binding NavigateHomeCommand}"
Margin="8,0,8,2" KeyboardNavigation.TabIndex="0"
Click="OnEverythingClick"
Margin="8,0,8,2" IsTabStop="False" Focusable="False"
HorizontalContentAlignment="Stretch" Cursor="Hand" Padding="0" BorderThickness="0"
Foreground="{StaticResource CanvasInk}" FontSize="14"
ToolTip="Show everything across all folders">
Expand Down Expand Up @@ -620,11 +624,14 @@
FontSize="14"
KeyboardNavigation.TabNavigation="Once"
SelectedItemChanged="OnTreeSelectedChanged" PreviewKeyDown="OnTreeKeyDown"
PreviewMouseLeftButtonDown="OnTreePreviewMouseLeftButtonDown"
PreviewGotKeyboardFocus="OnTreePreviewGotKeyboardFocus">
<TreeView.ItemContainerStyle>
<Style TargetType="TreeViewItem">
<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}" />
<Setter Property="IsSelected" Value="{Binding IsSelected, Mode=TwoWay}" />
<Setter Property="Focusable" Value="True" />
<Setter Property="KeyboardNavigation.IsTabStop" Value="False" />
<Setter Property="Padding" Value="2,3" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<!-- Custom template: the selection cue is driven by the item's own IsSelected
Expand Down Expand Up @@ -697,6 +704,7 @@
<StackPanel Orientation="Horizontal" Margin="0,2">
<CheckBox IsChecked="{Binding Root.IsIncluded, Mode=TwoWay}" VerticalAlignment="Center"
Margin="0,0,6,0" ToolTip="Include this folder"
Focusable="False" IsTabStop="False"
Visibility="{Binding IsRoot, Converter={StaticResource BoolToVis}}" />
<Path Data="M2,5 L6,5 L7.6,6.8 L14,6.8 L14,13 L2,13 Z" Fill="#3FC2B6"
Stretch="Uniform" Width="15" Height="15" Margin="0,0,6,0" VerticalAlignment="Center"
Expand Down
Loading
Loading