diff --git a/TgaBuilderAvaloniaUi/App.DI.axaml.cs b/TgaBuilderAvaloniaUi/App.DI.axaml.cs index 0f34278..3fbccce 100644 --- a/TgaBuilderAvaloniaUi/App.DI.axaml.cs +++ b/TgaBuilderAvaloniaUi/App.DI.axaml.cs @@ -244,12 +244,12 @@ private void AddTabVMsToProvider(IServiceCollection services) panel: sp.GetRequiredService(), messageBoxService: sp.GetRequiredService())); - services.AddTransient(sp => new ViewTabViewModel( + services.AddTransient(sp => new ReadOnlyViewTabViewModel( visualPanelSize: sp.GetServices() .ElementAt((int)PresenterType.Source), panel: sp.GetRequiredService())); - services.AddTransient(sp => new ViewTabViewModel( + services.AddTransient(sp => new ReadOnlyViewTabViewModel( visualPanelSize: sp.GetServices() .ElementAt((int)PresenterType.Target), panel: sp.GetRequiredService())); @@ -297,9 +297,9 @@ private void AddViewVMsToProvider(IServiceCollection services) .ElementAt((int)PresenterType.Target), - sourceViewTab: sp.GetServices() + sourceViewTab: sp.GetServices() .ElementAt((int)PresenterType.Source), - destinationViewTab: sp.GetServices() + destinationViewTab: sp.GetServices() .ElementAt((int)PresenterType.Target), usageData: sp.GetRequiredService())); diff --git a/TgaBuilderAvaloniaUi/View/MainWindow.Methods.axaml.cs b/TgaBuilderAvaloniaUi/View/MainWindow.Methods.axaml.cs index 09a1f94..60715de 100644 --- a/TgaBuilderAvaloniaUi/View/MainWindow.Methods.axaml.cs +++ b/TgaBuilderAvaloniaUi/View/MainWindow.Methods.axaml.cs @@ -1,8 +1,11 @@  +using Avalonia; using Avalonia.Controls; using Avalonia.Controls.PanAndZoom; +using Avalonia.Threading; using Avalonia.VisualTree; using System; +using TgaBuilderLib.ViewModel; namespace TgaBuilderAvaloniaUi.View { @@ -27,5 +30,50 @@ public ZoomBorder GetPanelFromImage(Image image) public void SetPanelFromImage(Image image) => CurrentPanel = GetPanelFromImage(image); + + public void RegisterZoomBorderCallbacks(ReadOnlyViewTabViewModel viewTab, ZoomBorder panel) + { + viewTab.ApplyTransformCallback = (zoom, translateX, translateY) => + { + Dispatcher.UIThread.Post(() => + { + var matrix = Matrix.CreateScale(zoom, zoom) * + Matrix.CreateTranslation(translateX, translateY); + panel.SetMatrix(matrix); + }); + }; + + viewTab.PanStepCallback = (deltaX, deltaY) => + { + Dispatcher.UIThread.Post(() => + { + panel.SetMatrix(panel.Matrix * Matrix.CreateTranslation(deltaX, deltaY)); + }); + }; + + viewTab.ZoomStepCallback = (zoomDelta) => + { + Dispatcher.UIThread.Post(() => + { + var currentMatrix = panel.Matrix; + double centerX = panel.Bounds.Width / 2; + double centerY = panel.Bounds.Height / 2; + + var matrix = currentMatrix * + Matrix.CreateTranslation(-centerX, -centerY) * + Matrix.CreateScale(zoomDelta, zoomDelta) * + Matrix.CreateTranslation(centerX, centerY); + panel.SetMatrix(matrix); + }); + }; + + viewTab.ResetViewCallback = () => + { + Dispatcher.UIThread.Post(() => + { + panel.ResetMatrix(); + }); + }; + } } } diff --git a/TgaBuilderAvaloniaUi/View/MainWindow.axaml b/TgaBuilderAvaloniaUi/View/MainWindow.axaml index b6aad4a..7ba3a30 100644 --- a/TgaBuilderAvaloniaUi/View/MainWindow.axaml +++ b/TgaBuilderAvaloniaUi/View/MainWindow.axaml @@ -422,17 +422,10 @@ ap:MouseOverInfoAP.InfoText="{Binding PanelHelp, Converter={StaticResource PanelHelpConverter}}" ClipToBounds="True" DropCommand="{Binding FileDropSourceCommand}"> - @@ -522,7 +513,6 @@ StrokeThickness="{Binding StrokeThickness}" /> - - - - + + @@ -1075,17 +1051,10 @@ ap:MouseOverInfoAP.InfoText="{Binding PanelHelp, Converter={StaticResource PanelHelpConverter}}" ClipToBounds="True" DropCommand="{Binding FileDropDestinationCommand}"> - - - - - + + diff --git a/TgaBuilderAvaloniaUi/View/MainWindow.axaml.cs b/TgaBuilderAvaloniaUi/View/MainWindow.axaml.cs index 8df7c63..817caa3 100644 --- a/TgaBuilderAvaloniaUi/View/MainWindow.axaml.cs +++ b/TgaBuilderAvaloniaUi/View/MainWindow.axaml.cs @@ -36,6 +36,19 @@ public MainWindow(INotifyPropertyChanged mainViewModel) InitializeComponent(); base.DataContext = mainViewModel; + + if (mainViewModel is MainViewModel vm) + { + this.Opened += (_, _) => + { + var sourcePanel = this.FindControl("SourcePanel"); + var targetPanel = this.FindControl("TargetPanel"); + if (sourcePanel != null && vm.SourceViewTab is ReadOnlyViewTabViewModel sourceVm) + RegisterZoomBorderCallbacks(sourceVm, sourcePanel); + if (targetPanel != null && vm.DestinationViewTab is ReadOnlyViewTabViewModel targetVm) + RegisterZoomBorderCallbacks(targetVm, targetPanel); + }; + } } [Obsolete("For designer use only")] diff --git a/TgaBuilderLib/Abstraction/IViewTabViewModel.cs b/TgaBuilderLib/Abstraction/IViewTabViewModel.cs new file mode 100644 index 0000000..89b4ee5 --- /dev/null +++ b/TgaBuilderLib/Abstraction/IViewTabViewModel.cs @@ -0,0 +1,42 @@ +using System.ComponentModel; +using System.Windows.Input; +using TgaBuilderLib.ViewModel; + +namespace TgaBuilderLib.Abstraction +{ + /// + /// Interface for view tab view models that control zoom and pan behavior + /// for texture panels. Provides the common contract used by both + /// writable (WPF) and read-only (Avalonia) implementations. + /// + public interface IViewTabViewModel : INotifyPropertyChanged + { + bool IsScrolling { get; set; } + + PanelVisualSizeViewModel VisualPanelSize { get; } + + double ContentActualWidth { get; } + double ContentActualHeight { get; } + + double OffsetX { get; set; } + double OffsetY { get; set; } + double Zoom { get; set; } + + double MultipliedOffsetX { get; set; } + double MultipliedOffsetY { get; set; } + + double HorizonatlMargin { get; set; } + + ICommand FillCommand { get; } + ICommand FitCommand { get; } + ICommand Zoom100Command { get; } + ICommand ScrollCommand { get; } + ICommand ZoomInCommand { get; } + ICommand ZoomOutCommand { get; } + + Task DefferedFill(); + void Fill(); + void Fit(); + void Zoom100(); + } +} diff --git a/TgaBuilderLib/ViewModel/Tabs/ReadOnlyViewTabViewModel.cs b/TgaBuilderLib/ViewModel/Tabs/ReadOnlyViewTabViewModel.cs new file mode 100644 index 0000000..4ad1c03 --- /dev/null +++ b/TgaBuilderLib/ViewModel/Tabs/ReadOnlyViewTabViewModel.cs @@ -0,0 +1,273 @@ +using System.Windows.Input; +using TgaBuilderLib.Abstraction; +using TgaBuilderLib.Commands; + +namespace TgaBuilderLib.ViewModel +{ + /// + /// View tab implementation for Avalonia where zoom and offsets are read-only + /// from the PanAndZoom control. Uses matrix transformation callbacks to + /// apply view changes (zoom, pan) through the view's dispatcher service. + /// + public class ReadOnlyViewTabViewModel : ViewModelBase, IViewTabViewModel + { + public ReadOnlyViewTabViewModel( + PanelVisualSizeViewModel visualPanelSize, + TexturePanelViewModelBase panel) + { + _panel = panel; + VisualPanelSize = visualPanelSize; + + _panel.PresenterChanged += (_, _) => _ = DefferedFill(); + VisualPanelSize.PropertyChanged += (_, _) => OnContentActualSizeChanged(); + } + + private const int SCROLL_SPEED_PIX_PER_SEC = 420; + private const int DRAG_THRESHOLD = 10; + private const int SCROLLING_THRESHOLD = 30; + private const double ZOOM_STEP_FACTOR = 1.2; + + public bool IsScrolling { get; set; } = false; + private (double X, double Y) _scrollDirection; + + private TexturePanelViewModelBase _panel; + + private double _offsetX; + private double _offsetY; + + private double _horizonatlMargin; + + private RelayCommand? _FillCommand; + private RelayCommand? _FitCommand; + private RelayCommand? _100PercentCommand; + private RelayCommand? _zoomInCommand; + private RelayCommand? _zoomOutCommand; + private RelayCommand<(double X, double Y)>? _scrollCommand; + + public PanelVisualSizeViewModel VisualPanelSize { get; set; } + + public double ContentActualWidth + => Math.Min(_panel.Presenter.PixelWidth * Zoom, VisualPanelSize.ViewportWidth); + + public double ContentActualHeight + => Math.Min(_panel.Presenter.PixelHeight * Zoom, VisualPanelSize.ViewportHeight); + + public double OffsetX + { + get => _offsetX; + set => SetProperty(ref _offsetX, value, nameof(OffsetX)); + } + public double OffsetY + { + get => _offsetY; + set => SetProperty(ref _offsetY, value, nameof(OffsetY)); + } + public double Zoom + { + get => _panel.Zoom; + set => SetPanelZoom(value); + } + + public double MultipliedOffsetX + { + get => -1.0 * OffsetX * Zoom; + set + { + OffsetX = -1.0 * value / Zoom; + OnCallerPropertyChanged(); + } + } + + public double MultipliedOffsetY + { + get => -1.0 * OffsetY * Zoom; + set + { + OffsetY = -1.0 * value / Zoom; + OnCallerPropertyChanged(); + } + } + + public double HorizonatlMargin + { + get => _horizonatlMargin; + set => SetProperty(ref _horizonatlMargin, value, nameof(HorizonatlMargin)); + } + + public ICommand FillCommand => _FillCommand ??= new RelayCommand(Fill); + public ICommand FitCommand => _FitCommand ??= new RelayCommand(Fit); + public ICommand Zoom100Command => _100PercentCommand ??= new RelayCommand(Zoom100); + public ICommand ScrollCommand => _scrollCommand ??= new(DoPanelScrolling); + + /// + /// Command to zoom in by a fixed step factor. + /// + public ICommand ZoomInCommand => _zoomInCommand ??= new RelayCommand(ZoomIn); + + /// + /// Command to zoom out by a fixed step factor. + /// + public ICommand ZoomOutCommand => _zoomOutCommand ??= new RelayCommand(ZoomOut); + + /// + /// Callback to apply a full view transformation (zoom + translate). + /// Parameters: (zoom, translateX, translateY) in screen-space coordinates. + /// + public Action? ApplyTransformCallback { get; set; } + + /// + /// Callback to apply an incremental pan step. + /// Parameters: (deltaX, deltaY) in screen-space coordinates. + /// + public Action? PanStepCallback { get; set; } + + /// + /// Callback to apply a zoom step at the center of the viewport. + /// Parameters: (zoomDelta) where values > 1 zoom in, < 1 zoom out. + /// + public Action? ZoomStepCallback { get; set; } + + /// + /// Callback to reset the ZoomBorder to its initial state. + /// Used to fix ScrollViewer range issues upon content reset. + /// + public Action? ResetViewCallback { get; set; } + + public async Task DefferedFill() + { + await Task.Delay(20); + + ResetViewCallback?.Invoke(); + + var zoom = _panel.Presenter.PixelWidth < _panel.Presenter.PixelHeight + ? Math.Max( + VisualPanelSize.ViewportWidth / _panel.Presenter.PixelWidth, + VisualPanelSize.ViewportHeight / _panel.Presenter.PixelHeight) + : Math.Min( + VisualPanelSize.ViewportWidth / _panel.Presenter.PixelWidth, + VisualPanelSize.ViewportHeight / _panel.Presenter.PixelHeight); + + _panel.Zoom = zoom; + OnPropertyChanged(nameof(Zoom)); + OnContentActualSizeChanged(); + await Task.Delay(20); + ApplyTransformCallback?.Invoke(zoom, 0, 0); + } + + public void Fill() + { + var zoom = _panel.Presenter.PixelWidth < _panel.Presenter.PixelHeight + ? Math.Max( + VisualPanelSize.ViewportWidth / _panel.Presenter.PixelWidth, + VisualPanelSize.ViewportHeight / _panel.Presenter.PixelHeight) + : Math.Min( + VisualPanelSize.ViewportWidth / _panel.Presenter.PixelWidth, + VisualPanelSize.ViewportHeight / _panel.Presenter.PixelHeight); + + _panel.Zoom = zoom; + OnPropertyChanged(nameof(Zoom)); + OnContentActualSizeChanged(); + ApplyTransformCallback?.Invoke(zoom, 0, 0); + } + + public void Fit() + { + var zoom = _panel.Presenter.PixelWidth < _panel.Presenter.PixelHeight + ? Math.Min( + VisualPanelSize.ViewportWidth / _panel.Presenter.PixelWidth, + VisualPanelSize.ViewportHeight / _panel.Presenter.PixelHeight) + : Math.Max( + VisualPanelSize.ViewportWidth / _panel.Presenter.PixelWidth, + VisualPanelSize.ViewportHeight / _panel.Presenter.PixelHeight); + + _panel.Zoom = zoom; + OnPropertyChanged(nameof(Zoom)); + OnContentActualSizeChanged(); + ApplyTransformCallback?.Invoke(zoom, 0, 0); + } + + public void Zoom100() + { + _panel.Zoom = 1.0; + OnPropertyChanged(nameof(Zoom)); + OnContentActualSizeChanged(); + double offsetX = (_panel.Presenter.PixelWidth - VisualPanelSize.ViewportWidth) / 2; + double offsetY = (_panel.Presenter.PixelHeight - VisualPanelSize.ViewportHeight) / 2; + ApplyTransformCallback?.Invoke(1.0, -offsetX, -offsetY); + } + + public void ZoomIn() + { + ZoomStepCallback?.Invoke(ZOOM_STEP_FACTOR); + } + + public void ZoomOut() + { + ZoomStepCallback?.Invoke(1.0 / ZOOM_STEP_FACTOR); + } + + private void SetPanelZoom(double zoom) + { + if (zoom == _panel.Zoom) + return; + + _panel.Zoom = zoom; + + OnContentActualSizeChanged(); + OnPropertyChanged(nameof(Zoom)); + } + + private void OnContentActualSizeChanged() + { + OnPropertyChanged(nameof(ContentActualWidth)); + OnPropertyChanged(nameof(ContentActualHeight)); + } + + private void DoPanelScrolling((double X, double Y) pos) + { + int posX = (int)pos.X; + int posY = (int)pos.Y; + + if ((Math.Abs(posX - VisualPanelSize.ViewportWidth) > DRAG_THRESHOLD || + Math.Abs(posY - VisualPanelSize.ViewportHeight) > DRAG_THRESHOLD) && + _panel.CanScroll) + { + (double X, double Y) scrollVector = new(0, 0); + + if (posY < SCROLLING_THRESHOLD) + scrollVector.Y = -1; + else if (posY > VisualPanelSize.ViewportHeight - SCROLLING_THRESHOLD) + scrollVector.Y = 1; + + if (posX < SCROLLING_THRESHOLD) + scrollVector.X = -1; + else if (posX > VisualPanelSize.ViewportWidth - SCROLLING_THRESHOLD) + scrollVector.X = 1; + + if (scrollVector.X != 0 || scrollVector.Y != 0) + { + _scrollDirection = scrollVector; + if (!IsScrolling) + { + _ = StartScrollingAsync(); + } + } + else IsScrolling = false; + } + else IsScrolling = false; + } + + private async Task StartScrollingAsync() + { + IsScrolling = true; + + while (IsScrolling) + { + double deltaX = -_scrollDirection.X * SCROLL_SPEED_PIX_PER_SEC; + double deltaY = -_scrollDirection.Y * SCROLL_SPEED_PIX_PER_SEC; + PanStepCallback?.Invoke(deltaX, deltaY); + await Task.Delay(1000); + } + } + } +} diff --git a/TgaBuilderLib/ViewModel/Tabs/ViewTabViewModel.cs b/TgaBuilderLib/ViewModel/Tabs/WritableViewTabViewModel.cs similarity index 93% rename from TgaBuilderLib/ViewModel/Tabs/ViewTabViewModel.cs rename to TgaBuilderLib/ViewModel/Tabs/WritableViewTabViewModel.cs index d6e1faa..25bb447 100644 --- a/TgaBuilderLib/ViewModel/Tabs/ViewTabViewModel.cs +++ b/TgaBuilderLib/ViewModel/Tabs/WritableViewTabViewModel.cs @@ -1,13 +1,17 @@ using System.Diagnostics; - using System.Windows.Input; +using TgaBuilderLib.Abstraction; using TgaBuilderLib.Commands; namespace TgaBuilderLib.ViewModel { - public class ViewTabViewModel : ViewModelBase + /// + /// View tab implementation for WPF where zoom and offsets are writable + /// via two-way property bindings to the zoom panel control. + /// + public class WritableViewTabViewModel : ViewModelBase, IViewTabViewModel { - public ViewTabViewModel( + public WritableViewTabViewModel( PanelVisualSizeViewModel visualPanelSize, TexturePanelViewModelBase panel) { @@ -37,6 +41,8 @@ public ViewTabViewModel( private RelayCommand? _FillCommand; private RelayCommand? _FitCommand; private RelayCommand? _100PercentCommand; + private RelayCommand? _zoomInCommand; + private RelayCommand? _zoomOutCommand; private RelayCommand<(double X, double Y)>? _scrollCommand; double maxX; @@ -98,8 +104,8 @@ public double HorizonatlMargin public ICommand FitCommand => _FitCommand ??= new RelayCommand(Fit); public ICommand Zoom100Command => _100PercentCommand ??= new RelayCommand(Zoom100); public ICommand ScrollCommand => _scrollCommand ??= new(DoPanelScrolling); - - + public ICommand ZoomInCommand => _zoomInCommand ??= new RelayCommand(() => Zoom *= 1.2); + public ICommand ZoomOutCommand => _zoomOutCommand ??= new RelayCommand(() => Zoom /= 1.2); // A makeshift workaround to avoid a race condition when the panel is resized. // WPF seemingly requires time to set everything up appropriately. @@ -246,4 +252,4 @@ private async Task StartScrollingAsync() } } } -} \ No newline at end of file +} diff --git a/TgaBuilderLib/ViewModel/Views/MainViewModel.cs b/TgaBuilderLib/ViewModel/Views/MainViewModel.cs index c03a67c..6fef1ad 100644 --- a/TgaBuilderLib/ViewModel/Views/MainViewModel.cs +++ b/TgaBuilderLib/ViewModel/Views/MainViewModel.cs @@ -28,8 +28,8 @@ public MainViewModel( SourceIOViewModel sourceIO, TargetIOViewModel destinationIO, - ViewTabViewModel sourceViewTab, - ViewTabViewModel destinationViewTab, + IViewTabViewModel sourceViewTab, + IViewTabViewModel destinationViewTab, PlacingTabViewModel placing, EditTabViewModel edits, @@ -143,8 +143,8 @@ public MainViewModel( public FormatTabViewModel SourceFormatTab { get; set; } public FormatTabViewModel DestinationFormatTab { get; set; } - public ViewTabViewModel SourceViewTab { get; set; } - public ViewTabViewModel DestinationViewTab { get; set; } + public IViewTabViewModel SourceViewTab { get; set; } + public IViewTabViewModel DestinationViewTab { get; set; } public SelectionViewModel Selection { get; set; } public AnimationViewModel Animation { get; set; } diff --git a/TgaBuilderWpfUi/App.DI.xaml.cs b/TgaBuilderWpfUi/App.DI.xaml.cs index a09a92e..ac7414b 100644 --- a/TgaBuilderWpfUi/App.DI.xaml.cs +++ b/TgaBuilderWpfUi/App.DI.xaml.cs @@ -244,12 +244,12 @@ private void AddTabVMsToProvider(IServiceCollection services) panel: sp.GetRequiredService(), messageBoxService: sp.GetRequiredService())); - services.AddTransient(sp => new ViewTabViewModel( + services.AddTransient(sp => new WritableViewTabViewModel( visualPanelSize: sp.GetServices() .ElementAt((int)PresenterType.Source), panel: sp.GetRequiredService())); - services.AddTransient(sp => new ViewTabViewModel( + services.AddTransient(sp => new WritableViewTabViewModel( visualPanelSize: sp.GetServices() .ElementAt((int)PresenterType.Target), panel: sp.GetRequiredService())); @@ -306,9 +306,9 @@ private void AddViewVMsToProvider(IServiceCollection services) .ElementAt((int)PresenterType.Target), - sourceViewTab: sp.GetServices() + sourceViewTab: sp.GetServices() .ElementAt((int)PresenterType.Source), - destinationViewTab: sp.GetServices() + destinationViewTab: sp.GetServices() .ElementAt((int)PresenterType.Target), usageData: sp.GetRequiredService()));