Changed behavior to accept keyboard overrides, added keyboard mouse state validator and indirect rules to allow keyboard and mouse navigation

This commit is contained in:
Ano-sys
2026-04-03 02:22:57 +02:00
parent 162df64596
commit d25e8238f2
4 changed files with 213 additions and 75 deletions
+72 -61
View File
@@ -29,6 +29,12 @@ public static class CardHoverEffectBehavior
typeof(CardHoverEffectBehavior), typeof(CardHoverEffectBehavior),
0.99); 0.99);
public static readonly AttachedProperty<bool> SuppressPointerHoverProperty =
AvaloniaProperty.RegisterAttached<Control, bool>(
"SuppressPointerHover",
typeof(CardHoverEffectBehavior),
false);
static CardHoverEffectBehavior() static CardHoverEffectBehavior()
{ {
IsEnabledProperty.Changed.AddClassHandler<Control>(OnIsEnabledChanged); IsEnabledProperty.Changed.AddClassHandler<Control>(OnIsEnabledChanged);
@@ -43,10 +49,64 @@ public static class CardHoverEffectBehavior
public static double GetScaleFactor(AvaloniaObject obj) => obj.GetValue(ScaleFactorProperty); public static double GetScaleFactor(AvaloniaObject obj) => obj.GetValue(ScaleFactorProperty);
public static void SetScaleFactor(AvaloniaObject obj, double value) => obj.SetValue(ScaleFactorProperty, value); public static void SetScaleFactor(AvaloniaObject obj, double value) => obj.SetValue(ScaleFactorProperty, value);
public static bool GetSuppressPointerHover(AvaloniaObject obj) => obj.GetValue(SuppressPointerHoverProperty);
public static void SetSuppressPointerHover(AvaloniaObject obj, bool value) => obj.SetValue(SuppressPointerHoverProperty, value);
public static void ApplyHover(Control element)
{
element.ZIndex = 10;
var visual = ElementComposition.GetElementVisual(element);
var compositor = visual?.Compositor;
if (visual is null || compositor is null) return;
var scaleFactor = (float)GetScaleFactor(element);
var width = (float)element.Bounds.Width;
var height = (float)element.Bounds.Height;
if (width <= 0 || height <= 0) return;
visual.CenterPoint = new Vector3(width / 2f, height / 2f, 0f);
var scaleAnimation = compositor.CreateVector3KeyFrameAnimation();
scaleAnimation.InsertKeyFrame(1.0f, new Vector3(scaleFactor, scaleFactor, 1f));
scaleAnimation.Duration = _animationDuration;
var orientationAnimation = compositor.CreateQuaternionKeyFrameAnimation();
orientationAnimation.InsertKeyFrame(1.0f, Quaternion.Identity);
orientationAnimation.Duration = _animationDuration;
visual.StartAnimation("Scale", scaleAnimation);
visual.StartAnimation(nameof(visual.Orientation), orientationAnimation);
}
public static void ResetHover(Control element)
{
element.ZIndex = 0;
var visual = ElementComposition.GetElementVisual(element);
var compositor = visual?.Compositor;
if (visual is null || compositor is null) return;
var scaleAnimation = compositor.CreateVector3KeyFrameAnimation();
scaleAnimation.InsertKeyFrame(1.0f, new Vector3(1f, 1f, 1f));
scaleAnimation.Duration = _animationDuration;
var orientationAnimation = compositor.CreateQuaternionKeyFrameAnimation();
orientationAnimation.InsertKeyFrame(1.0f, Quaternion.Identity);
orientationAnimation.Duration = _animationDuration;
visual.StartAnimation("Scale", scaleAnimation);
visual.StartAnimation(nameof(visual.Orientation), orientationAnimation);
}
private static void OnIsEnabledChanged(Control c, AvaloniaPropertyChangedEventArgs e) private static void OnIsEnabledChanged(Control c, AvaloniaPropertyChangedEventArgs e)
{ {
if (e.NewValue is not bool enabled) if (e.NewValue is not bool enabled) return;
return;
if (enabled) if (enabled)
{ {
@@ -64,55 +124,30 @@ public static class CardHoverEffectBehavior
private static void Element_PointerEntered(object? sender, PointerEventArgs e) private static void Element_PointerEntered(object? sender, PointerEventArgs e)
{ {
if (sender is not Control element) if (sender is not Control element || GetSuppressPointerHover(element)) return;
return; ApplyHover(element);
}
element.ZIndex = 10; private static void Element_PointerExited(object? sender, PointerEventArgs e)
var visual = ElementComposition.GetElementVisual(element); {
var compositor = visual?.Compositor; if (sender is not Control element || GetSuppressPointerHover(element)) return;
ResetHover(element);
if (visual is null || compositor is null)
return;
var scaleFactor = (float)GetScaleFactor(element);
var width = (float)element.Bounds.Width;
var height = (float)element.Bounds.Height;
if (width <= 0 || height <= 0)
return;
visual.CenterPoint = new Vector3(width / 2f, height / 2f, 0f);
var scaleAnimation = compositor.CreateVector3KeyFrameAnimation();
scaleAnimation.InsertKeyFrame(1.0f, new Vector3(scaleFactor, scaleFactor, 1f));
scaleAnimation.Duration = _animationDuration;
var orientationAnimation = compositor.CreateQuaternionKeyFrameAnimation();
orientationAnimation.InsertKeyFrame(1.0f, Quaternion.Identity);
orientationAnimation.Duration = _animationDuration;
visual.StartAnimation("Scale", scaleAnimation);
visual.StartAnimation(nameof(visual.Orientation), orientationAnimation);
} }
private static void Element_PointerMoved(object? sender, PointerEventArgs e) private static void Element_PointerMoved(object? sender, PointerEventArgs e)
{ {
if (sender is not Control element) if (sender is not Control element || GetSuppressPointerHover(element)) return;
return;
var visual = ElementComposition.GetElementVisual(element); var visual = ElementComposition.GetElementVisual(element);
if (visual is null) if (visual is null) return;
return;
var maxRotationAngle = (float)GetMaxRotationAngle(element); var maxRotationAngle = (float)GetMaxRotationAngle(element);
var width = element.Bounds.Width; var width = element.Bounds.Width;
var height = element.Bounds.Height; var height = element.Bounds.Height;
if (width <= 0 || height <= 0) if (width <= 0 || height <= 0) return;
return;
var position = e.GetPosition(element); var position = e.GetPosition(element);
var centerX = width / 2.0; var centerX = width / 2.0;
@@ -132,28 +167,4 @@ public static class CardHoverEffectBehavior
visual.Orientation = qy * qx; visual.Orientation = qy * qx;
} }
private static void Element_PointerExited(object? sender, PointerEventArgs e)
{
if (sender is not Control element)
return;
element.ZIndex = 0;
var visual = ElementComposition.GetElementVisual(element);
var compositor = visual?.Compositor;
if (visual is null || compositor is null)
return;
var scaleAnimation = compositor.CreateVector3KeyFrameAnimation();
scaleAnimation.InsertKeyFrame(1.0f, new Vector3(1f, 1f, 1f));
scaleAnimation.Duration = _animationDuration;
var orientationAnimation = compositor.CreateQuaternionKeyFrameAnimation();
orientationAnimation.InsertKeyFrame(1.0f, Quaternion.Identity);
orientationAnimation.Duration = _animationDuration;
visual.StartAnimation("Scale", scaleAnimation);
visual.StartAnimation(nameof(visual.Orientation), orientationAnimation);
}
} }
-1
View File
@@ -101,7 +101,6 @@ public partial class MainWindowViewModel : ViewModelBase
public async Task TryFocusImage(int index) public async Task TryFocusImage(int index)
{ {
if (index < 0 || index >= Photos.Count ) return; if (index < 0 || index >= Photos.Count ) return;
// if(SelectedPhoto != null) ResetSelectedPhoto();
SelectedPhoto = Photos[index]; SelectedPhoto = Photos[index];
await SelectedPhoto.LoadFullImage(); await SelectedPhoto.LoadFullImage();
} }
+10 -5
View File
@@ -64,6 +64,8 @@
Background="Transparent" Background="Transparent"
Foreground="#EDEDED" Foreground="#EDEDED"
Margin="5,0,0,0" Margin="5,0,0,0"
IsTabStop="False"
Focusable="False"
> >
<!-- <!--
<MenuItem Header="_File"> <MenuItem Header="_File">
@@ -71,8 +73,8 @@
<MenuItem Header="_Close" Command="{Binding CloseDirectory}"/> <MenuItem Header="_Close" Command="{Binding CloseDirectory}"/>
</MenuItem> </MenuItem>
--> -->
<MenuItem Header="_Open Library" IsVisible="{Binding !PhotosLoaded}" Command="{Binding OpenDirectory}"/> <MenuItem Header="_Open Library" Focusable="False" IsVisible="{Binding !PhotosLoaded}" Command="{Binding OpenDirectory}"/>
<MenuItem Header="_Close Library" IsVisible="{Binding PhotosLoaded}" Command="{Binding CloseDirectory}"/> <MenuItem Header="_Close Library" Focusable="False" IsVisible="{Binding PhotosLoaded}" Command="{Binding CloseDirectory}"/>
</Menu> </Menu>
</DockPanel> </DockPanel>
</Border> </Border>
@@ -114,11 +116,13 @@
Grid.RowSpan="2" Grid.RowSpan="2"
IsVisible="{Binding PhotosLoaded}"> IsVisible="{Binding PhotosLoaded}">
<Border Padding="72,88,72,48"> <Border Padding="72,88,72,48">
<ItemsRepeater ItemsSource="{Binding Photos}" <ItemsRepeater x:Name="Repeater"
ItemsSource="{Binding Photos}"
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" VerticalAlignment="Stretch"
ClipToBounds="False" ClipToBounds="False"
IsTabStop="True"> IsTabStop="True"
>
<ItemsRepeater.Layout> <ItemsRepeater.Layout>
<UniformGridLayout MinItemWidth="220" <UniformGridLayout MinItemWidth="220"
MinItemHeight="290" MinItemHeight="290"
@@ -136,7 +140,8 @@
BoxShadow="0 10 24 0 #30000000" BoxShadow="0 10 24 0 #30000000"
anim:CardHoverEffectBehavior.IsEnabled="True" anim:CardHoverEffectBehavior.IsEnabled="True"
anim:CardHoverEffectBehavior.MaxRotationAngle="2.5" anim:CardHoverEffectBehavior.MaxRotationAngle="2.5"
anim:CardHoverEffectBehavior.ScaleFactor="1.08"> anim:CardHoverEffectBehavior.ScaleFactor="1.08"
>
<StackPanel Orientation="Vertical" <StackPanel Orientation="Vertical"
Spacing="10"> Spacing="10">
<i:Interaction.Behaviors> <i:Interaction.Behaviors>
+131 -8
View File
@@ -1,7 +1,11 @@
using System;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.VisualTree;
using Avalonia.Xaml.Interactions.DragAndDrop; using Avalonia.Xaml.Interactions.DragAndDrop;
using Photos.Animations;
using Photos.Models; using Photos.Models;
using Photos.ViewModels; using Photos.ViewModels;
@@ -9,8 +13,17 @@ namespace Photos.Views;
public partial class MainWindow : Window public partial class MainWindow : Window
{ {
private enum NavigationInputMode
{
Mouse,
Keyboard
}
private NavigationInputMode _navigationInputMode = NavigationInputMode.Mouse;
private IFolderPickerService _folderPickerService; private IFolderPickerService _folderPickerService;
private int _currentImageIndex = 0; private int _currentImageIndex = 0;
private Control? _currentHoveredCard;
public MainWindow() public MainWindow()
{ {
@@ -24,10 +37,25 @@ public partial class MainWindow : Window
(DataContext as MainWindowViewModel)?.WindowDropHandler(e); (DataContext as MainWindowViewModel)?.WindowDropHandler(e);
} }
private void OnKeyDownHandler(object sender, KeyEventArgs e) private async void OnKeyDownHandler(object sender, KeyEventArgs e)
{ {
if (DataContext is not MainWindowViewModel vm) return; if (DataContext is not MainWindowViewModel vm) return;
// input state changer
switch (e.Key)
{
case Key.Enter:
case Key.Escape:
case Key.Left:
case Key.Right:
case Key.Up:
case Key.Down:
_navigationInputMode = NavigationInputMode.Keyboard;
UpdatePointerHoverSuppression();
UpdateKeyboardHover();
break;
}
async Task HandleEnter() async Task HandleEnter()
{ {
if (vm.IsPhotoFocused()) vm.UnfocusPhoto(); if (vm.IsPhotoFocused()) vm.UnfocusPhoto();
@@ -45,28 +73,98 @@ public partial class MainWindow : Window
// TODO: move basic selection // TODO: move basic selection
} }
int CalculateCurrentIndexHop()
{
// Values of UniformGridLayout
var minItemWidth = 220;
var minColumnSpacing = 18;
var repeaterWidth = Repeater.Bounds.Width;
var abstractedColumnCount = Math.Floor(repeaterWidth / (minItemWidth + minColumnSpacing));
return Math.Max(1, (int)abstractedColumnCount);
}
void HandleArrowKeyDown()
{
var nextIdx = CalculateCurrentIndexHop();
if (_currentImageIndex + nextIdx >= vm.Photos.Count) _currentImageIndex = vm.Photos.Count - 1;
else _currentImageIndex += nextIdx;
}
void HandleArrowKeyUp()
{
var nextIdx = CalculateCurrentIndexHop();
if (_currentImageIndex - nextIdx < 0) _currentImageIndex = 0;
else _currentImageIndex -= nextIdx;
}
Control? FindCardControlForCurrentIndex()
{
if (_currentImageIndex < 0 || _currentImageIndex >= vm.Photos.Count)
return null;
var currentPhoto = vm.Photos[_currentImageIndex];
return Repeater.GetVisualDescendants()
.OfType<Control>()
.FirstOrDefault(c =>
ReferenceEquals(c.DataContext, currentPhoto) &&
CardHoverEffectBehavior.GetIsEnabled(c));
}
void UpdateKeyboardHover()
{
if (DataContext is not MainWindowViewModel vm)
return;
if (_currentHoveredCard is not null)
{
CardHoverEffectBehavior.ResetHover(_currentHoveredCard);
_currentHoveredCard = null;
}
var newCard = FindCardControlForCurrentIndex();
if (newCard is null) return;
CardHoverEffectBehavior.ApplyHover(newCard);
_currentHoveredCard = newCard;
_currentHoveredCard.BringIntoView();
}
switch (e.Key) switch (e.Key)
{ {
case Key.Enter: case Key.Enter: case Key.Space:
HandleEnter().GetAwaiter(); if (!vm.PhotosLoaded) await vm.OpenDirectory();
else await HandleEnter();
break; break;
case Key.Escape: case Key.Escape:
HandleEscape(); HandleEscape();
break; break;
case Key.Down: case Key.Down:
HandleArrowKeyDown();
UpdateKeyboardHover();
await HandleArrowKey();
break;
case Key.Up:
HandleArrowKeyUp();
UpdateKeyboardHover();
await HandleArrowKey();
break; break;
case Key.Left: case Key.Left:
if (_currentImageIndex > 0) if (_currentImageIndex > 0)
{ {
_currentImageIndex--; _currentImageIndex--;
HandleArrowKey().GetAwaiter(); UpdateKeyboardHover();
await HandleArrowKey();
} }
break; break;
case Key.Right: case Key.Right:
if (_currentImageIndex < vm.Photos.Count - 1) if (_currentImageIndex < vm.Photos.Count - 1)
{ {
_currentImageIndex++; _currentImageIndex++;
HandleArrowKey().GetAwaiter(); UpdateKeyboardHover();
await HandleArrowKey();
} }
break; break;
} }
@@ -74,9 +172,34 @@ public partial class MainWindow : Window
private void PointerEnteredPhotoHandler(object sender, PointerEventArgs e) private void PointerEnteredPhotoHandler(object sender, PointerEventArgs e)
{ {
if (sender is not Image i || DataContext is not MainWindowViewModel vm) return; if (sender is not Image image || DataContext is not MainWindowViewModel vm) return;
var idx = vm.GetImageIndex(i);
if (idx == -1) return; var photo = image.DataContext as Photo;
if (photo is null) return;
var idx = vm.Photos.IndexOf(photo);
if (idx < 0) return;
_navigationInputMode = NavigationInputMode.Mouse;
UpdatePointerHoverSuppression();
_currentImageIndex = idx; _currentImageIndex = idx;
if (_currentHoveredCard is null) return;
CardHoverEffectBehavior.ResetHover(_currentHoveredCard);
_currentHoveredCard = null;
}
private void UpdatePointerHoverSuppression()
{
foreach (var control in Repeater.GetVisualDescendants().OfType<Control>())
{
if (CardHoverEffectBehavior.GetIsEnabled(control))
{
CardHoverEffectBehavior.SetSuppressPointerHover(
control,
_navigationInputMode == NavigationInputMode.Keyboard);
}
}
} }
} }