mirror of
https://github.com/4sval/FModel.git
synced 2026-03-29 05:05:33 -05:00
[P2-007] Migrate Magnifier adorner system to Avalonia (#74)
* feat(P2-007): migrate Magnifier adorner system to Avalonia (#20) Magnifier.cs: - System.Windows.Controls.Control → Avalonia TemplatedControl - DependencyProperty → StyledProperty<T> with AddClassHandler callbacks - Target property type UIElement → Control (Avalonia uses Control as the visual-tree base) - ZoomFactor / ZoomFactorOnMouseWheel: validation callback replaced with validate: parameter on Register (Avalonia 11 API) - OverrideMetadata for Width/Height → OverrideDefaultValue<T> - SizeChanged event → BoundsProperty.Changed class handler - IsInitialized guard → Bounds.Width/Height > 0 guard - Remove PART_VisualBrush TemplateBinding lookup: Magnifier now renders itself via Render(DrawingContext) instead of relying on a ControlTemplate in Resources.xaml (which is not yet migrated). Circle/Rectangle shapes are drawn directly via DrawingContext.DrawEllipse / DrawRectangle using a VisualBrush whose SourceControl points at the Target; SourceRect carries the viewport (equivalent to WPF VisualBrush.Viewbox / Absolute ViewboxUnits). Brush is rebuilt in RebuildBrush whenever Target changes. - Avalonia.Data using removed (unused after DependencyProperty removal) MagnifierAdorner.cs: - WPF Adorner subclass replaced by Canvas-based adorner control; the canvas is placed in the AdornerLayer via AdornerLayer.SetAdornment (called by MagnifierManager). - AddVisualChild(_magnifier) / ArrangeOverride replaced by Canvas.Children plus Canvas.SetLeft/SetTop for positioning. - InputManager.Current.PostProcessInput replaced by PointerMoved on the adorned element; position is obtained via e.GetPosition(this) relative to the adorner canvas. - VisualTreeHelper.GetOffset → Control.TranslatePoint to get the Target's offset within the adorned element's coordinate space. - Detach() method added to allow clean unsubscription on removal. MagnifierManager.cs: - DependencyObject with RegisterAttached → plain class with AvaloniaProperty.RegisterAttached; change handler wired via MagnifierProperty.Changed.Subscribe. - MouseLeftButtonDown/Up → PointerPressed/Released events. - MouseWheel → PointerWheelChanged; WPF Delta sign convention: Delta < 0 = scroll down = zoom out; Avalonia Delta.Y < 0 = scroll down — same directional mapping applied. - AdornerLayer.GetAdornerLayer + layer.Add → AdornerLayer.GetAdornerLayer + AdornerLayer.SetAdornment (Avalonia 11 API). - Visibility.Visible/Collapsed → IsVisible bool. ImagePopout.xaml: - AdonisWindow + WPF namespace → Avalonia Window. - MagnifierManager.Magnifier inline object-element syntax removed; Magnifier is now attached in code-behind (XAML object-valued attached property on a different element is not supported in Avalonia). - DockPanel given x:Name="RootPanel" for code-behind access. - UseLayoutRounding removed (Avalonia default). ImagePopout.xaml.cs: - Added : Window base class and using Avalonia.Markup.Xaml. - Magnifier attached in OnAttachedToVisualTree to avoid running before the adorner layer is available. Closes #20 * Add untracked file * fix(magnifier): address PR #74 review findings C1/C2 (Magnifier.cs) - BoundsProperty.Changed.AddClassHandler moved from instance ctor to static ctor; now also calls InvalidateVisual() (C1: was registering N handlers for N instances) - TargetProperty.Changed.AddClassHandler added to static ctor so RebuildBrush fires whenever Target is reassigned (C2 / PR-4) - Remove unused 'using Avalonia.Data' (m1 / PR-5) C3/M2 (MagnifierAdorner.cs) - Track _currentElementPosition (element-relative) alongside _currentPointerPosition (adorner-relative); both captured from the same PointerEventArgs so no PointToScreen/PointToClient round-trip is needed (C3) - CalculateViewBoxLocation now uses _currentElementPosition; the old PointToClient(PointToScreen(...)) call is removed - Subscribe to adornedElement.PointerPressed alongside PointerMoved; both route through a shared HandlePointerEvent() so the magnifier is pre-positioned before the first render frame (M2 / PR-3) - Detach() now unsubscribes both PointerPressed and PointerMoved (PR-2) - Remove unused 'using Avalonia.VisualTree' (PR-6) M1/PR-1/PR-2 (MagnifierManager.cs) - e.NewValue.GetValueOrDefault() replaces unsafe e.NewValue.Value access (PR-1) - ConditionalWeakTable<Control, MagnifierManager> lets OnMagnifierChanged detach the old manager when the attached property is changed or cleared - AttachToMagnifier subscribes element.DetachedFromVisualTree so the adorner is detached when the host element leaves the tree (M1 / PR-2) - New Detach() instance method: unsubscribes all element events, calls adorner.Detach(), hides and nulls the adorner ImagePopout.xaml.cs - Remove unused 'using Avalonia.Markup.Xaml' (PR-7) - Use compiler-generated RootPanel field directly instead of this.FindControl<DockPanel>("RootPanel") (m2) * fix(magnifier): address review findings from PR #74 C1: Stretch.Fill in RebuildBrush — restores 1/ZoomFactor magnification ratio; Stretch.None was painting source at 1:1 with no zoom effect. C2: Wire SetMagnifier in ImagePopout constructor, not OnAttachedToVisualTree. Window is the visual root so AttachedToVisualTree never fires on it; constructor wiring matches WPF parse-time property assignment timing. M1: Filter PointerPressed/Released to left button only via PointerUpdateKind, matching WPF MouseLeftButtonDown/MouseLeftButtonUp routing event semantics. M2: Remove dead OnApplyTemplate override; no ControlTheme is registered for Magnifier so the override could never be called. S1: Change base class from TemplatedControl to Control; declare Background, BorderBrush and BorderThickness as local StyledProperty fields. Makes the template-less rendering path explicit at the type-system level. Min1: Subscribe PointerMoved dynamically via OnPropertyChanged(IsVisibleProperty) so the handler is only live while the magnifier is visible, matching the WPF Loaded/Unloaded-gated InputManager.PostProcessInput subscription. Min2: Guard BoundsProperty.Changed with an oldBounds.Size == newBounds.Size short-circuit to skip redundant UpdateViewBox calls on position-only changes (e.g. PositionMagnifier calling Canvas.SetLeft/SetTop). Closes review findings from PR #74 / issue #20. * fix(magnifier): address two unresolved PR review comments MagnifierManager.OnElementDetached: call full Detach() + _managers.Remove() instead of only _adorner.Detach(). The partial cleanup left PointerPressed, PointerReleased, PointerWheelChanged and DetachedFromVisualTree subscriptions live on the element, causing stale handlers if the control re-entered the visual tree. Full teardown ensures a clean slate for any future re-attachment. Magnifier.UpdateViewBox: fall back to Width/Height styled properties (default 100px) when Bounds are still zero (pre-first-layout). This ensures ViewBox.Size is non-zero on the very first PointerPressed, so CalculateViewBoxLocation() correctly centers the viewport under the cursor before the initial measure pass has completed. * fix(magnifier): address final review findings Min1: Remove adorner from AdornerLayer on Detach() via SetAdornment(element, null) so the control is not left as an invisible orphan child in the layer for the lifetime of the host window. HideAdorner() call removed as redundant — layer removal is a stronger cleanup than a visibility toggle. S1: Remove redundant InvalidateVisual() call in BoundsProperty.Changed handler. UpdateViewBox() already calls InvalidateVisual() at its end; the direct call in the handler was scheduling the same dirty-flag set twice per size change. * fix(magnifier): address four unresolved PR review comments MagnifierAdorner ctor: set IsVisible=false so the first ShowAdorner() call triggers a false→true transition on IsVisibleProperty, firing OnPropertyChanged and subscribing PointerMoved. Previously, IsVisible defaulted to true, so the first ShowAdorner() was a no-op and PointerMoved was never subscribed until after the first hide/show cycle. MagnifierManager.Detach(): replace 'AdornerLayer.GetAdornerLayer + layer?.SetAdornment' with a direct 'AdornerLayer.SetAdornment(_element, null)' static call. In Avalonia 11 SetAdornment is static; calling it via an instance reference made the null-conditional '?.' meaningless and was inconsistent with VerifyAdornerLayer which correctly uses the static API. Add/remove are now symmetrical. ImagePopout.xaml: remove unused xmlns:controls namespace alias. The magnifier wiring was moved to code-behind; no XAML element in this file references the alias, eliminating a linter warning.
This commit is contained in:
parent
2081c8af7d
commit
c33d5f58f2
|
|
@ -36,7 +36,8 @@ namespace FModel.Settings
|
|||
private static bool _bSave = true;
|
||||
public static void Save()
|
||||
{
|
||||
if (!_bSave || Default == null) return;
|
||||
if (!_bSave || Default == null)
|
||||
return;
|
||||
Default.PerDirectory[Default.CurrentDir.GameDirectory] = Default.CurrentDir;
|
||||
File.WriteAllText(FilePath, JsonConvert.SerializeObject(Default, Formatting.Indented));
|
||||
}
|
||||
|
|
@ -147,7 +148,7 @@ namespace FModel.Settings
|
|||
set => SetProperty(ref _isLoggerExpanded, value);
|
||||
}
|
||||
|
||||
private GridLength _avalonImageSize = new (200);
|
||||
private GridLength _avalonImageSize = new(200);
|
||||
public GridLength AvalonImageSize
|
||||
{
|
||||
get => _avalonImageSize;
|
||||
|
|
@ -300,7 +301,7 @@ namespace FModel.Settings
|
|||
set => SetProperty(ref _manualGames, value);
|
||||
}
|
||||
|
||||
private AuthResponse _lastAuthResponse = new() {AccessToken = "", ExpiresAt = DateTime.Now};
|
||||
private AuthResponse _lastAuthResponse = new() { AccessToken = "", ExpiresAt = DateTime.Now };
|
||||
public AuthResponse LastAuthResponse
|
||||
{
|
||||
get => _lastAuthResponse;
|
||||
|
|
|
|||
|
|
@ -1,16 +1,14 @@
|
|||
<adonisControls:AdonisWindow x:Class="FModel.Views.Resources.Controls.ImagePopout"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:controls="clr-namespace:FModel.Views.Resources.Controls"
|
||||
xmlns:adonisControls="clr-namespace:AdonisUI.Controls;assembly=AdonisUI"
|
||||
WindowStartupLocation="CenterScreen" IconVisibility="Collapsed">
|
||||
<DockPanel HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<controls:MagnifierManager.Magnifier>
|
||||
<controls:Magnifier Radius="150" ZoomFactor=".7" />
|
||||
</controls:MagnifierManager.Magnifier>
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="FModel.Views.Resources.Controls.ImagePopout"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Icon="{x:Null}">
|
||||
<DockPanel x:Name="RootPanel"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
|
||||
<Border BorderBrush="#3b3d4a" BorderThickness="1" HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<Image x:Name="ImageCtrl" UseLayoutRounding="True" />
|
||||
<Border BorderBrush="#3b3d4a" BorderThickness="1"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<Image x:Name="ImageCtrl" />
|
||||
</Border>
|
||||
</DockPanel>
|
||||
</adonisControls:AdonisWindow>
|
||||
</Window>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
using Avalonia.Controls;
|
||||
|
||||
namespace FModel.Views.Resources.Controls;
|
||||
|
||||
public partial class ImagePopout
|
||||
public partial class ImagePopout : Window
|
||||
{
|
||||
public ImagePopout()
|
||||
{
|
||||
InitializeComponent();
|
||||
MagnifierManager.SetMagnifier(RootPanel, new Magnifier { Radius = 150, ZoomFactor = 0.7 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,176 +1,230 @@
|
|||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace FModel.Views.Resources.Controls;
|
||||
|
||||
[TemplatePart(Name = PART_VisualBrush, Type = typeof(VisualBrush))]
|
||||
public class Magnifier : Control
|
||||
{
|
||||
private const double DEFAULT_SIZE = 100d;
|
||||
private const string PART_VisualBrush = "PART_VisualBrush";
|
||||
private VisualBrush _visualBrush = new();
|
||||
|
||||
public static readonly DependencyProperty FrameTypeProperty =
|
||||
DependencyProperty.Register("FrameType", typeof(EFrameType), typeof(Magnifier), new UIPropertyMetadata(EFrameType.Circle, OnFrameTypeChanged));
|
||||
// -----------------------------------------------------------------------
|
||||
// Styled properties
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public static readonly StyledProperty<EFrameType> FrameTypeProperty =
|
||||
AvaloniaProperty.Register<Magnifier, EFrameType>(nameof(FrameType), defaultValue: EFrameType.Circle);
|
||||
public EFrameType FrameType
|
||||
{
|
||||
get => (EFrameType) GetValue(FrameTypeProperty);
|
||||
get => GetValue(FrameTypeProperty);
|
||||
set => SetValue(FrameTypeProperty, value);
|
||||
}
|
||||
|
||||
private static void OnFrameTypeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
var m = (Magnifier) d;
|
||||
m.OnFrameTypeChanged((EFrameType) e.OldValue, (EFrameType) e.NewValue);
|
||||
}
|
||||
|
||||
protected virtual void OnFrameTypeChanged(EFrameType oldValue, EFrameType newValue)
|
||||
{
|
||||
UpdateSizeFromRadius();
|
||||
}
|
||||
|
||||
public static readonly DependencyProperty IsUsingZoomOnMouseWheelProperty =
|
||||
DependencyProperty.Register("IsUsingZoomOnMouseWheel", typeof(bool), typeof(Magnifier), new UIPropertyMetadata(true));
|
||||
public static readonly StyledProperty<bool> IsUsingZoomOnMouseWheelProperty =
|
||||
AvaloniaProperty.Register<Magnifier, bool>(nameof(IsUsingZoomOnMouseWheel), defaultValue: true);
|
||||
public bool IsUsingZoomOnMouseWheel
|
||||
{
|
||||
get => (bool) GetValue(IsUsingZoomOnMouseWheelProperty);
|
||||
get => GetValue(IsUsingZoomOnMouseWheelProperty);
|
||||
set => SetValue(IsUsingZoomOnMouseWheelProperty, value);
|
||||
}
|
||||
|
||||
public bool IsFrozen { get; private set; }
|
||||
|
||||
public static readonly DependencyProperty RadiusProperty =
|
||||
DependencyProperty.Register("Radius", typeof(double), typeof(Magnifier), new FrameworkPropertyMetadata(DEFAULT_SIZE / 2, OnRadiusPropertyChanged));
|
||||
public static readonly StyledProperty<double> RadiusProperty =
|
||||
AvaloniaProperty.Register<Magnifier, double>(nameof(Radius), defaultValue: DEFAULT_SIZE / 2);
|
||||
public double Radius
|
||||
{
|
||||
get => (double) GetValue(RadiusProperty);
|
||||
get => GetValue(RadiusProperty);
|
||||
set => SetValue(RadiusProperty, value);
|
||||
}
|
||||
|
||||
private static void OnRadiusPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
public static readonly StyledProperty<Control?> TargetProperty =
|
||||
AvaloniaProperty.Register<Magnifier, Control?>(nameof(Target));
|
||||
public Control? Target
|
||||
{
|
||||
var m = (Magnifier) d;
|
||||
m.OnRadiusChanged(e);
|
||||
}
|
||||
|
||||
protected virtual void OnRadiusChanged(DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
UpdateSizeFromRadius();
|
||||
}
|
||||
|
||||
public static readonly DependencyProperty TargetProperty = DependencyProperty.Register("Target", typeof(UIElement), typeof(Magnifier));
|
||||
public UIElement Target
|
||||
{
|
||||
get => (UIElement) GetValue(TargetProperty);
|
||||
get => GetValue(TargetProperty);
|
||||
set => SetValue(TargetProperty, value);
|
||||
}
|
||||
|
||||
public Rect ViewBox
|
||||
{
|
||||
get => _visualBrush.Viewbox;
|
||||
set => _visualBrush.Viewbox = value;
|
||||
}
|
||||
|
||||
public static readonly DependencyProperty ZoomFactorProperty =
|
||||
DependencyProperty.Register("ZoomFactor", typeof(double), typeof(Magnifier), new FrameworkPropertyMetadata(0.5, OnZoomFactorPropertyChanged), OnValidationCallback);
|
||||
public static readonly StyledProperty<double> ZoomFactorProperty =
|
||||
AvaloniaProperty.Register<Magnifier, double>(nameof(ZoomFactor), defaultValue: 0.5,
|
||||
validate: v => v >= 0);
|
||||
public double ZoomFactor
|
||||
{
|
||||
get => (double) GetValue(ZoomFactorProperty);
|
||||
get => GetValue(ZoomFactorProperty);
|
||||
set => SetValue(ZoomFactorProperty, value);
|
||||
}
|
||||
|
||||
private static bool OnValidationCallback(object baseValue)
|
||||
{
|
||||
return (double) baseValue >= 0;
|
||||
}
|
||||
|
||||
private static void OnZoomFactorPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
var m = (Magnifier) d;
|
||||
m.OnZoomFactorChanged(e);
|
||||
}
|
||||
|
||||
protected virtual void OnZoomFactorChanged(DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
UpdateViewBox();
|
||||
}
|
||||
|
||||
public static readonly DependencyProperty ZoomFactorOnMouseWheelProperty =
|
||||
DependencyProperty.Register("ZoomFactorOnMouseWheel", typeof(double), typeof(Magnifier), new FrameworkPropertyMetadata(0.1d, OnZoomFactorOnMouseWheelPropertyChanged), OnZoomFactorOnMouseWheelValidationCallback);
|
||||
public static readonly StyledProperty<double> ZoomFactorOnMouseWheelProperty =
|
||||
AvaloniaProperty.Register<Magnifier, double>(nameof(ZoomFactorOnMouseWheel), defaultValue: 0.1d,
|
||||
validate: v => v >= 0);
|
||||
public double ZoomFactorOnMouseWheel
|
||||
{
|
||||
get => (double) GetValue(ZoomFactorOnMouseWheelProperty);
|
||||
get => GetValue(ZoomFactorOnMouseWheelProperty);
|
||||
set => SetValue(ZoomFactorOnMouseWheelProperty, value);
|
||||
}
|
||||
|
||||
private static bool OnZoomFactorOnMouseWheelValidationCallback(object baseValue)
|
||||
public static readonly StyledProperty<IBrush?> BackgroundProperty =
|
||||
AvaloniaProperty.Register<Magnifier, IBrush?>(nameof(Background));
|
||||
public IBrush? Background
|
||||
{
|
||||
return (double) baseValue >= 0;
|
||||
get => GetValue(BackgroundProperty);
|
||||
set => SetValue(BackgroundProperty, value);
|
||||
}
|
||||
|
||||
private static void OnZoomFactorOnMouseWheelPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
public static readonly StyledProperty<IBrush?> BorderBrushProperty =
|
||||
AvaloniaProperty.Register<Magnifier, IBrush?>(nameof(BorderBrush));
|
||||
public IBrush? BorderBrush
|
||||
{
|
||||
var m = (Magnifier) d;
|
||||
m.OnZoomFactorOnMouseWheelChanged(e);
|
||||
get => GetValue(BorderBrushProperty);
|
||||
set => SetValue(BorderBrushProperty, value);
|
||||
}
|
||||
|
||||
protected virtual void OnZoomFactorOnMouseWheelChanged(DependencyPropertyChangedEventArgs e)
|
||||
public static readonly StyledProperty<Thickness> BorderThicknessProperty =
|
||||
AvaloniaProperty.Register<Magnifier, Thickness>(nameof(BorderThickness));
|
||||
public Thickness BorderThickness
|
||||
{
|
||||
get => GetValue(BorderThicknessProperty);
|
||||
set => SetValue(BorderThicknessProperty, value);
|
||||
}
|
||||
|
||||
// ViewBox is not a styled property — it is driven programmatically by the adorner.
|
||||
public Rect ViewBox { get; set; }
|
||||
|
||||
public bool IsFrozen { get; private set; }
|
||||
|
||||
// VisualBrush paints the magnified region; rebuilt whenever Target changes.
|
||||
private VisualBrush? _visualBrush;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Static ctor — wire property-changed callbacks
|
||||
// -----------------------------------------------------------------------
|
||||
static Magnifier()
|
||||
{
|
||||
DefaultStyleKeyProperty.OverrideMetadata(typeof(Magnifier), new FrameworkPropertyMetadata(typeof(Magnifier)));
|
||||
HeightProperty.OverrideMetadata(typeof(Magnifier), new FrameworkPropertyMetadata(DEFAULT_SIZE));
|
||||
WidthProperty.OverrideMetadata(typeof(Magnifier), new FrameworkPropertyMetadata(DEFAULT_SIZE));
|
||||
}
|
||||
FrameTypeProperty.Changed.AddClassHandler<Magnifier>((m, _) => m.OnFrameTypeChanged());
|
||||
RadiusProperty.Changed.AddClassHandler<Magnifier>((m, _) => m.OnRadiusChanged());
|
||||
ZoomFactorProperty.Changed.AddClassHandler<Magnifier>((m, _) => m.UpdateViewBox());
|
||||
// Rebuild the VisualBrush whenever the Target control changes.
|
||||
TargetProperty.Changed.AddClassHandler<Magnifier>((m, _) => m.RebuildBrush());
|
||||
|
||||
public Magnifier()
|
||||
{
|
||||
SizeChanged += OnSizeChangedEvent;
|
||||
}
|
||||
// Default Width / Height
|
||||
WidthProperty.OverrideDefaultValue<Magnifier>(DEFAULT_SIZE);
|
||||
HeightProperty.OverrideDefaultValue<Magnifier>(DEFAULT_SIZE);
|
||||
|
||||
private void OnSizeChangedEvent(object sender, SizeChangedEventArgs e)
|
||||
{
|
||||
UpdateViewBox();
|
||||
}
|
||||
|
||||
private void UpdateSizeFromRadius()
|
||||
{
|
||||
if (FrameType != EFrameType.Circle) return;
|
||||
|
||||
var newSize = Radius * 2;
|
||||
if (!Helper.AreVirtuallyEqual(Width, newSize))
|
||||
// Class-level handler — must live in static ctor, not the instance ctor, to avoid
|
||||
// registering N handlers for N instances (AddClassHandler is class-wide).
|
||||
BoundsProperty.Changed.AddClassHandler<Magnifier>((m, e) =>
|
||||
{
|
||||
Width = newSize;
|
||||
if (e.OldValue is Rect oldBounds && e.NewValue is Rect newBounds && oldBounds.Size == newBounds.Size)
|
||||
return;
|
||||
m.UpdateViewBox(); // UpdateViewBox already calls InvalidateVisual().
|
||||
});
|
||||
}
|
||||
|
||||
public Magnifier() { }
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Overrides
|
||||
// -----------------------------------------------------------------------
|
||||
public override void Render(DrawingContext context)
|
||||
{
|
||||
if (_visualBrush == null || Bounds.Width <= 0 || Bounds.Height <= 0)
|
||||
return;
|
||||
|
||||
var strokeThickness = (BorderThickness.Left + BorderThickness.Top +
|
||||
BorderThickness.Right + BorderThickness.Bottom) / 4;
|
||||
var pen = BorderBrush != null ? new Pen(BorderBrush, strokeThickness) : null;
|
||||
|
||||
if (FrameType == EFrameType.Circle)
|
||||
{
|
||||
var center = new Point(Bounds.Width / 2, Bounds.Height / 2);
|
||||
var rx = Bounds.Width / 2;
|
||||
var ry = Bounds.Height / 2;
|
||||
if (Background != null)
|
||||
context.DrawEllipse(Background, null, center, rx, ry);
|
||||
context.DrawEllipse(_visualBrush, pen, center, rx, ry);
|
||||
}
|
||||
|
||||
if (!Helper.AreVirtuallyEqual(Height, newSize))
|
||||
else
|
||||
{
|
||||
Height = newSize;
|
||||
var rect = new Rect(0, 0, Bounds.Width, Bounds.Height);
|
||||
if (Background != null)
|
||||
context.DrawRectangle(Background, null, rect);
|
||||
context.DrawRectangle(_visualBrush, pen, rect);
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnApplyTemplate()
|
||||
{
|
||||
base.OnApplyTemplate();
|
||||
|
||||
var newBrush = GetTemplateChild(PART_VisualBrush) as VisualBrush ?? new VisualBrush();
|
||||
newBrush.Viewbox = _visualBrush.Viewbox;
|
||||
_visualBrush = newBrush;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------------
|
||||
public void Freeze(bool freeze)
|
||||
{
|
||||
IsFrozen = freeze;
|
||||
}
|
||||
|
||||
private void UpdateViewBox()
|
||||
private void OnFrameTypeChanged()
|
||||
{
|
||||
if (!IsInitialized)
|
||||
UpdateSizeFromRadius();
|
||||
InvalidateVisual();
|
||||
}
|
||||
|
||||
private void OnRadiusChanged()
|
||||
{
|
||||
UpdateSizeFromRadius();
|
||||
}
|
||||
|
||||
private void UpdateSizeFromRadius()
|
||||
{
|
||||
if (FrameType != EFrameType.Circle)
|
||||
return;
|
||||
|
||||
ViewBox = new Rect(ViewBox.Location, new Size(ActualWidth * ZoomFactor, ActualHeight * ZoomFactor));
|
||||
var newSize = Radius * 2;
|
||||
if (!Helper.AreVirtuallyEqual(Width, newSize))
|
||||
Width = newSize;
|
||||
if (!Helper.AreVirtuallyEqual(Height, newSize))
|
||||
Height = newSize;
|
||||
}
|
||||
}
|
||||
|
||||
internal void UpdateViewBox()
|
||||
{
|
||||
// Prefer actual layout size; fall back to the Width/Height styled properties
|
||||
// (default 100) so that ViewBox.Size is non-zero even before the first layout
|
||||
// pass. This ensures CalculateViewBoxLocation() can center the viewport under
|
||||
// the cursor on the very first PointerPressed before Bounds are measured.
|
||||
var w = Bounds.Width > 0 ? Bounds.Width : Width;
|
||||
var h = Bounds.Height > 0 ? Bounds.Height : Height;
|
||||
if (w <= 0 || h <= 0)
|
||||
return;
|
||||
|
||||
ViewBox = new Rect(ViewBox.X, ViewBox.Y, w * ZoomFactor, h * ZoomFactor);
|
||||
UpdateBrushViewBox();
|
||||
InvalidateVisual();
|
||||
}
|
||||
|
||||
private void RebuildBrush()
|
||||
{
|
||||
if (Target == null)
|
||||
{
|
||||
_visualBrush = null;
|
||||
InvalidateVisual();
|
||||
return;
|
||||
}
|
||||
|
||||
_visualBrush = new VisualBrush
|
||||
{
|
||||
SourceControl = Target,
|
||||
Stretch = Stretch.Fill,
|
||||
TileMode = TileMode.None,
|
||||
AlignmentX = AlignmentX.Left,
|
||||
AlignmentY = AlignmentY.Top,
|
||||
DestinationRect = new RelativeRect(0, 0, 1, 1, RelativeUnit.Relative),
|
||||
};
|
||||
UpdateBrushViewBox();
|
||||
InvalidateVisual();
|
||||
}
|
||||
|
||||
private void UpdateBrushViewBox()
|
||||
{
|
||||
if (_visualBrush == null)
|
||||
return;
|
||||
// SourceRect maps which region of Target is painted — the magnified "window".
|
||||
_visualBrush.SourceRect = new RelativeRect(ViewBox, RelativeUnit.Absolute);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,85 +1,121 @@
|
|||
using System.Windows;
|
||||
using System.Windows.Documents;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
|
||||
namespace FModel.Views.Resources.Controls;
|
||||
|
||||
public class MagnifierAdorner : Adorner
|
||||
/// <summary>
|
||||
/// Avalonia replacement for WPF MagnifierAdorner.
|
||||
/// Placed in the AdornerLayer as a Canvas that positions the <see cref="Magnifier"/> at the cursor.
|
||||
/// </summary>
|
||||
public class MagnifierAdorner : Canvas
|
||||
{
|
||||
private Magnifier _magnifier;
|
||||
private Point _currentMousePosition;
|
||||
private readonly Control _adornedElement;
|
||||
private readonly Magnifier _magnifier;
|
||||
private Point _currentPointerPosition;
|
||||
private Point _currentElementPosition;
|
||||
private double _currentZoomFactor;
|
||||
|
||||
public MagnifierAdorner(UIElement element, Magnifier magnifier) : base(element)
|
||||
public MagnifierAdorner(Control adornedElement, Magnifier magnifier)
|
||||
{
|
||||
_adornedElement = adornedElement;
|
||||
_magnifier = magnifier;
|
||||
_currentZoomFactor = _magnifier.ZoomFactor;
|
||||
_currentZoomFactor = magnifier.ZoomFactor;
|
||||
|
||||
// The canvas must be transparent to hit-testing so pointer events reach the adorned control.
|
||||
IsHitTestVisible = false;
|
||||
// Start hidden so the first ShowAdorner() call triggers IsVisible false→true,
|
||||
// which fires OnPropertyChanged and subscribes PointerMoved correctly.
|
||||
IsVisible = false;
|
||||
|
||||
Children.Add(_magnifier);
|
||||
UpdateViewBox();
|
||||
AddVisualChild(_magnifier);
|
||||
|
||||
Loaded += (_, _) => InputManager.Current.PostProcessInput += OnProcessInput;
|
||||
Unloaded += (_, _) => InputManager.Current.PostProcessInput -= OnProcessInput;
|
||||
// Subscribe to pointer events on the adorned element.
|
||||
// PointerPressed fires first (manager handles ShowAdorner before this handler runs,
|
||||
// so the adorner is already in the visual tree when we call e.GetPosition(this)).
|
||||
// PointerMoved is subscribed/unsubscribed dynamically via OnPropertyChanged(IsVisible).
|
||||
_adornedElement.PointerPressed += OnAdornedElementPointerPressed;
|
||||
}
|
||||
|
||||
private void OnProcessInput(object sender, ProcessInputEventArgs e)
|
||||
public void Detach()
|
||||
{
|
||||
var pt = Mouse.GetPosition(this);
|
||||
if (_currentMousePosition == pt && _magnifier.ZoomFactor == _currentZoomFactor)
|
||||
_adornedElement.PointerPressed -= OnAdornedElementPointerPressed;
|
||||
_adornedElement.PointerMoved -= OnAdornedElementPointerMoved;
|
||||
}
|
||||
|
||||
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||||
{
|
||||
base.OnPropertyChanged(change);
|
||||
if (change.Property == IsVisibleProperty)
|
||||
{
|
||||
if (change.GetNewValue<bool>())
|
||||
_adornedElement.PointerMoved += OnAdornedElementPointerMoved;
|
||||
else
|
||||
_adornedElement.PointerMoved -= OnAdornedElementPointerMoved;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAdornedElementPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
=> HandlePointerEvent(e);
|
||||
|
||||
private void OnAdornedElementPointerMoved(object? sender, PointerEventArgs e)
|
||||
=> HandlePointerEvent(e);
|
||||
|
||||
private void HandlePointerEvent(PointerEventArgs e)
|
||||
{
|
||||
// Adorner-canvas-relative position (used for magnifier placement on the canvas).
|
||||
var pt = e.GetPosition(this);
|
||||
|
||||
if (_currentPointerPosition == pt && _magnifier.ZoomFactor == _currentZoomFactor)
|
||||
return;
|
||||
|
||||
if (_magnifier.IsFrozen)
|
||||
return;
|
||||
|
||||
_currentMousePosition = pt;
|
||||
_currentPointerPosition = pt;
|
||||
// Element-relative position (used for viewbox origin calculation — avoids PointToScreen round-trip).
|
||||
_currentElementPosition = e.GetPosition(_adornedElement);
|
||||
_currentZoomFactor = _magnifier.ZoomFactor;
|
||||
|
||||
UpdateViewBox();
|
||||
InvalidateArrange();
|
||||
PositionMagnifier();
|
||||
}
|
||||
|
||||
public void UpdateViewBox()
|
||||
{
|
||||
var viewBoxLocation = CalculateViewBoxLocation();
|
||||
_magnifier.ViewBox = new Rect(viewBoxLocation, _magnifier.ViewBox.Size);
|
||||
var location = CalculateViewBoxLocation();
|
||||
_magnifier.ViewBox = new Rect(location, _magnifier.ViewBox.Size);
|
||||
_magnifier.UpdateViewBox();
|
||||
}
|
||||
|
||||
private Point CalculateViewBoxLocation()
|
||||
{
|
||||
double offsetX, offsetY;
|
||||
var adorner = Mouse.GetPosition(this);
|
||||
var element = Mouse.GetPosition(AdornedElement);
|
||||
// offsetX/offsetY = coordinate delta between adorner-canvas space and adorned-element space.
|
||||
// Both positions come from the same PointerEventArgs so they share the same root transform,
|
||||
// making this DPI-safe without any PointToScreen / PointToClient round-trip.
|
||||
var offsetX = _currentElementPosition.X - _currentPointerPosition.X;
|
||||
var offsetY = _currentElementPosition.Y - _currentPointerPosition.Y;
|
||||
|
||||
offsetX = element.X - adorner.X;
|
||||
offsetY = element.Y - adorner.Y;
|
||||
// Account for the target control's offset within its parent coordinate space.
|
||||
Point parentOffset = default;
|
||||
if (_magnifier.Target != null)
|
||||
{
|
||||
var offsetVec = _magnifier.Target.TranslatePoint(default, _adornedElement);
|
||||
if (offsetVec.HasValue)
|
||||
parentOffset = offsetVec.Value;
|
||||
}
|
||||
|
||||
var parentOffsetVector = VisualTreeHelper.GetOffset(_magnifier.Target);
|
||||
var parentOffset = new Point(parentOffsetVector.X, parentOffsetVector.Y);
|
||||
|
||||
var left = _currentMousePosition.X - (_magnifier.ViewBox.Width / 2 + offsetX) + parentOffset.X;
|
||||
var top = _currentMousePosition.Y - (_magnifier.ViewBox.Height / 2 + offsetY) + parentOffset.Y;
|
||||
var left = _currentPointerPosition.X - (_magnifier.ViewBox.Width / 2 + offsetX) + parentOffset.X;
|
||||
var top = _currentPointerPosition.Y - (_magnifier.ViewBox.Height / 2 + offsetY) + parentOffset.Y;
|
||||
return new Point(left, top);
|
||||
}
|
||||
|
||||
protected override Visual GetVisualChild(int index)
|
||||
private void PositionMagnifier()
|
||||
{
|
||||
return _magnifier;
|
||||
var x = _currentPointerPosition.X - _magnifier.Width / 2;
|
||||
var y = _currentPointerPosition.Y - _magnifier.Height / 2;
|
||||
SetLeft(_magnifier, x);
|
||||
SetTop(_magnifier, y);
|
||||
}
|
||||
|
||||
protected override int VisualChildrenCount => 1;
|
||||
|
||||
protected override Size MeasureOverride(Size constraint)
|
||||
{
|
||||
_magnifier.Measure(constraint);
|
||||
return base.MeasureOverride(constraint);
|
||||
}
|
||||
|
||||
protected override Size ArrangeOverride(Size finalSize)
|
||||
{
|
||||
var x = _currentMousePosition.X - _magnifier.Width / 2;
|
||||
var y = _currentMousePosition.Y - _magnifier.Height / 2;
|
||||
_magnifier.Arrange(new Rect(x, y, _magnifier.Width, _magnifier.Height));
|
||||
return base.ArrangeOverride(finalSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,102 +1,167 @@
|
|||
using System;
|
||||
using System.Windows;
|
||||
using System.Windows.Documents;
|
||||
using System.Windows.Input;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
|
||||
namespace FModel.Views.Resources.Controls;
|
||||
|
||||
public class MagnifierManager : DependencyObject
|
||||
public class MagnifierManager
|
||||
{
|
||||
private MagnifierAdorner _adorner;
|
||||
private UIElement _element;
|
||||
private MagnifierAdorner? _adorner;
|
||||
private Control? _element;
|
||||
|
||||
public static readonly DependencyProperty CurrentProperty =
|
||||
DependencyProperty.RegisterAttached("Magnifier", typeof(Magnifier), typeof(UIElement), new FrameworkPropertyMetadata(null, OnMagnifierChanged));
|
||||
// Stores active managers so OnMagnifierChanged can detach the old one when the property changes.
|
||||
private static readonly ConditionalWeakTable<Control, MagnifierManager> _managers = new();
|
||||
|
||||
public static void SetMagnifier(UIElement element, Magnifier value)
|
||||
// -----------------------------------------------------------------------
|
||||
// Attached property — usage: <Image controls:MagnifierManager.Magnifier="..." />
|
||||
// -----------------------------------------------------------------------
|
||||
public static readonly AttachedProperty<Magnifier?> MagnifierProperty =
|
||||
AvaloniaProperty.RegisterAttached<MagnifierManager, Control, Magnifier?>("Magnifier");
|
||||
|
||||
public static void SetMagnifier(Control element, Magnifier? value)
|
||||
=> element.SetValue(MagnifierProperty, value);
|
||||
|
||||
public static Magnifier? GetMagnifier(Control element)
|
||||
=> element.GetValue(MagnifierProperty);
|
||||
|
||||
static MagnifierManager()
|
||||
{
|
||||
element.SetValue(CurrentProperty, value);
|
||||
MagnifierProperty.Changed.Subscribe(OnMagnifierChanged);
|
||||
}
|
||||
|
||||
public static Magnifier GetMagnifier(UIElement element)
|
||||
private static void OnMagnifierChanged(AvaloniaPropertyChangedEventArgs<Magnifier?> e)
|
||||
{
|
||||
return (Magnifier) element.GetValue(CurrentProperty);
|
||||
}
|
||||
if (e.Sender is not Control target)
|
||||
throw new ArgumentException("Magnifier can only be attached to a Control.");
|
||||
|
||||
private static void OnMagnifierChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (d is not UIElement target)
|
||||
throw new ArgumentException("Magnifier can only be attached to a UIElement.");
|
||||
|
||||
new MagnifierManager().AttachToMagnifier(target, e.NewValue as Magnifier);
|
||||
}
|
||||
|
||||
private void ElementOnMouseLeftButtonUp(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (GetMagnifier(_element) is { IsFrozen: true })
|
||||
return;
|
||||
|
||||
HideAdorner();
|
||||
}
|
||||
|
||||
private void ElementOnMouseLeftButtonDown(object sender, MouseEventArgs e)
|
||||
{
|
||||
ShowAdorner();
|
||||
}
|
||||
|
||||
private void ElementOnMouseWheel(object sender, MouseWheelEventArgs e)
|
||||
{
|
||||
if (GetMagnifier(_element) is not { IsUsingZoomOnMouseWheel: true } magnifier) return;
|
||||
|
||||
switch (e.Delta)
|
||||
// Detach any previously registered manager on this element before attaching a new one.
|
||||
if (_managers.TryGetValue(target, out var old))
|
||||
{
|
||||
case < 0:
|
||||
{
|
||||
var newValue = magnifier.ZoomFactor + magnifier.ZoomFactorOnMouseWheel;
|
||||
magnifier.SetCurrentValue(Magnifier.ZoomFactorProperty, newValue);
|
||||
break;
|
||||
}
|
||||
case > 0:
|
||||
{
|
||||
var newValue = magnifier.ZoomFactor >= magnifier.ZoomFactorOnMouseWheel ? magnifier.ZoomFactor - magnifier.ZoomFactorOnMouseWheel : 0d;
|
||||
magnifier.SetCurrentValue(Magnifier.ZoomFactorProperty, newValue);
|
||||
break;
|
||||
}
|
||||
old.Detach();
|
||||
_managers.Remove(target);
|
||||
}
|
||||
|
||||
_adorner.UpdateViewBox();
|
||||
var magnifier = e.NewValue.GetValueOrDefault();
|
||||
if (magnifier != null)
|
||||
{
|
||||
var manager = new MagnifierManager();
|
||||
manager.AttachToMagnifier(target, magnifier);
|
||||
_managers.Add(target, manager);
|
||||
}
|
||||
}
|
||||
|
||||
private void AttachToMagnifier(UIElement element, Magnifier magnifier)
|
||||
// -----------------------------------------------------------------------
|
||||
// Instance — manages one control/magnifier pair
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void AttachToMagnifier(Control element, Magnifier magnifier)
|
||||
{
|
||||
_element = element;
|
||||
_element.MouseLeftButtonDown += ElementOnMouseLeftButtonDown;
|
||||
_element.MouseLeftButtonUp += ElementOnMouseLeftButtonUp;
|
||||
_element.MouseWheel += ElementOnMouseWheel;
|
||||
_element.PointerPressed += ElementOnPointerPressed;
|
||||
_element.PointerReleased += ElementOnPointerReleased;
|
||||
_element.PointerWheelChanged += ElementOnPointerWheelChanged;
|
||||
_element.DetachedFromVisualTree += OnElementDetached;
|
||||
|
||||
magnifier.Target = _element;
|
||||
|
||||
_adorner = new MagnifierAdorner(_element, magnifier);
|
||||
}
|
||||
|
||||
private void Detach()
|
||||
{
|
||||
if (_element != null)
|
||||
{
|
||||
_element.PointerPressed -= ElementOnPointerPressed;
|
||||
_element.PointerReleased -= ElementOnPointerReleased;
|
||||
_element.PointerWheelChanged -= ElementOnPointerWheelChanged;
|
||||
_element.DetachedFromVisualTree -= OnElementDetached;
|
||||
|
||||
// Remove the adorner control from the AdornerLayer so it is not left
|
||||
// as an invisible orphan child for the lifetime of the host window.
|
||||
// Use the static API to match the add path in VerifyAdornerLayer.
|
||||
AdornerLayer.SetAdornment(_element, null);
|
||||
}
|
||||
_adorner?.Detach();
|
||||
_adorner = null;
|
||||
_element = null;
|
||||
}
|
||||
|
||||
private void OnElementDetached(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
// Full teardown so that if the element is ever re-attached and SetMagnifier is
|
||||
// called again, a fresh manager/adorner pair is created from scratch.
|
||||
var element = _element;
|
||||
Detach();
|
||||
if (element != null)
|
||||
_managers.Remove(element);
|
||||
}
|
||||
|
||||
private void ElementOnPointerReleased(object? sender, PointerReleasedEventArgs e)
|
||||
{
|
||||
if (e.GetCurrentPoint(null).Properties.PointerUpdateKind != PointerUpdateKind.LeftButtonReleased)
|
||||
return;
|
||||
if (_element != null && GetMagnifier(_element) is { IsFrozen: true })
|
||||
return;
|
||||
|
||||
HideAdorner();
|
||||
}
|
||||
|
||||
private void ElementOnPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (e.GetCurrentPoint(null).Properties.PointerUpdateKind == PointerUpdateKind.LeftButtonPressed)
|
||||
ShowAdorner();
|
||||
}
|
||||
|
||||
private void ElementOnPointerWheelChanged(object? sender, PointerWheelEventArgs e)
|
||||
{
|
||||
if (_element == null)
|
||||
return;
|
||||
if (GetMagnifier(_element) is not { IsUsingZoomOnMouseWheel: true } magnifier)
|
||||
return;
|
||||
|
||||
// WPF Delta < 0 = scroll down = zoom out (larger viewbox); Delta > 0 = scroll up = zoom in.
|
||||
// Avalonia Delta.Y > 0 = scroll up.
|
||||
if (e.Delta.Y < 0)
|
||||
{
|
||||
var newValue = magnifier.ZoomFactor + magnifier.ZoomFactorOnMouseWheel;
|
||||
magnifier.SetCurrentValue(Magnifier.ZoomFactorProperty, newValue);
|
||||
}
|
||||
else if (e.Delta.Y > 0)
|
||||
{
|
||||
var newValue = magnifier.ZoomFactor >= magnifier.ZoomFactorOnMouseWheel
|
||||
? magnifier.ZoomFactor - magnifier.ZoomFactorOnMouseWheel
|
||||
: 0d;
|
||||
magnifier.SetCurrentValue(Magnifier.ZoomFactorProperty, newValue);
|
||||
}
|
||||
|
||||
_adorner?.UpdateViewBox();
|
||||
}
|
||||
|
||||
private void ShowAdorner()
|
||||
{
|
||||
if (_adorner == null || _element == null)
|
||||
return;
|
||||
VerifyAdornerLayer();
|
||||
_adorner.Visibility = Visibility.Visible;
|
||||
_adorner.IsVisible = true;
|
||||
}
|
||||
|
||||
private void VerifyAdornerLayer()
|
||||
{
|
||||
if (_adorner.Parent != null) return;
|
||||
if (_adorner == null || _element == null)
|
||||
return;
|
||||
if (_adorner.Parent != null)
|
||||
return;
|
||||
|
||||
var layer = AdornerLayer.GetAdornerLayer(_element);
|
||||
layer?.Add(_adorner);
|
||||
if (layer != null)
|
||||
AdornerLayer.SetAdornment(_element, _adorner);
|
||||
}
|
||||
|
||||
private void HideAdorner()
|
||||
{
|
||||
if (_adorner.Visibility == Visibility.Visible)
|
||||
{
|
||||
_adorner.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
if (_adorner is { IsVisible: true })
|
||||
_adorner.IsVisible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user