diff --git a/.gitignore b/.gitignore
index 9cac996..0517833 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,231 +1,53 @@
-# Byte-compiled / optimized / DLL files
-__pycache__/
-*.py[codz]
-*$py.class
-
-# C extensions
-*.so
-
-# Distribution / packaging
-.Python
-build/
-develop-eggs/
-dist/
-downloads/
-eggs/
-.eggs/
-lib/
-lib64/
-parts/
-sdist/
-var/
-wheels/
-share/python-wheels/
-*.egg-info/
-.installed.cfg
-*.egg
-MANIFEST
-
-# PyInstaller
-# Usually these files are written by a python script from a template
-# before PyInstaller builds the exe, so as to inject date/other infos into it.
-*.manifest
-*.spec
-
-# Installer logs
-pip-log.txt
-pip-delete-this-directory.txt
-
-# Unit test / coverage reports
-htmlcov/
-.tox/
-.nox/
-.coverage
-.coverage.*
-.cache
-nosetests.xml
-coverage.xml
-*.cover
-*.py.cover
-.hypothesis/
-.pytest_cache/
-cover/
-
-# Translations
-*.mo
-*.pot
-
-# Django stuff:
-*.log
-local_settings.py
-db.sqlite3
-db.sqlite3-journal
-
-# Flask stuff:
-instance/
-.webassets-cache
-
-# Scrapy stuff:
-.scrapy
-
-# Sphinx documentation
-docs/_build/
-
-# PyBuilder
-.pybuilder/
-target/
-
-# Jupyter Notebook
-.ipynb_checkpoints
-
-# IPython
-profile_default/
-ipython_config.py
-
-# pyenv
-# For a library or package, you might want to ignore these files since the code is
-# intended to run in multiple environments; otherwise, check them in:
-# .python-version
-
-# pipenv
-# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
-# However, in case of collaboration, if having platform-specific dependencies or dependencies
-# having no cross-platform support, pipenv may install dependencies that don't work, or not
-# install all needed dependencies.
-#Pipfile.lock
-
-# UV
-# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
-# This is especially recommended for binary packages to ensure reproducibility, and is more
-# commonly ignored for libraries.
-#uv.lock
-
-# poetry
-# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
-# This is especially recommended for binary packages to ensure reproducibility, and is more
-# commonly ignored for libraries.
-# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
-#poetry.lock
-#poetry.toml
-
-# pdm
-# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
-# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
-# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
-#pdm.lock
-#pdm.toml
-.pdm-python
-.pdm-build/
-
-# pixi
-# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
-#pixi.lock
-# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
-# in the .venv directory. It is recommended not to include this directory in version control.
-.pixi
-
-# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
-__pypackages__/
-
-# Celery stuff
-celerybeat-schedule
-celerybeat.pid
-
-# SageMath parsed files
-*.sage.py
-
-# Environments
-.env
-.envrc
-.venv
-env/
-venv/
-ENV/
-env.bak/
-venv.bak/
-
-# Spyder project settings
-.spyderproject
-.spyproject
-
-# Rope project settings
-.ropeproject
-
-# mkdocs documentation
-/site
-
-# mypy
-.mypy_cache/
-.dmypy.json
-dmypy.json
-
-# Pyre type checker
-.pyre/
-
-# pytype static type analyzer
-.pytype/
-
-# Cython debug symbols
-cython_debug/
-
-# PyCharm
-# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
-# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
-# and can be added to the global gitignore or merged into this file. For a more nuclear
-# option (not recommended) you can uncomment the following to ignore the entire idea folder.
-#.idea/
-
-# Abstra
-# Abstra is an AI-powered process automation framework.
-# Ignore directories containing user credentials, local state, and settings.
-# Learn more at https://abstra.io/docs
-.abstra/
-
-# Visual Studio Code
-# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
-# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
-# and can be added to the global gitignore or merged into this file. However, if you prefer,
-# you could uncomment the following to ignore the entire vscode folder
-# .vscode/
-
-# Ruff stuff:
-.ruff_cache/
-
-# PyPI configuration file
-.pypirc
-
-# Cursor
-# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
-# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
-# refer to https://docs.cursor.com/context/ignore-files
-.cursorignore
-.cursorindexingignore
-
-# Marimo
-marimo/_static/
-marimo/_lsp/
-__marimo__/
-
-# Project-specific entries
-# IDE
-.vscode/
-.idea/
-*.swp
-*.swo
-
-# OS
-.DS_Store
-Thumbs.db
-
-# Temporary files
-*.tmp
-*.temp
-
-# Compiled executables
-*.exe
-*.dll
-*.pyd
-
-# Test files
-test_*.gif
-test_*.png
+# .NET / Avalonia
+**/bin/
+**/obj/
+*.user
+*.suo
+*.userosscache
+*.sln.docstates
+*.vshost.*
+*.pidb
+*.log
+*.nupkg
+*.snupkg
+*.vs/
+.vscode/
+.idea/
+*.swp
+*.swo
+*.tmp
+*.temp
+.venv/
+cooliocns SVG/
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Build outputs
+*.exe
+*.dll
+*.pdb
+*.cache
+*.db
+*.db-shm
+*.db-wal
+
+# Rider/VS
+*.DotSettings.user
+
+# Avalonia generated caches
+Avalonia.Designer.*
+
+# Editor caches
+*.orig
+
+# Cursor AI
+.cursor/
+.cursorignore
+.cursorindexingignore
+
+# Publish artifacts
+FreshViewer-win-x64/
+FreshViewer/**/bin/
+FreshViewer/**/obj/
diff --git a/FreshViewer.Tests/FreshViewer.Tests.csproj b/FreshViewer.Tests/FreshViewer.Tests.csproj
new file mode 100644
index 0000000..bf2eded
--- /dev/null
+++ b/FreshViewer.Tests/FreshViewer.Tests.csproj
@@ -0,0 +1,18 @@
+
+
+ net8.0-windows10.0.17763.0
+ enable
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/FreshViewer.Tests/MainWindowViewModelTests.cs b/FreshViewer.Tests/MainWindowViewModelTests.cs
new file mode 100644
index 0000000..82c4195
--- /dev/null
+++ b/FreshViewer.Tests/MainWindowViewModelTests.cs
@@ -0,0 +1,40 @@
+using FreshViewer.ViewModels;
+using FreshViewer.Models;
+using Xunit;
+
+namespace FreshViewer.Tests;
+
+public sealed class MainWindowViewModelTests
+{
+ [Fact]
+ public void ApplyMetadata_SetsVisibilityFlags()
+ {
+ var viewModel = new MainWindowViewModel();
+ var metadata = new ImageMetadata(new[]
+ {
+ new MetadataSection("General", new[] { new MetadataField("Label", "Value") })
+ });
+
+ viewModel.ApplyMetadata("file.png", "100x100", "Loaded", metadata);
+
+ Assert.True(viewModel.IsMetadataVisible);
+ Assert.True(viewModel.HasMetadataDetails);
+ Assert.True(viewModel.HasSummaryItems);
+ Assert.Equal("file.png", viewModel.FileName);
+ Assert.Equal("Loaded", viewModel.StatusText);
+ }
+
+ [Fact]
+ public void ResetMetadata_HidesPanels()
+ {
+ var viewModel = new MainWindowViewModel();
+ viewModel.ApplyMetadata("file.png", "100x100", "Loaded", null);
+
+ viewModel.ResetMetadata();
+
+ Assert.False(viewModel.IsMetadataVisible);
+ Assert.False(viewModel.HasMetadataDetails);
+ Assert.Null(viewModel.FileName);
+ Assert.True(viewModel.ShowMetadataPlaceholder);
+ }
+}
diff --git a/FreshViewer.sln b/FreshViewer.sln
new file mode 100644
index 0000000..dd80834
--- /dev/null
+++ b/FreshViewer.sln
@@ -0,0 +1,24 @@
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.8.34330.188
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FreshViewer", "FreshViewer\FreshViewer.csproj", "{D1A0B5F4-89E7-4E61-9CDA-2A1D6A9293A2}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FreshViewer.Tests", "FreshViewer.Tests\FreshViewer.Tests.csproj", "{20527196-D2C4-42B4-80EF-DAE8C7470CFB}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {D1A0B5F4-89E7-4E61-9CDA-2A1D6A9293A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D1A0B5F4-89E7-4E61-9CDA-2A1D6A9293A2}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D1A0B5F4-89E7-4E61-9CDA-2A1D6A9293A2}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D1A0B5F4-89E7-4E61-9CDA-2A1D6A9293A2}.Release|Any CPU.Build.0 = Release|Any CPU
+ {20527196-D2C4-42B4-80EF-DAE8C7470CFB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {20527196-D2C4-42B4-80EF-DAE8C7470CFB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {20527196-D2C4-42B4-80EF-DAE8C7470CFB}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {20527196-D2C4-42B4-80EF-DAE8C7470CFB}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+EndGlobal
diff --git a/FreshViewer/App.axaml b/FreshViewer/App.axaml
new file mode 100644
index 0000000..4aa56c2
--- /dev/null
+++ b/FreshViewer/App.axaml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/FreshViewer/App.axaml.cs b/FreshViewer/App.axaml.cs
new file mode 100644
index 0000000..28ff2ca
--- /dev/null
+++ b/FreshViewer/App.axaml.cs
@@ -0,0 +1,80 @@
+using System;
+using System.Diagnostics;
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+using Avalonia.Markup.Xaml.Styling;
+using Avalonia.Platform;
+
+namespace FreshViewer;
+
+///
+/// Configures resources and starts the FreshViewer desktop lifetime.
+///
+public partial class App : Application
+{
+ ///
+ public override void Initialize()
+ {
+ try
+ {
+ AvaloniaXamlLoader.Load(this);
+
+ // Load Liquid Glass resources only after the core dictionary is in place.
+ try
+ {
+ var baseUri = new Uri("avares://FreshViewer/App.axaml");
+ var liquidGlassUri = new Uri("avares://FreshViewer/Styles/LiquidGlass.Windows.axaml");
+ Resources.MergedDictionaries.Add(new ResourceInclude(baseUri)
+ {
+ Source = liquidGlassUri
+ });
+ Debug.WriteLine("LiquidGlass styles loaded successfully");
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"Failed to load LiquidGlass styles: {ex.Message}");
+ }
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"Fatal error during app initialization: {ex}");
+ throw;
+ }
+ }
+
+ ///
+ public override void OnFrameworkInitializationCompleted()
+ {
+ try
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ Views.MainWindow? window = null;
+
+ try
+ {
+ window = new Views.MainWindow();
+ window.InitializeFromArguments(desktop.Args);
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"Error creating main window: {ex}");
+ window = new Views.MainWindow();
+ }
+
+ desktop.MainWindow = window;
+ desktop.ShutdownMode = Avalonia.Controls.ShutdownMode.OnMainWindowClose;
+
+ desktop.Exit += (s, e) => Debug.WriteLine($"Application exiting with code {e.ApplicationExitCode}");
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"Fatal error during framework initialization: {ex}");
+ throw;
+ }
+ }
+}
diff --git a/BlurViewer.ico b/FreshViewer/Assets/AppIcon.ico
similarity index 100%
rename from BlurViewer.ico
rename to FreshViewer/Assets/AppIcon.ico
diff --git a/FreshViewer/Assets/Icons/Arrow_Undo_Down_Left.svg b/FreshViewer/Assets/Icons/Arrow_Undo_Down_Left.svg
new file mode 100644
index 0000000..70251fd
--- /dev/null
+++ b/FreshViewer/Assets/Icons/Arrow_Undo_Down_Left.svg
@@ -0,0 +1,3 @@
+
diff --git a/FreshViewer/Assets/Icons/Chevron_Left_MD.svg b/FreshViewer/Assets/Icons/Chevron_Left_MD.svg
new file mode 100644
index 0000000..1b2804a
--- /dev/null
+++ b/FreshViewer/Assets/Icons/Chevron_Left_MD.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/FreshViewer/Assets/Icons/Chevron_Right_MD.svg b/FreshViewer/Assets/Icons/Chevron_Right_MD.svg
new file mode 100644
index 0000000..14bdc86
--- /dev/null
+++ b/FreshViewer/Assets/Icons/Chevron_Right_MD.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/FreshViewer/Assets/Icons/Folder_Open.svg b/FreshViewer/Assets/Icons/Folder_Open.svg
new file mode 100644
index 0000000..cffa233
--- /dev/null
+++ b/FreshViewer/Assets/Icons/Folder_Open.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/FreshViewer/Assets/Icons/open.svg b/FreshViewer/Assets/Icons/open.svg
new file mode 100644
index 0000000..c03fdde
--- /dev/null
+++ b/FreshViewer/Assets/Icons/open.svg
@@ -0,0 +1,4 @@
+
diff --git a/FreshViewer/Assets/Icons/rotate.svg b/FreshViewer/Assets/Icons/rotate.svg
new file mode 100644
index 0000000..0325aea
--- /dev/null
+++ b/FreshViewer/Assets/Icons/rotate.svg
@@ -0,0 +1,4 @@
+
diff --git a/FreshViewer/Assets/i18n/Strings.de.axaml b/FreshViewer/Assets/i18n/Strings.de.axaml
new file mode 100644
index 0000000..2eaff0a
--- /dev/null
+++ b/FreshViewer/Assets/i18n/Strings.de.axaml
@@ -0,0 +1,12 @@
+
+ Zurück
+ Weiter
+ Anpassen
+ Drehen
+ Öffnen
+ Info
+ Ausblenden
+ Einstellungen
+ Vollbild
+
+
diff --git a/FreshViewer/Assets/i18n/Strings.en.axaml b/FreshViewer/Assets/i18n/Strings.en.axaml
new file mode 100644
index 0000000..8607098
--- /dev/null
+++ b/FreshViewer/Assets/i18n/Strings.en.axaml
@@ -0,0 +1,12 @@
+
+ Back
+ Next
+ Fit
+ Rotate
+ Open
+ Info
+ Hide
+ Settings
+ Screen
+
+
diff --git a/FreshViewer/Assets/i18n/Strings.ru.axaml b/FreshViewer/Assets/i18n/Strings.ru.axaml
new file mode 100644
index 0000000..9429786
--- /dev/null
+++ b/FreshViewer/Assets/i18n/Strings.ru.axaml
@@ -0,0 +1,12 @@
+
+ Назад
+ Вперёд
+ Подогнать
+ Повернуть
+ Открыть
+ Инфо
+ Скрыть
+ Настройки
+ Экран
+
+
diff --git a/FreshViewer/Assets/i18n/Strings.uk.axaml b/FreshViewer/Assets/i18n/Strings.uk.axaml
new file mode 100644
index 0000000..6659584
--- /dev/null
+++ b/FreshViewer/Assets/i18n/Strings.uk.axaml
@@ -0,0 +1,12 @@
+
+ Назад
+ Далі
+ Підігнати
+ Повернути
+ Відкрити
+ Інфо
+ Сховати
+ Налаштування
+ Екран
+
+
diff --git a/FreshViewer/Controls/ImageViewport.cs b/FreshViewer/Controls/ImageViewport.cs
new file mode 100644
index 0000000..3ff7d25
--- /dev/null
+++ b/FreshViewer/Controls/ImageViewport.cs
@@ -0,0 +1,1001 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Media;
+using Avalonia.Media.Imaging;
+using Avalonia.Threading;
+using FreshViewer.Models;
+using FreshViewer.Services;
+
+namespace FreshViewer.Controls;
+
+///
+/// Displays still images and animations with kinetic panning, zooming, and rotation support.
+///
+public sealed class ImageViewport : Control, IDisposable
+{
+ private const double LerpFactor = 0.18;
+ private const double PanFriction = 0.85;
+ private const double ZoomFactor = 1.2;
+ private const double ZoomStep = 0.15;
+ private const double MinScale = 0.05;
+ private const double MaxScale = 20.0;
+ private static readonly TimeSpan TimerInterval = TimeSpan.FromMilliseconds(8);
+
+ private readonly DispatcherTimer _timer;
+ private readonly ImageLoader _loader = new();
+
+ private LoadedImage? _currentImage;
+ private CancellationTokenSource? _loadingCts;
+ private ImageMetadata? _currentMetadata;
+
+ private double _currentScale = 1.0;
+ private double _targetScale = 1.0;
+ private double _fitScale = 1.0;
+ private Vector _currentOffset;
+ private Vector _targetOffset;
+ private Vector _panVelocity;
+ private double _backgroundOpacity;
+ private double _targetBackgroundOpacity = 0.68;
+
+ private bool _isPanning;
+ private Point _lastPointerPosition;
+
+ private bool _openingAnimationActive;
+ private double _openingScale = 0.8;
+ private double _openingOpacity;
+
+ private bool _closingAnimationActive;
+ private double _closingScale = 1.0;
+ private double _closingOpacity = 1.0;
+
+ private bool _needsRedraw;
+ private bool _fitPending;
+
+ private double _rotation;
+ private double _targetRotation;
+
+ private int _animationFrameIndex;
+ private TimeSpan _animationFrameElapsed;
+ private int _completedLoops;
+ private bool _isFullscreen;
+ private ImageTransition _pendingTransition = ImageTransition.None;
+
+ private DateTime _lastTick = DateTime.UtcNow;
+
+ ///
+ /// Raised when an image or animation finishes loading.
+ ///
+ public event EventHandler? ImagePresented;
+ ///
+ /// Raised when the viewport transform (zoom, offset, or rotation) changes.
+ ///
+ public event EventHandler? ViewStateChanged;
+ ///
+ /// Raised when loading an image fails with an exception.
+ ///
+ public event EventHandler? ImageFailed;
+ ///
+ /// Raised when the user clicks the background instead of the image content.
+ ///
+ public event EventHandler? BackgroundClicked;
+
+ public ImageViewport()
+ {
+ ClipToBounds = false;
+ Focusable = true;
+
+ _timer = new DispatcherTimer(TimerInterval, DispatcherPriority.Render, (_, _) => OnTick());
+ _timer.Start();
+
+ AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel, handledEventsToo: true);
+ AddHandler(PointerMovedEvent, OnPointerMoved, RoutingStrategies.Tunnel, handledEventsToo: true);
+ AddHandler(PointerReleasedEvent, OnPointerReleased, RoutingStrategies.Tunnel, handledEventsToo: true);
+ AddHandler(PointerWheelChangedEvent, OnPointerWheelChanged, RoutingStrategies.Bubble, handledEventsToo: true);
+ AddHandler(PointerCaptureLostEvent, OnPointerCaptureLost);
+ }
+
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ base.OnPropertyChanged(change);
+
+ if (change.Property == BoundsProperty)
+ {
+ RecalculateFitScale();
+ if (_fitPending)
+ {
+ ApplyFitToView();
+ }
+
+ if (_pendingTransition != ImageTransition.None)
+ {
+ if (ApplyPendingTransition())
+ {
+ _needsRedraw = true;
+ }
+ }
+ }
+ }
+
+ ///
+ /// Gets or sets a value indicating whether the viewport is rendered in fullscreen mode.
+ ///
+ public bool IsFullscreen
+ {
+ get => _isFullscreen;
+ set
+ {
+ if (_isFullscreen == value)
+ {
+ return;
+ }
+
+ _isFullscreen = value;
+ _targetBackgroundOpacity = value ? 1.0 : 0.68;
+ AnimateViewportRealignment(value);
+ _needsRedraw = true;
+ }
+ }
+
+ ///
+ /// Gets a value indicating whether an image is currently loaded.
+ ///
+ public bool HasImage => _currentImage is not null;
+
+ ///
+ /// Determines whether the specified point is within the projected image bounds.
+ ///
+ public bool IsPointWithinImage(Point point) => IsPointOnImage(point);
+
+ ///
+ /// Gets the current rotation angle in degrees.
+ ///
+ public double Rotation => _rotation;
+
+ ///
+ /// Gets the current zoom factor applied to the image.
+ ///
+ public double CurrentScale => _currentScale;
+
+ ///
+ /// Gets the current pan offset in device-independent units.
+ ///
+ public Vector CurrentOffset => _currentOffset;
+
+ ///
+ /// Gets the geometric center of the viewport.
+ ///
+ public Point ViewportCenterPoint => new Point(Bounds.Width / 2.0, Bounds.Height / 2.0);
+
+ ///
+ /// Loads an image asynchronously and optionally applies a transition animation.
+ ///
+ public async Task LoadImageAsync(string path, ImageTransition transition = ImageTransition.FadeIn)
+ {
+ CancelLoading();
+ _loadingCts = new CancellationTokenSource();
+ _pendingTransition = transition;
+ var requestedPath = path;
+
+ try
+ {
+ var loaded = await _loader.LoadAsync(path, _loadingCts.Token).ConfigureAwait(false);
+ await Dispatcher.UIThread.InvokeAsync(() => PresentLoadedImage(loaded), DispatcherPriority.Render);
+ }
+ catch (OperationCanceledException)
+ {
+ // ignore
+ }
+ catch (Exception ex)
+ {
+ await Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ ImageFailed?.Invoke(this, new ImageFailedEventArgs(requestedPath, ex));
+ });
+ }
+ }
+
+ ///
+ /// Resets zoom, rotation, and offset to fit the current image.
+ ///
+ public void ResetView()
+ {
+ if (!HasImage)
+ {
+ return;
+ }
+
+ _rotation = 0;
+ RecalculateFitScale();
+ ApplyFitToView();
+ }
+
+ ///
+ /// Sets the target zoom so that the image fits inside the viewport bounds.
+ ///
+ public void FitToView()
+ {
+ if (!HasImage)
+ {
+ return;
+ }
+
+ RecalculateFitScale();
+ _targetScale = _fitScale;
+ _panVelocity = Vector.Zero;
+
+ if (!double.IsNaN(_fitScale) && _fitScale > 0)
+ {
+ var scaleDiff = _targetScale - _currentScale;
+ if (Math.Abs(scaleDiff) > 0.01)
+ {
+ _currentScale += scaleDiff * 0.35;
+ }
+ else
+ {
+ _currentScale = _targetScale;
+ }
+ }
+
+ var center = GetViewportCenter();
+ var offsetDiff = center - _targetOffset;
+ _targetOffset = center;
+
+ if (offsetDiff.Length > 1.0)
+ {
+ _currentOffset += offsetDiff * 0.4;
+ }
+ else
+ {
+ _currentOffset = center;
+ }
+
+ _needsRedraw = true;
+ }
+
+ public void ZoomTo(double newScale, Point focusPoint)
+ {
+ if (!HasImage)
+ {
+ return;
+ }
+
+ var clamped = Math.Clamp(newScale, MinScale, MaxScale);
+ var referenceScale = _targetScale;
+ if (referenceScale <= 0)
+ {
+ referenceScale = 0.001;
+ }
+
+ var focusVector = new Vector(focusPoint.X, focusPoint.Y);
+ var delta = focusVector - _targetOffset;
+ var ratio = clamped / referenceScale;
+ _targetOffset = focusVector - delta * ratio;
+ _targetScale = clamped;
+ _needsRedraw = true;
+ }
+
+ ///
+ /// Adjusts the zoom by a fixed step around the supplied focus point.
+ ///
+ public void ZoomIncrement(Point focusPoint, bool zoomIn)
+ {
+ var factor = zoomIn ? ZoomFactor : (1.0 / ZoomFactor);
+ ZoomTo(_targetScale * factor, focusPoint);
+ }
+
+ ///
+ /// Applies mouse wheel zooming using the configured step factor.
+ ///
+ public void ZoomWithWheel(Point focusPoint, double wheelDelta)
+ {
+ var zoomFactor = 1.0 + wheelDelta * ZoomStep;
+ if (zoomFactor < 0.05)
+ {
+ zoomFactor = 0.05;
+ }
+ ZoomTo(_targetScale * zoomFactor, focusPoint);
+ }
+
+ ///
+ /// Rotates the image clockwise by 90 degrees.
+ ///
+ public void RotateClockwise()
+ {
+ if (!HasImage)
+ {
+ return;
+ }
+
+ _panVelocity = Vector.Zero;
+ _targetRotation = NormalizeAngle(_targetRotation + 90);
+ RecalculateFitScale();
+ _needsRedraw = true;
+ ViewStateChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ ///
+ /// Rotates the image counter-clockwise by 90 degrees.
+ ///
+ public void RotateCounterClockwise()
+ {
+ if (!HasImage)
+ {
+ return;
+ }
+
+ _panVelocity = Vector.Zero;
+ _targetRotation = NormalizeAngle(_targetRotation - 90);
+ RecalculateFitScale();
+ _needsRedraw = true;
+ ViewStateChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ ///
+ /// Returns the pixel dimensions taking rotation into account.
+ ///
+ public (int width, int height) GetEffectivePixelDimensions()
+ {
+ if (!HasImage)
+ {
+ return (0, 0);
+ }
+
+ var pixelSize = _currentImage!.PixelSize;
+ return IsRotationSwappingDimensions()
+ ? (pixelSize.Height, pixelSize.Width)
+ : (pixelSize.Width, pixelSize.Height);
+ }
+
+ ///
+ /// Retrieves the bitmap backing the current static image or animation frame.
+ ///
+ public Bitmap? GetCurrentFrameBitmap()
+ {
+ if (_currentImage is null)
+ {
+ return null;
+ }
+
+ if (_currentImage.IsAnimated)
+ {
+ var frames = _currentImage.Animation!.Frames;
+ if (frames.Count == 0)
+ {
+ return null;
+ }
+
+ return frames[Math.Clamp(_animationFrameIndex, 0, frames.Count - 1)].Bitmap;
+ }
+
+ return _currentImage.Bitmap;
+ }
+
+ ///
+ /// Gets the metadata associated with the currently loaded image.
+ ///
+ public ImageMetadata? CurrentMetadata => _currentMetadata;
+
+ public override void Render(DrawingContext context)
+ {
+ base.Render(context);
+
+ var bounds = Bounds;
+ if (bounds.Width <= 0 || bounds.Height <= 0)
+ {
+ return;
+ }
+
+ var alpha = (byte)Math.Clamp(_backgroundOpacity * 255.0, 0, 255);
+ var r = _isFullscreen ? (byte)0 : (byte)10;
+ var g = _isFullscreen ? (byte)0 : (byte)12;
+ var b = _isFullscreen ? (byte)0 : (byte)18;
+ var backgroundColor = Color.FromArgb(alpha, r, g, b);
+ context.FillRectangle(new SolidColorBrush(backgroundColor), bounds);
+
+ var bitmap = GetCurrentFrameBitmap();
+ if (bitmap is null)
+ {
+ return;
+ }
+
+ var finalScale = _currentScale;
+ if (_openingAnimationActive)
+ {
+ finalScale *= _openingScale;
+ }
+ else if (_closingAnimationActive)
+ {
+ finalScale *= _closingScale;
+ }
+
+ var centerOffset = _currentOffset;
+ var destRect = new Rect(-bitmap.PixelSize.Width / 2.0, -bitmap.PixelSize.Height / 2.0,
+ bitmap.PixelSize.Width, bitmap.PixelSize.Height);
+
+ using var translate = context.PushTransform(Matrix.CreateTranslation(centerOffset.X, centerOffset.Y));
+ IDisposable? rotate = null;
+ IDisposable? scale = null;
+
+ if (Math.Abs(_rotation) > 0.01)
+ {
+ var radians = Math.PI * _rotation / 180.0;
+ rotate = context.PushTransform(Matrix.CreateRotation(radians));
+ }
+
+ if (Math.Abs(finalScale - 1.0) > 0.001)
+ {
+ scale = context.PushTransform(Matrix.CreateScale(finalScale, finalScale));
+ }
+
+ var opacity = 1.0;
+ if (_openingAnimationActive)
+ {
+ opacity *= _openingOpacity;
+ }
+ else if (_closingAnimationActive)
+ {
+ opacity *= _closingOpacity;
+ }
+
+ var sourceRect = new Rect(0, 0, bitmap.PixelSize.Width, bitmap.PixelSize.Height);
+ using (var opacityScope = context.PushOpacity(opacity))
+ {
+ context.DrawImage(bitmap, sourceRect, destRect);
+ }
+
+ scale?.Dispose();
+ rotate?.Dispose();
+ }
+
+ private void PresentLoadedImage(LoadedImage loaded)
+ {
+ _currentImage?.Dispose();
+ _currentImage = loaded;
+ _currentMetadata = loaded.Metadata;
+
+ _rotation = 0.0;
+ _targetRotation = 0.0;
+ _animationFrameIndex = 0;
+ _animationFrameElapsed = TimeSpan.Zero;
+ _completedLoops = 0;
+ _panVelocity = Vector.Zero;
+ _openingAnimationActive = false;
+ _openingScale = 1.0;
+ _openingOpacity = 1.0;
+ _closingAnimationActive = false;
+ _closingOpacity = 1.0;
+ _closingScale = 1.0;
+
+ if (_pendingTransition == ImageTransition.None)
+ {
+ _pendingTransition = ImageTransition.FadeIn;
+ }
+
+ _backgroundOpacity = _pendingTransition == ImageTransition.FadeIn
+ ? 0
+ : _targetBackgroundOpacity;
+
+ _fitPending = Bounds.Width <= 0 || Bounds.Height <= 0;
+ RecalculateFitScale();
+ ApplyFitToView();
+
+ if (!ApplyPendingTransition())
+ {
+ _needsRedraw = true;
+ }
+
+ ImagePresented?.Invoke(this, new ImagePresentedEventArgs(loaded.Path, GetEffectivePixelDimensions(), loaded.IsAnimated, _currentMetadata));
+ ViewStateChanged?.Invoke(this, EventArgs.Empty);
+ InvalidateVisual();
+ }
+
+ private void ApplyFitToView()
+ {
+ if (!HasImage)
+ {
+ return;
+ }
+
+ var bounds = Bounds;
+ if (bounds.Width <= 0 || bounds.Height <= 0)
+ {
+ _fitPending = true;
+ return;
+ }
+
+ _fitPending = false;
+ var center = GetViewportCenter();
+ _targetOffset = center;
+ _currentOffset = center;
+ _targetScale = _fitScale;
+ _currentScale = _isFullscreen ? _fitScale : _fitScale * 0.85;
+ _needsRedraw = true;
+ }
+
+ private bool ApplyPendingTransition()
+ {
+ if (_pendingTransition == ImageTransition.None)
+ {
+ return false;
+ }
+
+ var bounds = Bounds;
+ if (bounds.Width <= 0 || bounds.Height <= 0)
+ {
+ return false;
+ }
+
+ var center = GetViewportCenter();
+
+ switch (_pendingTransition)
+ {
+ case ImageTransition.SlideFromLeft:
+ PrepareSlideTransition(center, new Vector(-bounds.Width * 0.55, 0));
+ break;
+ case ImageTransition.SlideFromRight:
+ PrepareSlideTransition(center, new Vector(bounds.Width * 0.55, 0));
+ break;
+ case ImageTransition.Instant:
+ _openingAnimationActive = false;
+ _openingOpacity = 1.0;
+ _currentOffset = center;
+ _targetOffset = center;
+ _currentScale = _targetScale;
+ _backgroundOpacity = _targetBackgroundOpacity;
+ break;
+ case ImageTransition.FadeIn:
+ default:
+ _openingAnimationActive = true;
+ _openingScale = 0.92;
+ _openingOpacity = 0.0;
+ break;
+ }
+
+ _pendingTransition = ImageTransition.None;
+ _needsRedraw = true;
+ return true;
+ }
+
+ private void PrepareSlideTransition(Vector center, Vector offset)
+ {
+ _openingAnimationActive = false;
+ _openingOpacity = 1.0;
+ _currentOffset = center + offset;
+ _targetOffset = center;
+ _currentScale = _targetScale;
+ _backgroundOpacity = _targetBackgroundOpacity;
+ }
+
+ private bool IsPointOnImage(Point point)
+ {
+ if (!HasImage)
+ {
+ return false;
+ }
+
+ var bitmap = GetCurrentFrameBitmap();
+ if (bitmap is null)
+ {
+ return false;
+ }
+
+ if (!TryGetBitmapSize(bitmap, out var pixelSize))
+ {
+ return false;
+ }
+
+ var finalScale = _currentScale;
+ var centerPoint = new Point(_currentOffset.X, _currentOffset.Y);
+ var local = new Vector(point.X - centerPoint.X, point.Y - centerPoint.Y);
+
+ if (Math.Abs(_rotation) > 0.001)
+ {
+ var radians = Math.PI * _rotation / 180.0;
+ var cos = Math.Cos(-radians);
+ var sin = Math.Sin(-radians);
+ var rotatedX = local.X * cos - local.Y * sin;
+ var rotatedY = local.X * sin + local.Y * cos;
+ local = new Vector(rotatedX, rotatedY);
+ }
+
+ if (Math.Abs(finalScale) < 0.0001)
+ {
+ return false;
+ }
+
+ local /= finalScale;
+ var halfWidth = pixelSize.Width / 2.0;
+ var halfHeight = pixelSize.Height / 2.0;
+
+ return Math.Abs(local.X) <= halfWidth && Math.Abs(local.Y) <= halfHeight;
+ }
+
+ private static bool TryGetBitmapSize(Bitmap bitmap, out PixelSize size)
+ {
+ try
+ {
+ size = bitmap.PixelSize;
+ return size.Width > 0 && size.Height > 0;
+ }
+ catch (Exception ex) when (ex is NullReferenceException or ObjectDisposedException)
+ {
+ size = default;
+ return false;
+ }
+ }
+
+ private void AnimateViewportRealignment(bool enteringFullscreen)
+ {
+ _panVelocity = Vector.Zero;
+ _pendingTransition = ImageTransition.None;
+
+ if (!HasImage)
+ {
+ _backgroundOpacity = enteringFullscreen ? 1.0 : _targetBackgroundOpacity;
+ return;
+ }
+
+ RecalculateFitScale();
+ _fitPending = false;
+
+ var center = GetViewportCenter();
+ _targetOffset = center;
+ _currentOffset = center;
+ _targetScale = _fitScale;
+
+ if (enteringFullscreen)
+ {
+ _currentScale = Math.Min(_currentScale, Math.Max(_targetScale, 0.001) * 0.96);
+ }
+ else
+ {
+ _currentScale = Math.Min(_currentScale, Math.Max(_targetScale, 0.001) * 1.02);
+ }
+
+ _openingAnimationActive = true;
+ _openingOpacity = 0.0;
+ _openingScale = enteringFullscreen ? 0.96 : 1.04;
+ _backgroundOpacity = enteringFullscreen ? 0.0 : _targetBackgroundOpacity;
+ _needsRedraw = true;
+ }
+
+ private void RecalculateFitScale()
+ {
+ if (!HasImage)
+ {
+ return;
+ }
+
+ var bounds = Bounds;
+ if (bounds.Width <= 0 || bounds.Height <= 0)
+ {
+ _fitPending = true;
+ return;
+ }
+
+ var (width, height) = GetEffectivePixelDimensions();
+ if (width <= 0 || height <= 0)
+ {
+ _fitScale = 1.0;
+ return;
+ }
+
+ var padding = _isFullscreen ? 1.0 : 0.9;
+ var scaleX = (bounds.Width * padding) / width;
+ var scaleY = (bounds.Height * padding) / height;
+ var fitScale = Math.Min(scaleX, scaleY);
+ _fitScale = _isFullscreen ? fitScale : Math.Min(1.0, fitScale);
+ if (double.IsNaN(_fitScale) || double.IsInfinity(_fitScale))
+ {
+ _fitScale = 1.0;
+ }
+ }
+
+ private Vector GetViewportCenter()
+ {
+ var bounds = Bounds;
+ return new Vector(bounds.Width / 2.0, bounds.Height / 2.0);
+ }
+
+ private bool IsRotationSwappingDimensions()
+ {
+ var rotated = Math.Abs((NormalizeAngle(_targetRotation) % 180)) > 0.01;
+ return rotated;
+ }
+
+ private void OnTick()
+ {
+ var now = DateTime.UtcNow;
+ var delta = now - _lastTick;
+ if (delta > TimeSpan.FromMilliseconds(50))
+ {
+ delta = TimeSpan.FromMilliseconds(16);
+ }
+ _lastTick = now;
+
+ var changed = false;
+
+ if (_currentImage?.IsAnimated == true)
+ {
+ var animation = _currentImage.Animation!;
+ if (animation.Frames.Count > 0)
+ {
+ _animationFrameElapsed += delta;
+ var frame = animation.Frames[_animationFrameIndex];
+ var duration = frame.Duration.TotalMilliseconds < 5 ? TimeSpan.FromMilliseconds(16) : frame.Duration;
+ if (_animationFrameElapsed >= duration)
+ {
+ _animationFrameElapsed -= duration;
+ _animationFrameIndex++;
+ if (_animationFrameIndex >= animation.Frames.Count)
+ {
+ _animationFrameIndex = 0;
+ _completedLoops++;
+ if (animation.LoopCount > 0 && _completedLoops >= animation.LoopCount)
+ {
+ _animationFrameIndex = animation.Frames.Count - 1;
+ }
+ }
+ changed = true;
+ }
+ }
+ }
+
+ if (_openingAnimationActive)
+ {
+ _openingScale = LerpTo(_openingScale, 1.0, 0.15);
+ _openingOpacity = LerpTo(_openingOpacity, 1.0, 0.2);
+ if (Math.Abs(_openingScale - 1.0) < 0.01 && Math.Abs(_openingOpacity - 1.0) < 0.01)
+ {
+ _openingScale = 1.0;
+ _openingOpacity = 1.0;
+ _openingAnimationActive = false;
+ }
+ changed = true;
+ }
+
+ if (_closingAnimationActive)
+ {
+ _closingScale = LerpTo(_closingScale, 0.7, 0.25);
+ _closingOpacity = LerpTo(_closingOpacity, 0.0, 0.25);
+ changed = true;
+ }
+
+ if (Math.Abs(_targetBackgroundOpacity - _backgroundOpacity) > 0.01)
+ {
+ _backgroundOpacity = LerpTo(_backgroundOpacity, _targetBackgroundOpacity, 0.15);
+ changed = true;
+ }
+
+ if (!_isPanning && (_panVelocity.X != 0 || _panVelocity.Y != 0))
+ {
+ _targetOffset += _panVelocity;
+ _panVelocity *= PanFriction;
+ if (Math.Abs(_panVelocity.X) < 0.1 && Math.Abs(_panVelocity.Y) < 0.1)
+ {
+ _panVelocity = Vector.Zero;
+ }
+ changed = true;
+ }
+
+ var scaleDiff = _targetScale - _currentScale;
+ if (Math.Abs(scaleDiff) > 0.0005)
+ {
+ _currentScale += scaleDiff * LerpFactor;
+ changed = true;
+ }
+
+ var offsetDiff = _targetOffset - _currentOffset;
+ if (Math.Abs(offsetDiff.X) > 0.1 || Math.Abs(offsetDiff.Y) > 0.1)
+ {
+ _currentOffset += offsetDiff * LerpFactor;
+ changed = true;
+ }
+
+ var rotationDiff = GetShortestRotationDelta(_rotation, _targetRotation);
+ if (Math.Abs(rotationDiff) > 0.05)
+ {
+ _rotation = NormalizeAngle(_rotation + rotationDiff * 0.18);
+ changed = true;
+ }
+ else if (Math.Abs(rotationDiff) > 0.001)
+ {
+ _rotation = NormalizeAngle(_targetRotation);
+ changed = true;
+ }
+
+ if (_needsRedraw || changed)
+ {
+ _needsRedraw = false;
+ InvalidateVisual();
+ }
+ }
+
+ private static double LerpTo(double current, double target, double factor)
+ => current + (target - current) * factor;
+
+ private static double NormalizeAngle(double angle)
+ {
+ angle %= 360;
+ if (angle < 0)
+ {
+ angle += 360;
+ }
+
+ return angle;
+ }
+
+ private static double GetShortestRotationDelta(double current, double target)
+ {
+ var diff = target - current;
+ diff %= 360;
+ if (diff > 180)
+ {
+ diff -= 360;
+ }
+ else if (diff < -180)
+ {
+ diff += 360;
+ }
+
+ return diff;
+ }
+
+ private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ if (e.GetCurrentPoint(this) is { Properties.IsLeftButtonPressed: true } point)
+ {
+ if (!HasImage || !IsPointOnImage(point.Position))
+ {
+ BackgroundClicked?.Invoke(this, EventArgs.Empty);
+ e.Handled = true;
+ return;
+ }
+
+ _isPanning = true;
+ _lastPointerPosition = point.Position;
+ _panVelocity = Vector.Zero;
+ e.Pointer.Capture(this);
+ e.Handled = true;
+ }
+ }
+
+ private void OnPointerMoved(object? sender, PointerEventArgs e)
+ {
+ if (!HasImage || !_isPanning)
+ {
+ return;
+ }
+
+ var position = e.GetPosition(this);
+ var delta = position - _lastPointerPosition;
+ _currentOffset += delta;
+ _targetOffset = _currentOffset;
+ _panVelocity = delta * 0.6;
+ _lastPointerPosition = position;
+ _needsRedraw = true;
+ e.Handled = true;
+ }
+
+ private void OnPointerReleased(object? sender, PointerReleasedEventArgs e)
+ {
+ if (!_isPanning)
+ {
+ return;
+ }
+
+ if (e.InitialPressMouseButton == MouseButton.Left)
+ {
+ _isPanning = false;
+ e.Pointer.Capture(null);
+ e.Handled = true;
+ }
+ }
+
+ private void OnPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e)
+ {
+ _isPanning = false;
+ }
+
+ private void OnPointerWheelChanged(object? sender, PointerWheelEventArgs e)
+ {
+ if (!HasImage)
+ {
+ return;
+ }
+
+ ZoomWithWheel(e.GetPosition(this), e.Delta.Y);
+ e.Handled = true;
+ }
+
+ private void CancelLoading()
+ {
+ _loadingCts?.Cancel();
+ _loadingCts?.Dispose();
+ _loadingCts = null;
+ }
+
+ ///
+ /// Releases image resources and stops the internal animation timer.
+ ///
+ public void Dispose()
+ {
+ CancelLoading();
+ _currentImage?.Dispose();
+ _timer.Stop();
+ }
+}
+
+///
+/// Event arguments describing the result of presenting an image.
+///
+public sealed class ImagePresentedEventArgs : EventArgs
+{
+ public ImagePresentedEventArgs(string path, (int width, int height) dimensions, bool isAnimated, ImageMetadata? metadata)
+ {
+ Path = path;
+ Dimensions = dimensions;
+ IsAnimated = isAnimated;
+ Metadata = metadata;
+ }
+
+ ///
+ /// Gets the path of the presented image.
+ ///
+ public string Path { get; }
+
+ ///
+ /// Gets the effective pixel dimensions of the image or animation.
+ ///
+ public (int Width, int Height) Dimensions { get; }
+
+ ///
+ /// Gets a value indicating whether the presented resource is animated.
+ ///
+ public bool IsAnimated { get; }
+
+ ///
+ /// Gets the metadata extracted during the load phase.
+ ///
+ public ImageMetadata? Metadata { get; }
+}
+
+///
+/// Defines the supported transition animations when presenting images.
+///
+public enum ImageTransition
+{
+ None,
+ FadeIn,
+ SlideFromLeft,
+ SlideFromRight,
+ Instant
+}
+
+///
+/// Event arguments describing a failure while loading an image.
+///
+public sealed class ImageFailedEventArgs : EventArgs
+{
+ public ImageFailedEventArgs(string path, Exception exception)
+ {
+ Path = path;
+ Exception = exception;
+ }
+
+ ///
+ /// Gets the path of the image that failed to load.
+ ///
+ public string Path { get; }
+
+ ///
+ /// Gets the exception describing the failure.
+ ///
+ public Exception Exception { get; }
+}
diff --git a/FreshViewer/Converters/BooleanToValueConverter.cs b/FreshViewer/Converters/BooleanToValueConverter.cs
new file mode 100644
index 0000000..98d96d9
--- /dev/null
+++ b/FreshViewer/Converters/BooleanToValueConverter.cs
@@ -0,0 +1,48 @@
+using System;
+using System.Globalization;
+using Avalonia.Data.Converters;
+using Avalonia.Data;
+
+namespace FreshViewer.Converters;
+
+///
+/// Converts a boolean into one of two configured values.
+///
+public sealed class BooleanToValueConverter : IValueConverter
+{
+ ///
+ /// Gets or sets the value produced when the source boolean is true.
+ ///
+ public object? TrueValue { get; set; }
+ ///
+ /// Gets or sets the value produced when the source boolean is false.
+ ///
+ public object? FalseValue { get; set; }
+
+ ///
+ public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ if (value is bool flag)
+ {
+ return flag ? TrueValue : FalseValue;
+ }
+
+ return FalseValue;
+ }
+
+ ///
+ public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ if (TrueValue is not null && Equals(value, TrueValue))
+ {
+ return true;
+ }
+
+ if (FalseValue is not null && Equals(value, FalseValue))
+ {
+ return false;
+ }
+
+ return BindingOperations.DoNothing;
+ }
+}
diff --git a/FreshViewer/Effects/LiquidGlass/Assets/Shaders/LiquidGlassShader.sksl b/FreshViewer/Effects/LiquidGlass/Assets/Shaders/LiquidGlassShader.sksl
new file mode 100644
index 0000000..694e02c
--- /dev/null
+++ b/FreshViewer/Effects/LiquidGlass/Assets/Shaders/LiquidGlassShader.sksl
@@ -0,0 +1,80 @@
+// Liquid Glass shader adapted for Avalonia + SkiaSharp runtime effects.
+// The shader samples the existing framebuffer, adds a soft distortion,
+// and applies subtle chromatic aberration to emulate a glass surface.
+
+uniform float2 resolution; // Size of the decorated area in pixels
+uniform float intensity; // General distortion strength (0..1)
+uniform float blurAmount; // Additional soft blur (0..1)
+uniform float chromaticAberration; // Chromatic aberration offset (0..1)
+uniform float saturation; // Saturation multiplier (0..2)
+uniform shader backgroundTexture; // Render target snapshot beneath the decorator
+
+static const float3 luminanceWeights = float3(0.299, 0.587, 0.114);
+
+half4 sampleBackground(float2 uv)
+{
+ float2 clamped = clamp(uv, float2(0.0, 0.0), float2(1.0, 1.0));
+ return sample(backgroundTexture, clamped * resolution);
+}
+
+half4 adjustSaturation(half4 color, float multiplier)
+{
+ half luminance = dot(color.rgb, half3(luminanceWeights));
+ half3 grey = half3(luminance, luminance, luminance);
+ return half4(mix(grey, color.rgb, half(multiplier)), color.a);
+}
+
+half4 gatherBlur(float2 uv, float strength)
+{
+ if (strength <= 0.001)
+ {
+ return sampleBackground(uv);
+ }
+
+ float radius = strength * 0.008;
+ float2 offsets[4] = float2[4](
+ float2( radius, radius),
+ float2(-radius, radius),
+ float2( radius, -radius),
+ float2(-radius, -radius)
+ );
+
+ half4 sum = half4(0.0);
+ for (int i = 0; i < 4; ++i)
+ {
+ sum += sampleBackground(uv + offsets[i]);
+ }
+
+ return sum * 0.25;
+}
+
+half4 main(float2 coord)
+{
+ float2 uv = coord / resolution;
+ float2 centered = uv - 0.5;
+ float distanceFromCenter = length(centered);
+
+ float ripple = sin(distanceFromCenter * 10.5) * intensity * 0.015;
+ float2 direction = distanceFromCenter > 0.0001 ? centered / distanceFromCenter : float2(0.0, 0.0);
+ float2 rippleOffset = direction * ripple;
+
+ float aberration = chromaticAberration * 0.003;
+
+ float2 uvR = uv + rippleOffset + float2(aberration, 0.0);
+ float2 uvG = uv + rippleOffset * 0.5;
+ float2 uvB = uv - rippleOffset - float2(aberration, 0.0);
+
+ half4 sampleR = sampleBackground(uvR);
+ half4 sampleG = gatherBlur(uvG, blurAmount);
+ half4 sampleB = sampleBackground(uvB);
+
+ half4 combined = half4(sampleR.r, sampleG.g, sampleB.b, sampleG.a);
+ combined = adjustSaturation(combined, saturation);
+
+ // Brighten edges slightly to mimic light scattering.
+ float edge = smoothstep(0.92, 1.02, distanceFromCenter * 1.4);
+ combined.rgb += half3(edge * 0.08, edge * 0.1, edge * 0.12);
+
+ combined.a = 1.0;
+ return combined;
+}
diff --git a/FreshViewer/Effects/LiquidGlass/LiquidGlassDecorator.cs b/FreshViewer/Effects/LiquidGlass/LiquidGlassDecorator.cs
new file mode 100644
index 0000000..11849dc
--- /dev/null
+++ b/FreshViewer/Effects/LiquidGlass/LiquidGlassDecorator.cs
@@ -0,0 +1,143 @@
+using System;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Media;
+
+namespace FreshViewer.Effects.LiquidGlass;
+
+///
+/// Decorator that renders a Liquid Glass background underneath its content.
+/// When the runtime shader is not available, a static fallback is used instead.
+///
+public class LiquidGlassDecorator : Decorator
+{
+ ///
+ /// Identifies the styled property.
+ ///
+ public static readonly StyledProperty RadiusProperty =
+ AvaloniaProperty.Register(nameof(Radius), 24);
+
+ ///
+ /// Identifies the styled property.
+ ///
+ public static readonly StyledProperty IntensityProperty =
+ AvaloniaProperty.Register(nameof(Intensity), 0.85);
+
+ ///
+ /// Identifies the styled property.
+ ///
+ public static readonly StyledProperty BlurAmountProperty =
+ AvaloniaProperty.Register(nameof(BlurAmount), 0.35);
+
+ ///
+ /// Identifies the styled property.
+ ///
+ public static readonly StyledProperty ChromaticAberrationProperty =
+ AvaloniaProperty.Register(nameof(ChromaticAberration), 0.45);
+
+ ///
+ /// Identifies the styled property.
+ ///
+ public static readonly StyledProperty SaturationProperty =
+ AvaloniaProperty.Register(nameof(Saturation), 1.2);
+
+ ///
+ /// Identifies the styled property.
+ ///
+ public static readonly StyledProperty IsEffectEnabledProperty =
+ AvaloniaProperty.Register(nameof(IsEffectEnabled), true);
+
+ static LiquidGlassDecorator()
+ {
+ AffectsRender(
+ RadiusProperty,
+ IntensityProperty,
+ BlurAmountProperty,
+ ChromaticAberrationProperty,
+ SaturationProperty,
+ IsEffectEnabledProperty);
+ }
+
+ ///
+ /// Gets or sets the corner radius used when rendering the glass card.
+ ///
+ public double Radius
+ {
+ get => GetValue(RadiusProperty);
+ set => SetValue(RadiusProperty, value);
+ }
+
+ ///
+ /// Gets or sets the distortion strength supplied to the shader.
+ ///
+ public double Intensity
+ {
+ get => GetValue(IntensityProperty);
+ set => SetValue(IntensityProperty, value);
+ }
+
+ ///
+ /// Gets or sets the soft blur intensity. Value is clamped between 0 and 1.
+ ///
+ public double BlurAmount
+ {
+ get => GetValue(BlurAmountProperty);
+ set => SetValue(BlurAmountProperty, value);
+ }
+
+ ///
+ /// Gets or sets the chromatic aberration amount used by the shader.
+ ///
+ public double ChromaticAberration
+ {
+ get => GetValue(ChromaticAberrationProperty);
+ set => SetValue(ChromaticAberrationProperty, value);
+ }
+
+ ///
+ /// Gets or sets the saturation multiplier applied by the shader.
+ ///
+ public double Saturation
+ {
+ get => GetValue(SaturationProperty);
+ set => SetValue(SaturationProperty, value);
+ }
+
+ ///
+ /// Gets or sets a value indicating whether the shader should be executed.
+ ///
+ public bool IsEffectEnabled
+ {
+ get => GetValue(IsEffectEnabledProperty);
+ set => SetValue(IsEffectEnabledProperty, value);
+ }
+
+ ///
+ public override void Render(DrawingContext context)
+ {
+ var bounds = new Rect(Bounds.Size);
+ var parameters = new LiquidGlassRenderParameters(
+ Radius,
+ Clamp01(Intensity),
+ Clamp01(BlurAmount),
+ Clamp01(ChromaticAberration),
+ ClampSaturation(Saturation));
+
+ var rendered = false;
+ if (IsEffectEnabled)
+ {
+ rendered = LiquidGlassRenderer.TryRender(context, bounds, parameters);
+ }
+
+ if (!rendered)
+ {
+ LiquidGlassFallbackRenderer.Render(context, bounds, Radius);
+ }
+
+ base.Render(context);
+ }
+
+ private static double Clamp01(double value) => Math.Clamp(value, 0, 1);
+
+ private static double ClampSaturation(double value) => Math.Clamp(value, 0, 2);
+}
diff --git a/FreshViewer/Effects/LiquidGlass/LiquidGlassFallbackRenderer.cs b/FreshViewer/Effects/LiquidGlass/LiquidGlassFallbackRenderer.cs
new file mode 100644
index 0000000..ea28849
--- /dev/null
+++ b/FreshViewer/Effects/LiquidGlass/LiquidGlassFallbackRenderer.cs
@@ -0,0 +1,49 @@
+using System;
+using Avalonia;
+using Avalonia.Media;
+
+namespace FreshViewer.Effects.LiquidGlass;
+
+///
+/// Draws a lightweight brush-based fallback whenever the shader is unavailable.
+///
+internal static class LiquidGlassFallbackRenderer
+{
+ private static readonly IBrush BaseBrush = new SolidColorBrush(Color.FromArgb(0x34, 0xFF, 0xFF, 0xFF));
+
+ private static readonly IBrush HighlightBrush = new LinearGradientBrush
+ {
+ StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
+ EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative),
+ GradientStops = new GradientStops
+ {
+ new GradientStop(Color.FromArgb(0xAA, 0xFF, 0xFF, 0xFF), 0),
+ new GradientStop(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF), 1)
+ }
+ };
+
+ private static readonly Pen BorderPen = new(new SolidColorBrush(Color.FromArgb(0x4A, 0xC5, 0xD9, 0xFF)), 1);
+
+ ///
+ /// Renders the fallback glass with a static gradient and border.
+ ///
+ /// The drawing context provided by Avalonia.
+ /// The area to paint.
+ /// Corner radius applied to the background card.
+ public static void Render(DrawingContext context, Rect bounds, double radius)
+ {
+ if (bounds.Width <= 0 || bounds.Height <= 0)
+ {
+ return;
+ }
+
+ var rounded = new RoundedRect(bounds, new CornerRadius(radius));
+ context.DrawRectangle(BaseBrush, null, rounded);
+
+ var highlightRect = new Rect(bounds.X + 6, bounds.Y + 6, Math.Max(0, bounds.Width - 12), Math.Max(0, bounds.Height / 2));
+ var highlightRounded = new RoundedRect(highlightRect, new CornerRadius(Math.Max(0, radius - 6)));
+ context.DrawRectangle(HighlightBrush, null, highlightRounded);
+
+ context.DrawRectangle(null, BorderPen, rounded);
+ }
+}
diff --git a/FreshViewer/Effects/LiquidGlass/LiquidGlassRenderOperation.cs b/FreshViewer/Effects/LiquidGlass/LiquidGlassRenderOperation.cs
new file mode 100644
index 0000000..353ad8b
--- /dev/null
+++ b/FreshViewer/Effects/LiquidGlass/LiquidGlassRenderOperation.cs
@@ -0,0 +1,106 @@
+using System;
+using Avalonia;
+using Avalonia.Media;
+using Avalonia.Rendering.SceneGraph;
+using Avalonia.Platform;
+using Avalonia.Skia;
+using SkiaSharp;
+
+namespace FreshViewer.Effects.LiquidGlass;
+
+///
+/// Custom draw operation that feeds the Liquid Glass shader with the framebuffer snapshot.
+///
+internal sealed class LiquidGlassRenderOperation : ICustomDrawOperation
+{
+ private readonly Rect _bounds;
+ private readonly LiquidGlassRenderParameters _parameters;
+ private readonly SKRuntimeEffect _effect;
+
+ public LiquidGlassRenderOperation(Rect bounds, LiquidGlassRenderParameters parameters, SKRuntimeEffect effect)
+ {
+ _bounds = bounds;
+ _parameters = parameters;
+ _effect = effect;
+ }
+
+ public Rect Bounds => _bounds;
+
+ public void Dispose()
+ {
+ }
+
+ public bool Equals(ICustomDrawOperation? other) => ReferenceEquals(this, other);
+
+ public bool HitTest(Point p) => _bounds.Contains(p);
+
+ public void Render(ImmediateDrawingContext context)
+ {
+ var leaseFeature = context.TryGetFeature();
+ if (leaseFeature is null)
+ {
+ return;
+ }
+
+ using var lease = leaseFeature.Lease();
+ var canvas = lease.SkCanvas;
+ if (canvas is null || lease.SkSurface is null)
+ {
+ return;
+ }
+
+ using var snapshot = lease.SkSurface.Snapshot();
+ if (snapshot is null)
+ {
+ return;
+ }
+
+ if (!canvas.TotalMatrix.TryInvert(out var invertedMatrix))
+ {
+ return;
+ }
+
+ using var backgroundShader = SKShader.CreateImage(
+ snapshot,
+ SKShaderTileMode.Clamp,
+ SKShaderTileMode.Clamp,
+ invertedMatrix);
+
+ var uniforms = new SKRuntimeEffectUniforms(_effect)
+ {
+ ["resolution"] = new[] { (float)_bounds.Width, (float)_bounds.Height },
+ ["intensity"] = (float)_parameters.Intensity,
+ ["blurAmount"] = (float)_parameters.Blur,
+ ["chromaticAberration"] = (float)_parameters.ChromaticAberration,
+ ["saturation"] = (float)_parameters.Saturation
+ };
+
+ var children = new SKRuntimeEffectChildren(_effect)
+ {
+ ["backgroundTexture"] = backgroundShader
+ };
+
+ using var shader = _effect.ToShader(false, uniforms, children);
+ if (shader is null)
+ {
+ return;
+ }
+
+ using var paint = new SKPaint
+ {
+ Shader = shader,
+ IsAntialias = true
+ };
+
+ var rect = SKRect.Create(0, 0, (float)_bounds.Width, (float)_bounds.Height);
+ var cornerRadius = (float)Math.Clamp(_parameters.Radius, 0, Math.Min(_bounds.Width, _bounds.Height) * 0.5);
+
+ using var path = new SKPath();
+ path.AddRoundRect(rect, cornerRadius, cornerRadius);
+
+ canvas.Save();
+ canvas.ClipPath(path, SKClipOperation.Intersect, true);
+ canvas.DrawRect(rect, paint);
+ canvas.Restore();
+ }
+}
diff --git a/FreshViewer/Effects/LiquidGlass/LiquidGlassRenderer.cs b/FreshViewer/Effects/LiquidGlass/LiquidGlassRenderer.cs
new file mode 100644
index 0000000..20d2dc8
--- /dev/null
+++ b/FreshViewer/Effects/LiquidGlass/LiquidGlassRenderer.cs
@@ -0,0 +1,110 @@
+using System;
+using System.Diagnostics;
+using Avalonia;
+using Avalonia.Media;
+using Avalonia.Rendering.SceneGraph;
+using Avalonia.Skia;
+using SkiaSharp;
+
+namespace FreshViewer.Effects.LiquidGlass;
+
+///
+/// Provides the shader-backed rendering pipeline for the Liquid Glass decorator.
+///
+internal static class LiquidGlassRenderer
+{
+ private static readonly object SyncRoot = new();
+ private static SKRuntimeEffect? _cachedEffect;
+ private static bool _shaderLoadAttempted;
+
+ ///
+ /// Attempts to render the Liquid Glass shader. Returns true when the shader was executed.
+ ///
+ /// The drawing context provided by Avalonia.
+ /// The bounds of the decorator.
+ /// Uniform values controlling the shader.
+ public static bool TryRender(DrawingContext context, Rect bounds, in LiquidGlassRenderParameters parameters)
+ {
+ if (bounds.Width <= 0 || bounds.Height <= 0)
+ {
+ return false;
+ }
+
+ if (!LiquidGlassSupport.IsSupported)
+ {
+ LogOnce("Liquid Glass shader disabled because the platform is not supported.");
+ return false;
+ }
+
+ var effect = EnsureEffect();
+ if (effect is null)
+ {
+ return false;
+ }
+
+ context.Custom(new LiquidGlassRenderOperation(bounds, parameters, effect));
+ return true;
+ }
+
+ private static SKRuntimeEffect? EnsureEffect()
+ {
+ if (_cachedEffect is not null)
+ {
+ return _cachedEffect;
+ }
+
+ lock (SyncRoot)
+ {
+ if (_cachedEffect is not null || _shaderLoadAttempted)
+ {
+ return _cachedEffect;
+ }
+
+ _shaderLoadAttempted = true;
+
+ try
+ {
+ var assetUri = new Uri("avares://FreshViewer/Effects/LiquidGlass/Assets/Shaders/LiquidGlassShader.sksl");
+ using var stream = Avalonia.Platform.AssetLoader.Open(assetUri);
+ using var reader = new System.IO.StreamReader(stream);
+ var shaderCode = reader.ReadToEnd();
+
+ _cachedEffect = SKRuntimeEffect.Create(shaderCode, out var errorText);
+ if (_cachedEffect is null)
+ {
+ Debug.WriteLine($"LiquidGlass: failed to compile shader - {errorText}");
+ }
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"LiquidGlass: exception while loading shader - {ex.Message}");
+ _cachedEffect = null;
+ }
+
+ return _cachedEffect;
+ }
+ }
+
+ private static bool _loggedDiagnostic;
+
+ private static void LogOnce(string message)
+ {
+ if (_loggedDiagnostic)
+ {
+ return;
+ }
+
+ _loggedDiagnostic = true;
+ Debug.WriteLine($"LiquidGlass: {message} {LiquidGlassSupport.DiagnosticMessage}");
+ }
+}
+
+///
+/// Lightweight struct describing the uniforms used by the shader pipeline.
+///
+internal readonly record struct LiquidGlassRenderParameters(
+ double Radius,
+ double Intensity,
+ double Blur,
+ double ChromaticAberration,
+ double Saturation);
diff --git a/FreshViewer/Effects/LiquidGlass/LiquidGlassSupport.cs b/FreshViewer/Effects/LiquidGlass/LiquidGlassSupport.cs
new file mode 100644
index 0000000..90d72ba
--- /dev/null
+++ b/FreshViewer/Effects/LiquidGlass/LiquidGlassSupport.cs
@@ -0,0 +1,48 @@
+using System;
+using System.Diagnostics;
+
+namespace FreshViewer.Effects.LiquidGlass;
+
+///
+/// Provides environment checks and feature flags for the Liquid Glass renderer.
+///
+internal static class LiquidGlassSupport
+{
+ private const string EnvironmentSwitch = "FRESHVIEWER_FORCE_LIQUID_GLASS";
+
+ private static readonly Lazy CachedState = new(Evaluate, isThreadSafe: true);
+
+ ///
+ /// Gets a value indicating whether the advanced Liquid Glass effect can be used.
+ ///
+ public static bool IsSupported => CachedState.Value.IsSupported;
+
+ ///
+ /// Gets an optional diagnostic message describing why the effect is unavailable.
+ ///
+ public static string? DiagnosticMessage => CachedState.Value.Message;
+
+ private static SupportState Evaluate()
+ {
+ var overrideValue = Environment.GetEnvironmentVariable(EnvironmentSwitch);
+ if (!string.IsNullOrWhiteSpace(overrideValue))
+ {
+ if (bool.TryParse(overrideValue, out var parsed))
+ {
+ Debug.WriteLine($"LiquidGlass: support forced to {parsed} via environment switch.");
+ return parsed
+ ? new SupportState(true, "Forced on by environment variable.")
+ : new SupportState(false, "Forced off by environment variable.");
+ }
+ }
+
+ if (!OperatingSystem.IsWindows())
+ {
+ return new SupportState(false, "Liquid Glass requires the Windows compositor.");
+ }
+
+ return new SupportState(true, null);
+ }
+
+ private readonly record struct SupportState(bool IsSupported, string? Message);
+}
diff --git a/FreshViewer/FreshViewer.csproj b/FreshViewer/FreshViewer.csproj
new file mode 100644
index 0000000..6ce3968
--- /dev/null
+++ b/FreshViewer/FreshViewer.csproj
@@ -0,0 +1,38 @@
+
+
+ WinExe
+ net8.0-windows10.0.17763.0
+ win-x64
+ 10.0.17763.0
+ true
+ enable
+ enable
+ FreshViewer
+ FreshViewer
+ 0.9.0-alpha
+ Assets\AppIcon.ico
+ app.manifest
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/FreshViewer/Models/ImageMetadata.cs b/FreshViewer/Models/ImageMetadata.cs
new file mode 100644
index 0000000..169b947
--- /dev/null
+++ b/FreshViewer/Models/ImageMetadata.cs
@@ -0,0 +1,69 @@
+using System.Collections.Generic;
+
+namespace FreshViewer.Models;
+
+///
+/// Represents the metadata extracted from an image, grouped into sections.
+///
+public sealed class ImageMetadata
+{
+ public ImageMetadata(IReadOnlyList sections, IReadOnlyDictionary? raw = null)
+ {
+ Sections = sections;
+ Raw = raw;
+ }
+
+ ///
+ /// Gets the top-level metadata sections.
+ ///
+ public IReadOnlyList Sections { get; }
+
+ ///
+ /// Gets the raw key/value pairs when available.
+ ///
+ public IReadOnlyDictionary? Raw { get; }
+}
+
+///
+/// Represents a logical grouping of metadata items.
+///
+public sealed class MetadataSection
+{
+ public MetadataSection(string title, IReadOnlyList fields)
+ {
+ Title = title;
+ Fields = fields;
+ }
+
+ ///
+ /// Gets the section title displayed to the user.
+ ///
+ public string Title { get; }
+
+ ///
+ /// Gets the metadata items inside the section.
+ ///
+ public IReadOnlyList Fields { get; }
+}
+
+///
+/// Represents an individual metadata entry.
+///
+public sealed class MetadataField
+{
+ public MetadataField(string label, string? value)
+ {
+ Label = label;
+ Value = value;
+ }
+
+ ///
+ /// Gets the metadata label.
+ ///
+ public string Label { get; }
+
+ ///
+ /// Gets the metadata value.
+ ///
+ public string? Value { get; }
+}
diff --git a/FreshViewer/Program.cs b/FreshViewer/Program.cs
new file mode 100644
index 0000000..844dd2e
--- /dev/null
+++ b/FreshViewer/Program.cs
@@ -0,0 +1,31 @@
+using System;
+using Avalonia;
+
+namespace FreshViewer;
+
+///
+/// Entry point hosting Avalonia's desktop lifetime for FreshViewer.
+///
+internal static class Program
+{
+ [STAThread]
+ public static void Main(string[] args)
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ throw new PlatformNotSupportedException("FreshViewer is now available on Windows only.");
+ }
+
+ BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
+ }
+
+ ///
+ /// Creates and configures the Avalonia application builder.
+ ///
+ public static AppBuilder BuildAvaloniaApp()
+ => AppBuilder.Configure()
+ .UseWin32()
+ .UseSkia()
+ .WithInterFont()
+ .LogToTrace();
+}
diff --git a/FreshViewer/Services/ImageLoader.cs b/FreshViewer/Services/ImageLoader.cs
new file mode 100644
index 0000000..8b41145
--- /dev/null
+++ b/FreshViewer/Services/ImageLoader.cs
@@ -0,0 +1,339 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Runtime.InteropServices;
+using Avalonia;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform;
+using FreshViewer.Models;
+using ImageMagick;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.Formats.Gif;
+using SixLabors.ImageSharp.Formats.Png;
+using SixLabors.ImageSharp.PixelFormats;
+using SixLabors.ImageSharp.Processing;
+
+namespace FreshViewer.Services;
+
+///
+/// Loads still images and animations while extracting relevant metadata.
+///
+public sealed class ImageLoader
+{
+ private static readonly HashSet AnimatedExtensions = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ".gif", ".apng", ".mng"
+ };
+
+ private static readonly HashSet RawExtensions = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ".cr2", ".cr3", ".nef", ".arw", ".dng", ".raf", ".orf", ".rw2", ".pef", ".srw",
+ ".x3f", ".mrw", ".dcr", ".kdc", ".erf", ".mef", ".mos", ".ptx", ".r3d", ".fff", ".iiq"
+ };
+
+ private static readonly HashSet MagickStaticExtensions = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ".avif", ".heic", ".heif", ".psd", ".tga", ".svg", ".webp", ".hdr", ".exr", ".j2k", ".jp2", ".jpf"
+ };
+
+ ///
+ /// Loads an image from disk and returns the decoded bitmap or animation data.
+ ///
+ /// The file path to load.
+ /// A cancellation token controlling the asynchronous work.
+ public async Task LoadAsync(string path, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ throw new ArgumentException("Path is required", nameof(path));
+ }
+
+ var extension = Path.GetExtension(path);
+ if (RawExtensions.Contains(extension))
+ {
+ try
+ {
+ return await Task.Run(() => LoadRawInternal(path, cancellationToken), cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (MagickException)
+ {
+ // fallback to default pipeline
+ }
+ }
+
+ if (AnimatedExtensions.Contains(extension))
+ {
+ return await Task.Run(() => LoadAnimatedInternal(path, cancellationToken), cancellationToken).ConfigureAwait(false);
+ }
+
+ var preferMagick = MagickStaticExtensions.Contains(extension);
+
+ if (preferMagick)
+ {
+ try
+ {
+ return await Task.Run(() => LoadStaticWithMagick(path, cancellationToken), cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception) when (!cancellationToken.IsCancellationRequested)
+ {
+ // fallback below
+ }
+ }
+
+ return await Task.Run(() => LoadStaticInternal(path, extension, cancellationToken), cancellationToken).ConfigureAwait(false);
+ }
+
+ private static LoadedImage LoadStaticInternal(string path, string extension, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ try
+ {
+ using var stream = File.OpenRead(path);
+ var bitmap = new Bitmap(stream);
+ var metadata = MetadataBuilder.Build(path, bitmap.PixelSize, false, bitmap, null);
+ return new LoadedImage(path, bitmap, null, metadata);
+ }
+ catch (Exception) when (!cancellationToken.IsCancellationRequested)
+ {
+ if (!MagickStaticExtensions.Contains(extension))
+ {
+ throw;
+ }
+
+ return LoadStaticWithMagick(path, cancellationToken);
+ }
+ }
+
+ private static LoadedImage LoadAnimatedInternal(string path, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ using var image = Image.Load(path);
+
+ var frames = new List(image.Frames.Count);
+ for (var i = 0; i < image.Frames.Count; i++)
+ {
+ var frame = image.Frames[i];
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var frameMetadata = frame.Metadata.GetGifMetadata();
+ var frameDelay = frameMetadata?.FrameDelay ?? 10;
+ if (frameDelay <= 1)
+ {
+ frameDelay = 10;
+ }
+
+ using var clone = image.Frames.CloneFrame(i);
+ using var memoryStream = new MemoryStream();
+ clone.Metadata.ExifProfile?.RemoveValue(SixLabors.ImageSharp.Metadata.Profiles.Exif.ExifTag.Orientation);
+ clone.Save(memoryStream, new PngEncoder());
+ memoryStream.Position = 0;
+
+ var backingStream = new MemoryStream(memoryStream.ToArray());
+ var bitmap = new Bitmap(backingStream);
+ frames.Add(new AnimatedFrame(bitmap, backingStream, TimeSpan.FromMilliseconds(frameDelay * 10)));
+ }
+
+ var gifMetadata = image.Metadata.GetGifMetadata();
+ var loopCount = gifMetadata?.RepeatCount ?? 0;
+ var animated = new AnimatedImage(frames, loopCount);
+ Bitmap? sampleFrame = frames.Count > 0 ? frames[0].Bitmap : null;
+ var builtMetadata = MetadataBuilder.Build(path, animated.PixelSize, true, sampleFrame, animated);
+ return new LoadedImage(path, null, animated, builtMetadata);
+ }
+
+ private static LoadedImage LoadRawInternal(string path, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var settings = new MagickReadSettings
+ {
+ ColorSpace = ColorSpace.sRGB,
+ Depth = 8
+ };
+
+ using var magickImage = new MagickImage(path, settings);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ magickImage.AutoOrient();
+ magickImage.ColorSpace = ColorSpace.sRGB;
+ magickImage.Depth = 8;
+ return ConvertMagickImage(path, magickImage, cancellationToken);
+ }
+
+ private static LoadedImage LoadStaticWithMagick(string path, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ using var magickImage = new MagickImage(path);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ magickImage.AutoOrient();
+ magickImage.ColorSpace = ColorSpace.sRGB;
+ magickImage.Depth = 8;
+
+ return ConvertMagickImage(path, magickImage, cancellationToken);
+ }
+
+ private static LoadedImage ConvertMagickImage(string path, MagickImage magickImage, CancellationToken cancellationToken)
+ {
+ var width = (int)magickImage.Width;
+ var height = (int)magickImage.Height;
+ var pixelBytes = magickImage.ToByteArray(MagickFormat.Bgra);
+
+ var pixelSize = new PixelSize(width, height);
+ var density = magickImage.Density;
+ var dpi = density.X > 0 && density.Y > 0
+ ? new Vector(density.X, density.Y)
+ : new Vector(96, 96);
+
+ var writeable = new WriteableBitmap(pixelSize, dpi, PixelFormat.Bgra8888, AlphaFormat.Premul);
+ using (var frame = writeable.Lock())
+ {
+ var stride = frame.RowBytes;
+ var sourceStride = width * 4;
+
+ for (var y = 0; y < height; y++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var destRow = IntPtr.Add(frame.Address, y * stride);
+ Marshal.Copy(pixelBytes, y * sourceStride, destRow, sourceStride);
+ }
+ }
+
+ var metadata = MetadataBuilder.Build(path, writeable.PixelSize, false, writeable, null);
+ return new LoadedImage(path, writeable, null, metadata);
+ }
+}
+
+///
+/// Represents a decoded image together with optional animation metadata.
+///
+public sealed class LoadedImage : IDisposable
+{
+ public LoadedImage(string path, Bitmap? bitmap, AnimatedImage? animated, ImageMetadata? metadata)
+ {
+ Path = path;
+ Bitmap = bitmap;
+ Animation = animated;
+ Metadata = metadata;
+ }
+
+ ///
+ /// Gets the original file path.
+ ///
+ public string Path { get; }
+
+ ///
+ /// Gets the decoded bitmap when the image is static.
+ ///
+ public Bitmap? Bitmap { get; }
+
+ ///
+ /// Gets the animation sequence when the image contains multiple frames.
+ ///
+ public AnimatedImage? Animation { get; }
+
+ ///
+ /// Gets extracted metadata associated with the image.
+ ///
+ public ImageMetadata? Metadata { get; }
+
+ ///
+ /// Gets a value indicating whether the image is animated.
+ ///
+ public bool IsAnimated => Animation is not null;
+
+ ///
+ /// Gets the pixel size of the image or animation.
+ ///
+ public PixelSize PixelSize
+ => Animation?.PixelSize ?? Bitmap?.PixelSize ?? PixelSize.Empty;
+
+ ///
+ /// Releases bitmap resources.
+ ///
+ public void Dispose()
+ {
+ Bitmap?.Dispose();
+ Animation?.Dispose();
+ }
+}
+
+///
+/// Describes an animated image composed of individual frames and loop count.
+///
+public sealed class AnimatedImage : IDisposable
+{
+ public AnimatedImage(IReadOnlyList frames, int loopCount)
+ {
+ Frames = frames;
+ LoopCount = loopCount;
+ PixelSize = frames.Count > 0 ? frames[0].Bitmap.PixelSize : PixelSize.Empty;
+ }
+
+ ///
+ /// Gets the frames that compose the animation.
+ ///
+ public IReadOnlyList Frames { get; }
+
+ ///
+ /// Gets the loop count reported by the file (0 indicates infinite looping).
+ ///
+ public int LoopCount { get; }
+
+ ///
+ /// Gets the pixel size of the animation.
+ ///
+ public PixelSize PixelSize { get; }
+
+ ///
+ /// Releases frame resources.
+ ///
+ public void Dispose()
+ {
+ foreach (var frame in Frames)
+ {
+ frame.Dispose();
+ }
+ }
+}
+
+///
+/// Represents a single frame in an animated image.
+///
+public sealed class AnimatedFrame : IDisposable
+{
+ public AnimatedFrame(Bitmap bitmap, MemoryStream backingStream, TimeSpan duration)
+ {
+ Bitmap = bitmap;
+ BackingStream = backingStream;
+ Duration = duration;
+ }
+
+ ///
+ /// Gets the bitmap representing the frame contents.
+ ///
+ public Bitmap Bitmap { get; }
+
+ private MemoryStream BackingStream { get; }
+
+ ///
+ /// Gets the time each frame should remain visible.
+ ///
+ public TimeSpan Duration { get; }
+
+ ///
+ /// Releases bitmap and memory stream resources.
+ ///
+ public void Dispose()
+ {
+ Bitmap.Dispose();
+ BackingStream.Dispose();
+ }
+}
diff --git a/FreshViewer/Services/LocalizationService.cs b/FreshViewer/Services/LocalizationService.cs
new file mode 100644
index 0000000..0e5f205
--- /dev/null
+++ b/FreshViewer/Services/LocalizationService.cs
@@ -0,0 +1,72 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace FreshViewer.Services;
+
+///
+/// Provides a very small culture switcher by loading prebuilt resource dictionaries.
+/// The implementation keeps the default synchronized with the selected language.
+///
+public static class LocalizationService
+{
+ private static readonly Dictionary LanguageToCulture = new()
+ {
+ ["Русский"] = "ru-RU",
+ ["English"] = "en-US",
+ ["Українська"] = "uk-UA",
+ ["Deutsch"] = "de-DE"
+ };
+
+ ///
+ /// Applies the requested UI language by loading a corresponding resource dictionary.
+ ///
+ public static void ApplyLanguage(string languageName)
+ {
+ if (!LanguageToCulture.TryGetValue(languageName, out var culture))
+ {
+ culture = "ru-RU";
+ }
+
+ var ci = new CultureInfo(culture);
+ CultureInfo.CurrentCulture = ci;
+ CultureInfo.CurrentUICulture = ci;
+
+ var app = Application.Current;
+ if (app is null)
+ {
+ return;
+ }
+
+ var uri = culture switch
+ {
+ "en-US" => new Uri("avares://FreshViewer/Assets/i18n/Strings.en.axaml"),
+ "uk-UA" => new Uri("avares://FreshViewer/Assets/i18n/Strings.uk.axaml"),
+ "de-DE" => new Uri("avares://FreshViewer/Assets/i18n/Strings.de.axaml"),
+ _ => new Uri("avares://FreshViewer/Assets/i18n/Strings.ru.axaml")
+ };
+
+ var dict = AvaloniaXamlLoader.Load(uri) as ResourceDictionary;
+ if (dict is null)
+ {
+ return;
+ }
+
+ // Remove previous string dictionaries so only one language is active at a time.
+ for (var i = app.Resources.MergedDictionaries.Count - 1; i >= 0; i--)
+ {
+ if (app.Resources.MergedDictionaries[i] is ResourceDictionary existing
+ && existing.ContainsKey("Strings.Back"))
+ {
+ app.Resources.MergedDictionaries.RemoveAt(i);
+ }
+ }
+
+ app.Resources.MergedDictionaries.Add(dict);
+ }
+}
+
+
diff --git a/FreshViewer/Services/MetadataBuilder.cs b/FreshViewer/Services/MetadataBuilder.cs
new file mode 100644
index 0000000..8ebf272
--- /dev/null
+++ b/FreshViewer/Services/MetadataBuilder.cs
@@ -0,0 +1,234 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using Avalonia;
+using Avalonia.Media.Imaging;
+using FreshViewer.Models;
+using MetadataExtractor;
+using MetadataExtractor.Formats.Exif;
+
+namespace FreshViewer.Services;
+
+internal static class MetadataBuilder
+{
+ public static ImageMetadata? Build(string path, PixelSize pixelSize, bool isAnimated, Bitmap? bitmap, AnimatedImage? animation)
+ {
+ IReadOnlyList directories;
+ try
+ {
+ directories = ImageMetadataReader.ReadMetadata(path).ToList();
+ }
+ catch
+ {
+ directories = Array.Empty();
+ }
+
+ var sections = new List();
+
+ sections.Add(BuildGeneralSection(path, pixelSize, bitmap));
+
+ var capture = BuildCaptureSection(directories);
+ if (capture is not null)
+ {
+ sections.Add(capture);
+ }
+
+ var color = BuildColorSection(directories);
+ if (color is not null)
+ {
+ sections.Add(color);
+ }
+
+ if (isAnimated)
+ {
+ var animationSection = BuildAnimationSection(animation);
+ if (animationSection is not null)
+ {
+ sections.Add(animationSection);
+ }
+ }
+
+ var advanced = BuildAdvancedSection(directories);
+ if (advanced is not null)
+ {
+ sections.Add(advanced);
+ }
+
+ sections.RemoveAll(static section => section.Fields.Count == 0);
+ if (sections.Count == 0)
+ {
+ return null;
+ }
+
+ var flattened = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var field in sections.SelectMany(static section => section.Fields))
+ {
+ flattened[field.Label] = field.Value;
+ }
+
+ return new ImageMetadata(sections, flattened);
+ }
+
+ private static MetadataSection BuildGeneralSection(string path, PixelSize pixelSize, Bitmap? bitmap)
+ {
+ var fields = new List();
+ var fileInfo = new FileInfo(path);
+
+ AddField(fields, "Имя файла", fileInfo.Name);
+ AddField(fields, "Тип", fileInfo.Extension.TrimStart('.').ToUpperInvariant());
+ AddField(fields, "Размер", FormatFileSize(fileInfo.Length));
+ AddField(fields, "Папка", fileInfo.DirectoryName);
+ AddField(fields, "Создан", FormatDate(fileInfo.CreationTime));
+ AddField(fields, "Изменён", FormatDate(fileInfo.LastWriteTime));
+
+ if (pixelSize.Width > 0 && pixelSize.Height > 0)
+ {
+ AddField(fields, "Разрешение", $"{pixelSize.Width} × {pixelSize.Height}");
+ AddField(fields, "Мегапиксели", FormatMegapixels(pixelSize.Width, pixelSize.Height));
+ AddField(fields, "Соотношение", FormatAspectRatio(pixelSize.Width, pixelSize.Height));
+ }
+
+ if (bitmap is not null && bitmap.Dpi.X > 0 && bitmap.Dpi.Y > 0)
+ {
+ AddField(fields, "DPI", string.Format(CultureInfo.InvariantCulture, "{0:0.#} × {1:0.#}", bitmap.Dpi.X, bitmap.Dpi.Y));
+ }
+
+ return new MetadataSection("Общее", fields);
+ }
+
+ private static MetadataSection? BuildCaptureSection(IReadOnlyList directories)
+ {
+ var exifIfd0 = directories.OfType().FirstOrDefault();
+ var exifSubIfd = directories.OfType().FirstOrDefault();
+
+ var fields = new List();
+ AddField(fields, "Производитель", exifIfd0?.GetDescription(ExifDirectoryBase.TagMake));
+ AddField(fields, "Камера", exifIfd0?.GetDescription(ExifDirectoryBase.TagModel));
+ AddField(fields, "Дата съёмки", exifSubIfd?.GetDescription(ExifDirectoryBase.TagDateTimeOriginal));
+ AddField(fields, "Выдержка", exifSubIfd?.GetDescription(ExifDirectoryBase.TagExposureTime));
+ AddField(fields, "Диафрагма", exifSubIfd?.GetDescription(ExifDirectoryBase.TagFNumber));
+ AddField(fields, "ISO", exifSubIfd?.GetDescription(ExifDirectoryBase.TagIsoEquivalent));
+ AddField(fields, "Фокусное расстояние", exifSubIfd?.GetDescription(ExifDirectoryBase.TagFocalLength));
+ AddField(fields, "Экспозиция", exifSubIfd?.GetDescription(ExifDirectoryBase.TagExposureBias));
+ AddField(fields, "Баланс белого", exifSubIfd?.GetDescription(ExifDirectoryBase.TagWhiteBalance));
+ AddField(fields, "Вспышка", exifSubIfd?.GetDescription(ExifDirectoryBase.TagFlash));
+ AddField(fields, "Объектив", exifSubIfd?.GetDescription(ExifDirectoryBase.TagLensModel));
+
+ return fields.Count == 0 ? null : new MetadataSection("Параметры съёмки", fields);
+ }
+
+ private static MetadataSection? BuildColorSection(IReadOnlyList directories)
+ {
+ var exifSubIfd = directories.OfType().FirstOrDefault();
+ if (exifSubIfd is null)
+ {
+ return null;
+ }
+
+ var fields = new List();
+ AddField(fields, "Цветовое пространство", exifSubIfd.GetDescription(ExifDirectoryBase.TagColorSpace));
+ AddField(fields, "Профиль", exifSubIfd.GetDescription(ExifDirectoryBase.TagPhotometricInterpretation));
+ AddField(fields, "Биты на канал", exifSubIfd.GetDescription(ExifDirectoryBase.TagBitsPerSample));
+ AddField(fields, "Число каналов", exifSubIfd.GetDescription(ExifDirectoryBase.TagSamplesPerPixel));
+
+
+ return fields.Count == 0 ? null : new MetadataSection("Цвет", fields);
+ }
+
+ private static MetadataSection? BuildAnimationSection(AnimatedImage? animation)
+ {
+ if (animation is null)
+ {
+ return null;
+ }
+
+ var fields = new List();
+ AddField(fields, "Кадров", animation.Frames.Count.ToString(CultureInfo.InvariantCulture));
+ AddField(fields, "Повторений", animation.LoopCount > 0
+ ? animation.LoopCount.ToString(CultureInfo.InvariantCulture)
+ : "Бесконечно");
+
+ var totalDuration = TimeSpan.Zero;
+ foreach (var frame in animation.Frames)
+ {
+ totalDuration += frame.Duration;
+ }
+
+ if (totalDuration > TimeSpan.Zero)
+ {
+ AddField(fields, "Длительность", totalDuration.ToString());
+ }
+
+ return fields.Count == 0 ? null : new MetadataSection("Анимация", fields);
+ }
+
+ private static MetadataSection? BuildAdvancedSection(IReadOnlyList directories)
+ {
+ var exifIfd0 = directories.OfType().FirstOrDefault();
+ var exifSubIfd = directories.OfType().FirstOrDefault();
+
+ var fields = new List();
+ AddField(fields, "Ориентация", exifIfd0?.GetDescription(ExifDirectoryBase.TagOrientation));
+ AddField(fields, "Программа", exifIfd0?.GetDescription(ExifDirectoryBase.TagSoftware));
+ AddField(fields, "Авторские права", exifIfd0?.GetDescription(ExifDirectoryBase.TagCopyright));
+ AddField(fields, "Метод измерения", exifSubIfd?.GetDescription(ExifDirectoryBase.TagMeteringMode));
+ AddField(fields, "Тип экспозиции", exifSubIfd?.GetDescription(ExifDirectoryBase.TagExposureProgram));
+
+ return fields.Count == 0 ? null : new MetadataSection("Дополнительно", fields);
+ }
+
+ private static void AddField(ICollection fields, string label, string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return;
+ }
+
+ fields.Add(new MetadataField(label, value.Trim()));
+ }
+
+ private static string FormatAspectRatio(int width, int height)
+ {
+ var gcd = GreatestCommonDivisor(width, height);
+ var ratio = $"{width / gcd}:{height / gcd}";
+ var numeric = width / (double)height;
+ return string.Format(CultureInfo.InvariantCulture, "{0} ({1:0.##}:1)", ratio, numeric);
+ }
+
+ private static string FormatMegapixels(int width, int height)
+ {
+ var mp = (width * (double)height) / 1_000_000d;
+ return mp >= 0.05 ? mp.ToString("0.00 \\MP", CultureInfo.InvariantCulture) : "<0.05 MP";
+ }
+
+ private static string FormatFileSize(long bytes)
+ {
+ string[] suffixes = { "Б", "КБ", "МБ", "ГБ", "ТБ" };
+ double size = bytes;
+ var order = 0;
+ while (size >= 1024 && order < suffixes.Length - 1)
+ {
+ order++;
+ size /= 1024;
+ }
+
+ return string.Format(CultureInfo.InvariantCulture, "{0:0.##} {1}", size, suffixes[order]);
+ }
+
+ private static string FormatDate(DateTime date)
+ => date == DateTime.MinValue
+ ? "—"
+ : date.ToLocalTime().ToString("dd.MM.yyyy HH:mm:ss", CultureInfo.CurrentCulture);
+
+ private static int GreatestCommonDivisor(int a, int b)
+ {
+ while (b != 0)
+ {
+ (a, b) = (b, a % b);
+ }
+
+ return Math.Abs(a);
+ }
+}
diff --git a/FreshViewer/Services/ShortcutManager.cs b/FreshViewer/Services/ShortcutManager.cs
new file mode 100644
index 0000000..b6ffd2a
--- /dev/null
+++ b/FreshViewer/Services/ShortcutManager.cs
@@ -0,0 +1,287 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+using System.Threading.Tasks;
+using Avalonia.Input;
+
+namespace FreshViewer.Services;
+
+///
+/// Manages keyboard shortcut profiles and handles import/export of user mappings.
+///
+public sealed class ShortcutManager
+{
+ private readonly Dictionary> _actionToCombos = new();
+
+ ///
+ /// Initializes the manager with the default profile.
+ ///
+ public ShortcutManager()
+ {
+ ResetToProfile("Стандартный");
+ }
+
+ ///
+ /// Gets the available built-in profile names.
+ ///
+ public IReadOnlyList Profiles { get; } = new[] { "Стандартный", "Photoshop", "Lightroom" };
+
+ ///
+ /// Replaces the current shortcuts with the mappings defined by the specified profile.
+ ///
+ public void ResetToProfile(string profileName)
+ {
+ _actionToCombos.Clear();
+
+ switch (profileName)
+ {
+ case "Photoshop":
+ ApplyStandardBase();
+ // Add Lightroom-style rotation shortcuts: Ctrl+[ and Ctrl+].
+ Set(ShortcutAction.RotateCounterClockwise, new KeyCombo(Key.Oem4, KeyModifiers.Control));
+ Set(ShortcutAction.RotateClockwise, new KeyCombo(Key.Oem6, KeyModifiers.Control));
+ break;
+ case "Стандартный":
+ default:
+ ApplyStandardBase();
+ break;
+ }
+ }
+
+ private void ApplyStandardBase()
+ {
+ // Navigation
+ Set(ShortcutAction.Previous, new KeyCombo(Key.Left), new KeyCombo(Key.A));
+ Set(ShortcutAction.Next, new KeyCombo(Key.Right), new KeyCombo(Key.D));
+
+ // View manipulation
+ Set(ShortcutAction.Fit, new KeyCombo(Key.Space), new KeyCombo(Key.F));
+ Set(ShortcutAction.ZoomIn, new KeyCombo(Key.OemPlus), new KeyCombo(Key.Add));
+ Set(ShortcutAction.ZoomOut, new KeyCombo(Key.OemMinus), new KeyCombo(Key.Subtract));
+ Set(ShortcutAction.RotateClockwise, new KeyCombo(Key.R));
+ Set(ShortcutAction.RotateCounterClockwise, new KeyCombo(Key.L));
+
+ // Window/UI
+ Set(ShortcutAction.Fullscreen, new KeyCombo(Key.F11));
+ Set(ShortcutAction.ToggleUi, new KeyCombo(Key.Q));
+ Set(ShortcutAction.ToggleInfo, new KeyCombo(Key.I));
+ Set(ShortcutAction.ToggleSettings, new KeyCombo(Key.P));
+
+ // File/clipboard
+ Set(ShortcutAction.OpenFile, new KeyCombo(Key.O, KeyModifiers.Control));
+ Set(ShortcutAction.CopyFrame, new KeyCombo(Key.C, KeyModifiers.Control));
+ }
+
+ private void Set(ShortcutAction action, params KeyCombo[] combos)
+ {
+ _actionToCombos[action] = combos.ToList();
+ }
+
+ ///
+ /// Attempts to match the supplied key event to a registered shortcut action.
+ ///
+ /// The key event to inspect.
+ /// Receives the matched action when the method returns true.
+ public bool TryMatch(KeyEventArgs e, out ShortcutAction action)
+ {
+ foreach (var pair in _actionToCombos)
+ {
+ foreach (var combo in pair.Value)
+ {
+ if (combo.Matches(e))
+ {
+ action = pair.Key;
+ return true;
+ }
+ }
+ }
+
+ action = default;
+ return false;
+ }
+
+ ///
+ /// Exports the current shortcut mappings to a JSON string.
+ ///
+ public async Task ExportToJsonAsync()
+ {
+ var export = _actionToCombos.ToDictionary(
+ p => p.Key.ToString(),
+ p => p.Value.Select(KeyComboFormat.Format).ToArray());
+
+ var options = new JsonSerializerOptions { WriteIndented = true };
+ return await Task.Run(() => JsonSerializer.Serialize(export, options));
+ }
+
+ ///
+ /// Replaces existing shortcuts with mappings provided in a JSON payload.
+ ///
+ public void ImportFromJson(string json)
+ {
+ var doc = JsonSerializer.Deserialize>(json);
+ if (doc is null)
+ {
+ return;
+ }
+
+ _actionToCombos.Clear();
+ foreach (var (actionName, combos) in doc)
+ {
+ if (!Enum.TryParse(actionName, ignoreCase: true, out var action))
+ {
+ continue;
+ }
+
+ var parsed = combos.Select(KeyComboFormat.Parse).Where(static c => c is not null).Cast().ToList();
+ if (parsed.Count > 0)
+ {
+ _actionToCombos[action] = parsed;
+ }
+ }
+ }
+}
+
+///
+/// Represents actions invoked by keyboard shortcuts in the viewer.
+///
+public enum ShortcutAction
+{
+ Previous,
+ Next,
+ Fit,
+ ZoomIn,
+ ZoomOut,
+ RotateClockwise,
+ RotateCounterClockwise,
+ Fullscreen,
+ ToggleUi,
+ ToggleInfo,
+ ToggleSettings,
+ OpenFile,
+ CopyFrame
+}
+
+///
+/// Represents a single key with optional modifier mask.
+///
+public readonly struct KeyCombo
+{
+ public KeyCombo(Key key, KeyModifiers modifiers = KeyModifiers.None)
+ {
+ Key = key;
+ Modifiers = modifiers;
+ }
+
+ ///
+ /// Gets the primary key.
+ ///
+ public Key Key { get; }
+ ///
+ /// Gets the associated modifier mask.
+ ///
+ public KeyModifiers Modifiers { get; }
+
+ ///
+ /// Determines whether the supplied event matches the combo.
+ ///
+ public bool Matches(KeyEventArgs e)
+ {
+ if (e.Key != Key)
+ {
+ return false;
+ }
+
+ // Normalize modifiers – lock keys should not affect matching.
+ var mods = e.KeyModifiers & (KeyModifiers.Control | KeyModifiers.Shift | KeyModifiers.Alt | KeyModifiers.Meta);
+ return mods == Modifiers;
+ }
+}
+
+internal static class KeyComboFormat
+{
+ ///
+ /// Converts a shortcut combo to a human readable representation (e.g. Ctrl+O).
+ ///
+ public static string Format(KeyCombo combo)
+ {
+ var parts = new List();
+ if (combo.Modifiers.HasFlag(KeyModifiers.Control)) parts.Add("Ctrl");
+ if (combo.Modifiers.HasFlag(KeyModifiers.Shift)) parts.Add("Shift");
+ if (combo.Modifiers.HasFlag(KeyModifiers.Alt)) parts.Add("Alt");
+ if (combo.Modifiers.HasFlag(KeyModifiers.Meta)) parts.Add("Meta");
+
+ parts.Add(KeyToString(combo.Key));
+ return string.Join('+', parts);
+ }
+
+ ///
+ /// Parses a human readable shortcut combination back into a .
+ ///
+ public static KeyCombo? Parse(string? text)
+ {
+ if (string.IsNullOrWhiteSpace(text))
+ {
+ return null;
+ }
+
+ var parts = text.Split('+', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ var mods = KeyModifiers.None;
+ Key? key = null;
+
+ foreach (var p in parts)
+ {
+ switch (p.ToLowerInvariant())
+ {
+ case "ctrl":
+ case "control":
+ mods |= KeyModifiers.Control; break;
+ case "shift":
+ mods |= KeyModifiers.Shift; break;
+ case "alt":
+ mods |= KeyModifiers.Alt; break;
+ case "meta":
+ case "win":
+ mods |= KeyModifiers.Meta; break;
+ default:
+ key = StringToKey(p);
+ break;
+ }
+ }
+
+ if (key is null)
+ {
+ return null;
+ }
+
+ return new KeyCombo(key.Value, mods);
+ }
+
+ private static string KeyToString(Key key)
+ {
+ return key switch
+ {
+ Key.OemPlus => "+",
+ Key.Add => "+",
+ Key.OemMinus => "-",
+ Key.Subtract => "-",
+ Key.Oem4 => "[",
+ Key.Oem6 => "]",
+ _ => key.ToString()
+ };
+ }
+
+ private static Key? StringToKey(string s)
+ {
+ return s switch
+ {
+ "+" or "plus" => Key.OemPlus,
+ "-" or "minus" => Key.OemMinus,
+ "[" => Key.Oem4,
+ "]" => Key.Oem6,
+ _ => Enum.TryParse(s, true, out var k) ? k : null
+ };
+ }
+}
+
+
diff --git a/FreshViewer/Services/ThemeManager.cs b/FreshViewer/Services/ThemeManager.cs
new file mode 100644
index 0000000..7204445
--- /dev/null
+++ b/FreshViewer/Services/ThemeManager.cs
@@ -0,0 +1,51 @@
+using System;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace FreshViewer.Services;
+
+///
+/// Loads the themed resource dictionaries that provide the Liquid Glass color palettes.
+///
+public static class ThemeManager
+{
+ ///
+ /// Applies a theme by name by replacing the Liquid Glass resource dictionaries.
+ ///
+ public static void Apply(string themeName)
+ {
+ var app = Application.Current;
+ if (app is null)
+ {
+ return;
+ }
+
+ var uri = themeName switch
+ {
+ "Midnight Flow" => new Uri("avares://FreshViewer/Styles/LiquidGlass.Windows.axaml"),
+ "Frosted Steel" => new Uri("avares://FreshViewer/Styles/LiquidGlass.Windows.axaml"),
+ _ => new Uri("avares://FreshViewer/Styles/LiquidGlass.axaml")
+ };
+
+ var dict = AvaloniaXamlLoader.Load(uri) as ResourceDictionary;
+ if (dict is null)
+ {
+ return;
+ }
+
+ // Remove previous Liquid Glass dictionaries before adding the new palette.
+ for (var i = app.Resources.MergedDictionaries.Count - 1; i >= 0; i--)
+ {
+ if (app.Resources.MergedDictionaries[i] is ResourceDictionary existing
+ && existing.ContainsKey("LiquidGlass.WindowBackground"))
+ {
+ app.Resources.MergedDictionaries.RemoveAt(i);
+ }
+ }
+
+ app.Resources.MergedDictionaries.Add(dict);
+ }
+}
+
+
diff --git a/FreshViewer/Styles/LiquidGlass.Windows.axaml b/FreshViewer/Styles/LiquidGlass.Windows.axaml
new file mode 100644
index 0000000..6b2ce87
--- /dev/null
+++ b/FreshViewer/Styles/LiquidGlass.Windows.axaml
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 52
+ 0.26
+ 4.4
+ 26
+ 26
+
+ 44
+ 0.2
+ 3.6
+ 30
+ 30
+
+ 52
+ 0.26
+ 5.8
+ 34
+ 34
+
diff --git a/FreshViewer/Styles/LiquidGlass.axaml b/FreshViewer/Styles/LiquidGlass.axaml
new file mode 100644
index 0000000..f317f22
--- /dev/null
+++ b/FreshViewer/Styles/LiquidGlass.axaml
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 46
+ 0.24
+ 4.8
+ 24
+ 24
+
+ 34
+ 0.18
+ 3.4
+ 26
+ 26
+
+ 48
+ 0.26
+ 5.6
+ 32
+ 32
+
+ 28
+
diff --git a/FreshViewer/ViewModels/ImageMetadataViewModels.cs b/FreshViewer/ViewModels/ImageMetadataViewModels.cs
new file mode 100644
index 0000000..683d8fd
--- /dev/null
+++ b/FreshViewer/ViewModels/ImageMetadataViewModels.cs
@@ -0,0 +1,50 @@
+using System.Collections.Generic;
+using System.Linq;
+using FreshViewer.Models;
+
+namespace FreshViewer.ViewModels;
+
+///
+/// Represents a metadata item adapted for binding.
+///
+public sealed record MetadataItemViewModel(string Label, string? Value)
+{
+ ///
+ /// Gets the value formatted for display, returning an em dash when empty.
+ ///
+ public string DisplayValue => string.IsNullOrWhiteSpace(Value) ? "—" : Value;
+}
+
+///
+/// Represents a metadata section adapted for binding.
+///
+public sealed record MetadataSectionViewModel(string Title, IReadOnlyList Items)
+{
+ ///
+ /// Creates a view model from the domain model.
+ ///
+ public static MetadataSectionViewModel FromModel(MetadataSection section)
+ => new(section.Title, section.Fields.Select(f => new MetadataItemViewModel(f.Label, f.Value)).ToList());
+}
+
+///
+/// Builds view model representations from the metadata domain model.
+///
+public static class MetadataViewModelFactory
+{
+ ///
+ /// Converts to a list of section view models.
+ ///
+ public static IReadOnlyList? Create(ImageMetadata? metadata)
+ {
+ if (metadata is null)
+ {
+ return null;
+ }
+
+ return metadata.Sections
+ .Where(section => section.Fields.Any())
+ .Select(MetadataSectionViewModel.FromModel)
+ .ToList();
+ }
+}
diff --git a/FreshViewer/ViewModels/MainWindowViewModel.cs b/FreshViewer/ViewModels/MainWindowViewModel.cs
new file mode 100644
index 0000000..e593be4
--- /dev/null
+++ b/FreshViewer/ViewModels/MainWindowViewModel.cs
@@ -0,0 +1,394 @@
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using FreshViewer.Models;
+
+namespace FreshViewer.ViewModels;
+
+///
+/// Exposes the UI state for the main window, including metadata and UI toggle flags.
+///
+public sealed class MainWindowViewModel : INotifyPropertyChanged
+{
+ private string? _fileName;
+ private string? _resolutionText;
+ private string _statusText = "Откройте изображение";
+ private bool _isMetadataVisible;
+ private bool _isUiVisible = true;
+ private bool _isMetadataCardVisible;
+ private bool _isInfoPanelVisible;
+ private IReadOnlyList? _metadataSections;
+ private IReadOnlyList? _summaryItems;
+ private bool _hasSummaryItems;
+ private bool _hasMetadataDetails;
+ private bool _showMetadataPlaceholder = true;
+ private bool _isErrorVisible;
+ private string? _errorTitle;
+ private string? _errorDescription;
+ private bool _isSettingsPanelVisible;
+ private string? _galleryPositionText;
+ private string _selectedTheme = "Liquid Dawn";
+ private string _selectedLanguage = "Русский";
+ private string _selectedShortcutProfile = "Стандартный";
+ private bool _enableLiquidGlass = true;
+ private bool _enableAmbientAnimations = true;
+
+ ///
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ ///
+ /// Gets or sets the name of the currently opened file.
+ ///
+ public string? FileName
+ {
+ get => _fileName;
+ set => SetField(ref _fileName, value);
+ }
+
+ ///
+ /// Gets or sets the resolution label shown to the user.
+ ///
+ public string? ResolutionText
+ {
+ get => _resolutionText;
+ set => SetField(ref _resolutionText, value);
+ }
+
+ ///
+ /// Gets or sets the status message displayed in the footer panel.
+ ///
+ public string StatusText
+ {
+ get => _statusText;
+ set => SetField(ref _statusText, value);
+ }
+
+ ///
+ /// Gets or sets a value indicating whether metadata should be visible.
+ ///
+ public bool IsMetadataVisible
+ {
+ get => _isMetadataVisible;
+ set
+ {
+ if (SetField(ref _isMetadataVisible, value))
+ {
+ UpdateMetadataCardVisibility();
+ }
+ }
+ }
+
+ ///
+ /// Gets or sets a value indicating whether the main UI chrome is visible.
+ ///
+ public bool IsUiVisible
+ {
+ get => _isUiVisible;
+ set
+ {
+ if (SetField(ref _isUiVisible, value))
+ {
+ UpdateMetadataCardVisibility();
+
+ if (!value)
+ {
+ if (IsInfoPanelVisible)
+ {
+ IsInfoPanelVisible = false;
+ }
+
+ if (IsSettingsPanelVisible)
+ {
+ IsSettingsPanelVisible = false;
+ }
+ }
+ }
+ }
+ }
+
+ ///
+ /// Gets a value indicating whether the summary metadata card should be shown.
+ ///
+ public bool IsMetadataCardVisible
+ {
+ get => _isMetadataCardVisible;
+ private set => SetField(ref _isMetadataCardVisible, value);
+ }
+
+ ///
+ /// Gets or sets a value indicating whether the detailed metadata panel is open.
+ ///
+ public bool IsInfoPanelVisible
+ {
+ get => _isInfoPanelVisible;
+ set
+ {
+ if (SetField(ref _isInfoPanelVisible, value) && value)
+ {
+ if (IsSettingsPanelVisible)
+ {
+ IsSettingsPanelVisible = false;
+ }
+ }
+ }
+ }
+
+ ///
+ /// Gets or sets a value indicating whether the settings panel is open.
+ ///
+ public bool IsSettingsPanelVisible
+ {
+ get => _isSettingsPanelVisible;
+ set
+ {
+ if (SetField(ref _isSettingsPanelVisible, value) && value)
+ {
+ if (IsInfoPanelVisible)
+ {
+ IsInfoPanelVisible = false;
+ }
+ }
+ }
+ }
+
+ ///
+ /// Gets or sets the metadata sections displayed in the detail panel.
+ ///
+ public IReadOnlyList? MetadataSections
+ {
+ get => _metadataSections;
+ set => SetField(ref _metadataSections, value);
+ }
+
+ ///
+ /// Gets the summary metadata items shown in the floating card.
+ ///
+ public IReadOnlyList? SummaryItems
+ {
+ get => _summaryItems;
+ private set
+ {
+ if (SetField(ref _summaryItems, value))
+ {
+ HasSummaryItems = value is { Count: > 0 };
+ }
+ }
+ }
+
+ ///
+ /// Gets a value indicating whether the summary card has data to display.
+ ///
+ public bool HasSummaryItems
+ {
+ get => _hasSummaryItems;
+ private set => SetField(ref _hasSummaryItems, value);
+ }
+
+ ///
+ /// Gets or sets a value indicating whether the detail panel contains metadata.
+ ///
+ public bool HasMetadataDetails
+ {
+ get => _hasMetadataDetails;
+ set => SetField(ref _hasMetadataDetails, value);
+ }
+
+ ///
+ /// Gets or sets a value indicating whether the metadata placeholder should be shown.
+ ///
+ public bool ShowMetadataPlaceholder
+ {
+ get => _showMetadataPlaceholder;
+ set => SetField(ref _showMetadataPlaceholder, value);
+ }
+
+ ///
+ /// Gets or sets the text representing the current file index within its folder.
+ ///
+ public string? GalleryPositionText
+ {
+ get => _galleryPositionText;
+ set => SetField(ref _galleryPositionText, value);
+ }
+
+ ///
+ /// Gets or sets a value indicating whether the error overlay is displayed.
+ ///
+ public bool IsErrorVisible
+ {
+ get => _isErrorVisible;
+ set => SetField(ref _isErrorVisible, value);
+ }
+
+ ///
+ /// Gets or sets the title used by the error overlay.
+ ///
+ public string? ErrorTitle
+ {
+ get => _errorTitle;
+ set => SetField(ref _errorTitle, value);
+ }
+
+ ///
+ /// Gets or sets the description displayed in the error overlay.
+ ///
+ public string? ErrorDescription
+ {
+ get => _errorDescription;
+ set => SetField(ref _errorDescription, value);
+ }
+
+ ///
+ /// Gets the predefined theme names available to the user.
+ ///
+ public IReadOnlyList ThemeOptions { get; } = new[]
+ {
+ "Liquid Dawn",
+ "Midnight Flow",
+ "Frosted Steel"
+ };
+
+ ///
+ /// Gets the language options exposed in the UI.
+ ///
+ public IReadOnlyList LanguageOptions { get; } = new[]
+ {
+ "Русский",
+ "English",
+ "Українська",
+ "Deutsch"
+ };
+
+ ///
+ /// Gets the names of the available shortcut profiles.
+ ///
+ public IReadOnlyList ShortcutProfiles { get; } = new[]
+ {
+ "Стандартный",
+ "Photoshop",
+ "Lightroom"
+ };
+
+ ///
+ /// Gets or sets the currently active theme name.
+ ///
+ public string SelectedTheme
+ {
+ get => _selectedTheme;
+ set => SetField(ref _selectedTheme, value);
+ }
+
+ ///
+ /// Gets or sets the selected language option.
+ ///
+ public string SelectedLanguage
+ {
+ get => _selectedLanguage;
+ set => SetField(ref _selectedLanguage, value);
+ }
+
+ ///
+ /// Gets or sets the active shortcut profile name.
+ ///
+ public string SelectedShortcutProfile
+ {
+ get => _selectedShortcutProfile;
+ set => SetField(ref _selectedShortcutProfile, value);
+ }
+
+ ///
+ /// Gets or sets a value indicating whether the Liquid Glass effect is enabled.
+ ///
+ public bool EnableLiquidGlass
+ {
+ get => _enableLiquidGlass;
+ set => SetField(ref _enableLiquidGlass, value);
+ }
+
+ ///
+ /// Gets or sets a value indicating whether ambient UI animations are enabled.
+ ///
+ public bool EnableAmbientAnimations
+ {
+ get => _enableAmbientAnimations;
+ set => SetField(ref _enableAmbientAnimations, value);
+ }
+
+ ///
+ /// Updates the view model with metadata from a successfully loaded image.
+ ///
+ /// The file name to display.
+ /// The human readable resolution text.
+ /// A status message to present to the user.
+ /// The structured metadata extracted from the file.
+ public void ApplyMetadata(string? fileName, string? resolution, string statusMessage, ImageMetadata? metadata)
+ {
+ FileName = fileName;
+ ResolutionText = resolution;
+ StatusText = statusMessage;
+ IsMetadataVisible = !string.IsNullOrWhiteSpace(fileName);
+ MetadataSections = MetadataViewModelFactory.Create(metadata);
+ HasMetadataDetails = MetadataSections is { Count: > 0 };
+ SummaryItems = MetadataSections?.FirstOrDefault()?.Items?.Take(6).ToList();
+ ShowMetadataPlaceholder = !HasSummaryItems;
+ GalleryPositionText = null;
+ IsErrorVisible = false;
+ ErrorTitle = null;
+ ErrorDescription = null;
+ UpdateMetadataCardVisibility();
+ }
+
+ ///
+ /// Clears all metadata state and hides associated panels.
+ ///
+ public void ResetMetadata()
+ {
+ FileName = null;
+ ResolutionText = null;
+ MetadataSections = null;
+ SummaryItems = null;
+ HasMetadataDetails = false;
+ ShowMetadataPlaceholder = true;
+ IsMetadataVisible = false;
+ GalleryPositionText = null;
+ UpdateMetadataCardVisibility();
+ }
+
+ ///
+ /// Shows an error overlay with the provided title and description.
+ ///
+ public void ShowError(string title, string description)
+ {
+ ErrorTitle = title;
+ ErrorDescription = description;
+ IsErrorVisible = true;
+ }
+
+ ///
+ /// Hides the error overlay and clears the stored messages.
+ ///
+ public void HideError()
+ {
+ IsErrorVisible = false;
+ ErrorTitle = null;
+ ErrorDescription = null;
+ }
+
+ private bool SetField(ref T field, T value, [CallerMemberName] string? propertyName = null)
+ {
+ if (!Equals(field, value))
+ {
+ field = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+ return true;
+ }
+
+ return false;
+ }
+
+ private void UpdateMetadataCardVisibility()
+ {
+ IsMetadataCardVisible = _isUiVisible && _isMetadataVisible;
+ }
+}
diff --git a/FreshViewer/Views/MainWindow.axaml b/FreshViewer/Views/MainWindow.axaml
new file mode 100644
index 0000000..a364830
--- /dev/null
+++ b/FreshViewer/Views/MainWindow.axaml
@@ -0,0 +1,495 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/FreshViewer/Views/MainWindow.axaml.cs b/FreshViewer/Views/MainWindow.axaml.cs
new file mode 100644
index 0000000..b0b09ae
--- /dev/null
+++ b/FreshViewer/Views/MainWindow.axaml.cs
@@ -0,0 +1,1190 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Animation;
+using Avalonia.Animation.Easings;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Media;
+using Avalonia.Platform.Storage;
+using Avalonia.Threading;
+using Avalonia.Styling;
+using FreshViewer.Controls;
+using FreshViewer.Models;
+using FreshViewer.ViewModels;
+using ImageMagick;
+using FreshViewer.Services;
+
+namespace FreshViewer.Views;
+
+///
+/// Main window hosting the viewer UI and coordinating user interactions.
+///
+public sealed partial class MainWindow : Window
+{
+ private static readonly HashSet SupportedExtensions = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".mng", ".webp", ".tiff", ".tif", ".ico", ".svg",
+ ".pbm", ".pgm", ".ppm", ".xbm", ".xpm", ".heic", ".heif", ".avif", ".jxl", ".fits", ".hdr",
+ ".exr", ".pic", ".psd", ".cr2", ".cr3", ".nef", ".arw", ".dng", ".raf", ".orf", ".rw2",
+ ".pef", ".srw", ".x3f", ".mrw", ".dcr", ".kdc", ".erf", ".mef", ".mos", ".ptx", ".r3d", ".fff",
+ ".iiq"
+ };
+
+ private readonly ImageViewport _viewport;
+ private readonly Border _dragOverlay;
+ private readonly MainWindowViewModel _viewModel;
+ private readonly Grid? _uiChrome;
+ private readonly Border? _summaryCard;
+ private readonly Border? _infoPanel;
+ private readonly Border? _settingsPanel;
+ private readonly ShortcutManager _shortcuts = new();
+
+ private TranslateTransform? _uiChromeTransform;
+ private TranslateTransform? _summaryCardTransform;
+
+ private CancellationTokenSource? _chromeAnimationCts;
+ private CancellationTokenSource? _summaryAnimationCts;
+ private CancellationTokenSource? _infoPanelAnimationCts;
+ private CancellationTokenSource? _settingsPanelAnimationCts;
+
+ private static readonly SplineEasing SlideEasing = new(0.18, 0.88, 0.32, 1.08);
+ private static readonly TimeSpan ChromeAnimationDuration = TimeSpan.FromMilliseconds(260);
+ private static readonly TimeSpan PanelAnimationDuration = TimeSpan.FromMilliseconds(320);
+ private const double ChromeHiddenOffset = -28;
+ private const double SummaryHiddenOffset = -22;
+ private const double InfoPanelHiddenOffset = -80;
+ private const double SettingsPanelHiddenOffset = 80;
+
+ private string? _pendingInitialPath;
+ private string? _currentDirectory;
+ private List _directoryFiles = new();
+ private int _currentIndex = -1;
+ private bool _currentAnimated;
+ private ImageMetadata? _currentMetadata;
+
+ private WindowState _previousWindowState = WindowState.Normal;
+ private bool _uiVisibleBeforeFullscreen = true;
+ private PixelPoint? _restoreWindowPosition;
+ private Size? _restoreWindowSize;
+ private bool _wasMaximizedBeforeFullscreen;
+
+ public MainWindow()
+ {
+ InitializeComponent();
+ ConfigureWindowChrome();
+
+ _viewport = this.FindControl("ImageViewport")
+ ?? throw new InvalidOperationException("Viewport control not found");
+ _dragOverlay = this.FindControl("DragOverlay")
+ ?? throw new InvalidOperationException("Drag overlay not found");
+ _uiChrome = this.FindControl("UiChrome");
+ _summaryCard = this.FindControl("SummaryCard");
+ _infoPanel = this.FindControl("InfoPanel");
+ _settingsPanel = this.FindControl("SettingsPanel");
+
+ InitializeChromeState();
+
+ if (DataContext is MainWindowViewModel existingVm)
+ {
+ _viewModel = existingVm;
+ }
+ else
+ {
+ _viewModel = new MainWindowViewModel();
+ DataContext = _viewModel;
+ }
+
+ _viewport.ImagePresented += OnImagePresented;
+ _viewport.ImageFailed += OnImageFailed;
+ _viewport.ViewStateChanged += (_, _) => UpdateResolutionLabel();
+
+ AddHandler(DragDrop.DragEnterEvent, OnDragEnter);
+ AddHandler(DragDrop.DragLeaveEvent, OnDragLeave);
+ AddHandler(DragDrop.DropEvent, OnDrop);
+
+ PropertyChanged += OnWindowPropertyChanged;
+ _viewModel.PropertyChanged += OnViewModelPropertyChanged;
+
+ InitializePanelState(_infoPanel, -80);
+ InitializePanelState(_settingsPanel, 80);
+
+ // Apply the persisted theme and language.
+ LocalizationService.ApplyLanguage(_viewModel.SelectedLanguage);
+ ThemeManager.Apply(_viewModel.SelectedTheme);
+
+ _viewModel.StatusText = "Откройте изображение или перетащите файл";
+ }
+
+ private void ConfigureWindowChrome()
+ {
+ // Simplified chrome: no Mica/Acrylic effects, just a transparent surface.
+ TransparencyLevelHint = new[] { WindowTransparencyLevel.Transparent };
+
+ Background = Brushes.Transparent; // Leave the window transparent; draw the background via XAML.
+ ExtendClientAreaToDecorationsHint = true;
+ ExtendClientAreaChromeHints = Avalonia.Platform.ExtendClientAreaChromeHints.PreferSystemChrome;
+ ExtendClientAreaTitleBarHeightHint = 32;
+ }
+
+ ///
+ /// Applies command-line arguments captured by the desktop lifetime.
+ ///
+ public void InitializeFromArguments(string[]? args)
+ {
+ if (args is { Length: > 0 })
+ {
+ _pendingInitialPath = args[0];
+ }
+ }
+
+ protected override async void OnOpened(EventArgs e)
+ {
+ base.OnOpened(e);
+
+ if (WindowState != WindowState.Maximized)
+ {
+ WindowState = WindowState.Maximized;
+ }
+
+ if (!string.IsNullOrWhiteSpace(_pendingInitialPath) && File.Exists(_pendingInitialPath))
+ {
+ await OpenImageAsync(_pendingInitialPath);
+ }
+ else
+ {
+ _viewModel.ShowMetadataPlaceholder = true;
+ await Dispatcher.UIThread.InvokeAsync(() => _viewport.Focus());
+ }
+
+ Dispatcher.UIThread.Post(() =>
+ {
+ _ = AnimateUiChromeAsync(_viewModel.IsUiVisible, immediate: true);
+ if (_viewModel.IsMetadataCardVisible)
+ {
+ _ = AnimateSummaryCardAsync(true, immediate: true);
+ }
+ });
+ }
+
+ private static void InitializePanelState(Border? panel, double initialOffset)
+ {
+ if (panel is null)
+ {
+ return;
+ }
+
+ panel.IsVisible = false;
+ panel.IsHitTestVisible = false;
+ panel.Opacity = 0;
+ panel.RenderTransform = new TranslateTransform(initialOffset, 0);
+ }
+
+ private void InitializeChromeState()
+ {
+ if (_uiChrome is { } chrome)
+ {
+ _uiChromeTransform = EnsureTranslateTransform(chrome);
+ _uiChromeTransform.Y = ChromeHiddenOffset;
+ chrome.Opacity = 0;
+ chrome.IsVisible = false;
+ chrome.IsHitTestVisible = false;
+ }
+
+ if (_summaryCard is { } card)
+ {
+ _summaryCardTransform = EnsureTranslateTransform(card);
+ _summaryCardTransform.Y = SummaryHiddenOffset;
+ card.Opacity = 0;
+ card.IsVisible = false;
+ card.IsHitTestVisible = false;
+ }
+ }
+
+ private static TranslateTransform EnsureTranslateTransform(Visual visual)
+ {
+ if (visual.RenderTransform is TranslateTransform translate)
+ {
+ return translate;
+ }
+
+ translate = new TranslateTransform();
+ visual.RenderTransform = translate;
+ return translate;
+ }
+
+ private async Task PromptOpenFileAsync()
+ {
+ if (StorageProvider is null)
+ {
+ return;
+ }
+
+ var fileTypes = new List
+ {
+ new("Поддерживаемые форматы")
+ {
+ Patterns = SupportedExtensions.Select(ext => "*" + ext).ToArray()
+ }
+ };
+
+ var result = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
+ {
+ Title = "Открыть изображение",
+ AllowMultiple = false,
+ FileTypeFilter = fileTypes
+ });
+
+ var file = result?.FirstOrDefault();
+ if (file?.TryGetLocalPath() is { } localPath && File.Exists(localPath))
+ {
+ await OpenImageAsync(localPath);
+ }
+ }
+
+ private async Task OpenImageAsync(string path, ImageTransition transition = ImageTransition.FadeIn)
+ {
+ _viewModel.StatusText = "Загрузка изображения...";
+ Title = $"FreshViewer — {Path.GetFileName(path)}";
+ _currentMetadata = null;
+ _viewModel.ResetMetadata();
+ _viewModel.HideError();
+
+ if (_viewport.IsFullscreen && transition != ImageTransition.Instant)
+ {
+ transition = ImageTransition.Instant;
+ }
+
+ await _viewport.LoadImageAsync(path, transition);
+ UpdateDirectoryContext(path);
+ }
+
+ private void UpdateDirectoryContext(string path)
+ {
+ try
+ {
+ var directory = Path.GetDirectoryName(path);
+ if (string.IsNullOrWhiteSpace(directory) || !Directory.Exists(directory))
+ {
+ return;
+ }
+
+ _currentDirectory = directory;
+ _directoryFiles = Directory.EnumerateFiles(directory)
+ .Where(f => IsSupported(Path.GetExtension(f)))
+ .OrderBy(f => Path.GetFileName(f), StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ _currentIndex = _directoryFiles.FindIndex(f => string.Equals(f, path, StringComparison.OrdinalIgnoreCase));
+ }
+ catch (Exception ex)
+ {
+ _viewModel.StatusText = ex.Message;
+ }
+ }
+
+ private static bool IsSupported(string? extension)
+ => extension is not null && SupportedExtensions.Contains(extension);
+
+ private async void OnImagePresented(object? sender, ImagePresentedEventArgs e)
+ {
+ var fileName = Path.GetFileName(e.Path);
+ _currentAnimated = e.IsAnimated;
+ _currentMetadata = e.Metadata;
+ _viewModel.ApplyMetadata(fileName, FormatResolution(e.Dimensions, e.IsAnimated), "Готово", e.Metadata);
+ Title = $"FreshViewer — {fileName}";
+ await Dispatcher.UIThread.InvokeAsync(UpdateResolutionLabel);
+ _viewModel.StatusText = e.IsAnimated ? "Анимация загружена" : "Изображение загружено";
+ }
+
+ private static string FormatResolution((int Width, int Height) size, bool animated)
+ {
+ var resolution = $"{size.Width} × {size.Height}";
+ return animated ? resolution + " · Анимация" : resolution;
+ }
+
+ private void UpdateResolutionLabel()
+ {
+ if (!_viewport.HasImage)
+ {
+ _viewModel.HasMetadataDetails = false;
+ _viewModel.ResolutionText = null;
+ _viewModel.GalleryPositionText = null;
+ return;
+ }
+
+ var (width, height) = _viewport.GetEffectivePixelDimensions();
+ if (width <= 0 || height <= 0)
+ {
+ _viewModel.ResolutionText = null;
+ _viewModel.GalleryPositionText = null;
+ return;
+ }
+
+ var resolution = $"{width} × {height}" + (_currentAnimated ? " · Анимация" : string.Empty);
+ _viewModel.ResolutionText = resolution;
+
+ if (!string.IsNullOrWhiteSpace(_currentDirectory) && _currentIndex >= 0 &&
+ _currentIndex < _directoryFiles.Count)
+ {
+ _viewModel.GalleryPositionText = $"{_currentIndex + 1}/{_directoryFiles.Count}";
+ }
+ else
+ {
+ _viewModel.GalleryPositionText = null;
+ }
+ }
+
+ private void OnImageFailed(object? sender, ImageFailedEventArgs e)
+ {
+ var (title, description) = DescribeLoadFailure(e);
+ _viewModel.StatusText = title;
+ _viewModel.ResetMetadata();
+ _viewModel.ShowError(title, description);
+ }
+
+ protected override async void OnKeyDown(KeyEventArgs e)
+ {
+ base.OnKeyDown(e);
+
+ if (e.Handled)
+ {
+ return;
+ }
+
+ if (_shortcuts.TryMatch(e, out var action))
+ {
+ switch (action)
+ {
+ case ShortcutAction.OpenFile:
+ await PromptOpenFileAsync();
+ e.Handled = true;
+ return;
+ case ShortcutAction.CopyFrame:
+ await CopyCurrentFrameToClipboardAsync();
+ e.Handled = true;
+ return;
+ case ShortcutAction.Fullscreen:
+ ToggleFullscreen();
+ e.Handled = true;
+ return;
+ case ShortcutAction.Fit:
+ _viewport.FitToView();
+ _viewModel.StatusText = "Изображение подогнано по экрану";
+ e.Handled = true;
+ return;
+ case ShortcutAction.ZoomIn:
+ _viewport.ZoomIncrement(_viewport.ViewportCenterPoint, zoomIn: true);
+ _viewModel.StatusText = "Приближение";
+ e.Handled = true;
+ return;
+ case ShortcutAction.ZoomOut:
+ _viewport.ZoomIncrement(_viewport.ViewportCenterPoint, zoomIn: false);
+ _viewModel.StatusText = "Отдаление";
+ e.Handled = true;
+ return;
+ case ShortcutAction.RotateClockwise:
+ _viewport.RotateClockwise();
+ _viewModel.StatusText = "Поворот по часовой";
+ e.Handled = true;
+ return;
+ case ShortcutAction.RotateCounterClockwise:
+ _viewport.RotateCounterClockwise();
+ _viewModel.StatusText = "Поворот против часовой";
+ e.Handled = true;
+ return;
+ case ShortcutAction.ToggleUi:
+ ToggleInterfaceVisibility();
+ e.Handled = true;
+ return;
+ case ShortcutAction.ToggleInfo:
+ ToggleInfoPanel();
+ e.Handled = true;
+ return;
+ case ShortcutAction.ToggleSettings:
+ ToggleSettingsPanel();
+ e.Handled = true;
+ return;
+ case ShortcutAction.Next:
+ await NavigateAsync(1);
+ e.Handled = true;
+ return;
+ case ShortcutAction.Previous:
+ await NavigateAsync(-1);
+ e.Handled = true;
+ return;
+ default:
+ break;
+ }
+ }
+
+ if (e.Key == Key.Escape)
+ {
+ Close();
+ e.Handled = true;
+ }
+ }
+
+ private async Task NavigateAsync(int direction)
+ {
+ if (_directoryFiles.Count == 0 || _currentIndex < 0)
+ {
+ return;
+ }
+
+ var newIndex = (_currentIndex + direction + _directoryFiles.Count) % _directoryFiles.Count;
+ if (newIndex == _currentIndex)
+ {
+ return;
+ }
+
+ _currentIndex = newIndex;
+ var path = _directoryFiles[_currentIndex];
+ var transition = direction > 0 ? ImageTransition.SlideFromRight : ImageTransition.SlideFromLeft;
+ if (_viewport.IsFullscreen)
+ {
+ transition = ImageTransition.Instant;
+ }
+
+ await OpenImageAsync(path, transition);
+ _viewModel.StatusText = direction > 0 ? "Следующее изображение" : "Предыдущее изображение";
+ }
+
+ private void ToggleFullscreen()
+ {
+ if (WindowState == WindowState.FullScreen)
+ {
+ var targetState = _wasMaximizedBeforeFullscreen
+ ? WindowState.Maximized
+ : (_previousWindowState == WindowState.FullScreen ? WindowState.Maximized : _previousWindowState);
+
+ WindowState = WindowState.Normal;
+
+ if (targetState == WindowState.Maximized)
+ {
+ WindowState = WindowState.Maximized;
+ }
+ else
+ {
+ if (_restoreWindowSize is { } size)
+ {
+ Width = size.Width;
+ Height = size.Height;
+ }
+
+ if (_restoreWindowPosition is { } position)
+ {
+ Position = position;
+ }
+
+ WindowState = targetState;
+ }
+
+ _viewModel.IsUiVisible = _uiVisibleBeforeFullscreen;
+ _viewModel.StatusText = "Полноэкранный режим выключен";
+ UpdateResolutionLabel();
+ _wasMaximizedBeforeFullscreen = false;
+ return;
+ }
+
+ _previousWindowState = WindowState;
+ _wasMaximizedBeforeFullscreen = WindowState == WindowState.Maximized;
+ if (!_wasMaximizedBeforeFullscreen)
+ {
+ _restoreWindowSize = new Size(Width, Height);
+ _restoreWindowPosition = Position;
+ }
+ else
+ {
+ _restoreWindowSize = null;
+ _restoreWindowPosition = null;
+ }
+
+ _uiVisibleBeforeFullscreen = _viewModel.IsUiVisible;
+ WindowState = WindowState.FullScreen;
+
+ _viewModel.IsInfoPanelVisible = false;
+ _viewModel.IsSettingsPanelVisible = false;
+ _viewModel.IsUiVisible = false;
+
+ _viewModel.StatusText = "Полноэкранный режим";
+ UpdateResolutionLabel();
+ }
+
+ private async Task CopyCurrentFrameToClipboardAsync()
+ {
+ var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
+ if (clipboard is null)
+ {
+ return;
+ }
+
+ if (_viewport.GetCurrentFrameBitmap() is { } bitmap)
+ {
+ await using var stream = new MemoryStream();
+ bitmap.Save(stream);
+ var pngBytes = stream.ToArray();
+
+ var dataObject = new DataObject();
+ dataObject.Set("image/png", pngBytes);
+ await clipboard.SetDataObjectAsync(dataObject);
+ _viewModel.StatusText = "Кадр скопирован в буфер обмена";
+ }
+ }
+
+ private void OnDragEnter(object? sender, DragEventArgs e)
+ {
+ if (e.Data.Contains(DataFormats.Files))
+ {
+ _dragOverlay.IsVisible = true;
+ e.DragEffects = DragDropEffects.Copy;
+ e.Handled = true;
+ }
+ }
+
+ private void OnDragLeave(object? sender, RoutedEventArgs e)
+ {
+ _dragOverlay.IsVisible = false;
+ }
+
+ private async void OnDrop(object? sender, DragEventArgs e)
+ {
+ _dragOverlay.IsVisible = false;
+ if (!e.Data.Contains(DataFormats.Files))
+ {
+ return;
+ }
+
+ var files = e.Data.GetFiles();
+ if (files is null)
+ {
+ return;
+ }
+
+ var first = files.FirstOrDefault();
+ if (first?.TryGetLocalPath() is { } localPath && File.Exists(localPath))
+ {
+ await OpenImageAsync(localPath);
+ }
+ }
+
+ protected override void OnClosed(EventArgs e)
+ {
+ PropertyChanged -= OnWindowPropertyChanged;
+ _viewModel.PropertyChanged -= OnViewModelPropertyChanged;
+ base.OnClosed(e);
+ DisposeAnimationCts();
+ _viewport.Dispose();
+ }
+
+ private void OnWindowPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
+ {
+ if (e.Property == WindowStateProperty && e.NewValue is WindowState state)
+ {
+ _viewport.IsFullscreen = state == WindowState.FullScreen;
+ if (state != WindowState.FullScreen)
+ {
+ _previousWindowState = state;
+ }
+ }
+ }
+
+ private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (!ReferenceEquals(sender, _viewModel))
+ {
+ return;
+ }
+
+ switch (e.PropertyName)
+ {
+ case nameof(MainWindowViewModel.IsUiVisible):
+ _ = AnimateUiChromeAsync(_viewModel.IsUiVisible);
+ if (_viewModel.IsUiVisible)
+ {
+ _ = AnimateSummaryCardAsync(_viewModel.IsMetadataCardVisible);
+ }
+ else
+ {
+ _ = AnimateSummaryCardAsync(false);
+ }
+
+ break;
+ case nameof(MainWindowViewModel.SelectedTheme):
+ ThemeManager.Apply(_viewModel.SelectedTheme);
+ break;
+ case nameof(MainWindowViewModel.SelectedLanguage):
+ LocalizationService.ApplyLanguage(_viewModel.SelectedLanguage);
+ break;
+ case nameof(MainWindowViewModel.SelectedShortcutProfile):
+ _shortcuts.ResetToProfile(_viewModel.SelectedShortcutProfile);
+ break;
+ case nameof(MainWindowViewModel.IsMetadataCardVisible):
+ if (_viewModel.IsUiVisible)
+ {
+ _ = AnimateSummaryCardAsync(_viewModel.IsMetadataCardVisible);
+ }
+
+ break;
+ case nameof(MainWindowViewModel.IsInfoPanelVisible):
+ CancelAnimation(ref _infoPanelAnimationCts);
+ var infoCts = new CancellationTokenSource();
+ _infoPanelAnimationCts = infoCts;
+ _ = AnimatePanelAsync(
+ _infoPanel,
+ _viewModel.IsInfoPanelVisible,
+ InfoPanelHiddenOffset,
+ infoCts,
+ completedCts =>
+ {
+ if (ReferenceEquals(_infoPanelAnimationCts, completedCts))
+ {
+ _infoPanelAnimationCts = null;
+ }
+
+ completedCts.Dispose();
+ });
+ break;
+ case nameof(MainWindowViewModel.IsSettingsPanelVisible):
+ CancelAnimation(ref _settingsPanelAnimationCts);
+ var settingsCts = new CancellationTokenSource();
+ _settingsPanelAnimationCts = settingsCts;
+ _ = AnimatePanelAsync(
+ _settingsPanel,
+ _viewModel.IsSettingsPanelVisible,
+ SettingsPanelHiddenOffset,
+ settingsCts,
+ completedCts =>
+ {
+ if (ReferenceEquals(_settingsPanelAnimationCts, completedCts))
+ {
+ _settingsPanelAnimationCts = null;
+ }
+
+ completedCts.Dispose();
+ });
+ break;
+ }
+ }
+
+ private Task AnimateUiChromeAsync(bool show, bool immediate = false)
+ {
+ if (_uiChrome is not { } chrome)
+ {
+ return Task.CompletedTask;
+ }
+
+ _uiChromeTransform ??= EnsureTranslateTransform(chrome);
+
+ if (immediate)
+ {
+ _uiChromeTransform.Y = show ? 0 : ChromeHiddenOffset;
+ chrome.Opacity = show ? 1 : 0;
+ chrome.IsVisible = show;
+ chrome.IsHitTestVisible = show;
+ return Task.CompletedTask;
+ }
+
+ CancelAnimation(ref _chromeAnimationCts);
+ var cts = new CancellationTokenSource();
+ _chromeAnimationCts = cts;
+
+ if (show)
+ {
+ chrome.IsVisible = true;
+ chrome.IsHitTestVisible = true;
+ }
+ else
+ {
+ chrome.IsHitTestVisible = false;
+ }
+
+ var fromY = _uiChromeTransform.Y;
+ var toY = show ? 0 : ChromeHiddenOffset;
+ var fromOpacity = chrome.Opacity;
+ var toOpacity = show ? 1.0 : 0.0;
+
+ var translationAnimation = new Animation
+ {
+ Duration = ChromeAnimationDuration,
+ FillMode = FillMode.Both,
+ Easing = SlideEasing,
+ Children =
+ {
+ new KeyFrame
+ {
+ Cue = new Cue(0),
+ Setters = { new Setter(TranslateTransform.YProperty, fromY) }
+ },
+ new KeyFrame
+ {
+ Cue = new Cue(1),
+ Setters = { new Setter(TranslateTransform.YProperty, toY) }
+ }
+ }
+ };
+
+ var opacityAnimation = new Animation
+ {
+ Duration = ChromeAnimationDuration,
+ FillMode = FillMode.Both,
+ Easing = SlideEasing,
+ Children =
+ {
+ new KeyFrame
+ {
+ Cue = new Cue(0),
+ Setters = { new Setter(Visual.OpacityProperty, fromOpacity) }
+ },
+ new KeyFrame
+ {
+ Cue = new Cue(1),
+ Setters = { new Setter(Visual.OpacityProperty, toOpacity) }
+ }
+ }
+ };
+
+ return RunCompositeAnimationAsync(
+ chrome,
+ _uiChromeTransform!,
+ show,
+ cts,
+ completedCts =>
+ {
+ if (ReferenceEquals(_chromeAnimationCts, completedCts))
+ {
+ _chromeAnimationCts = null;
+ }
+
+ completedCts.Dispose();
+ },
+ translationAnimation,
+ opacityAnimation);
+ }
+
+ private Task AnimateSummaryCardAsync(bool show, bool immediate = false)
+ {
+ if (_summaryCard is not { } card)
+ {
+ return Task.CompletedTask;
+ }
+
+ _summaryCardTransform ??= EnsureTranslateTransform(card);
+
+ if (immediate)
+ {
+ _summaryCardTransform.Y = show ? 0 : SummaryHiddenOffset;
+ card.Opacity = show ? 1 : 0;
+ card.IsVisible = show;
+ card.IsHitTestVisible = show;
+ return Task.CompletedTask;
+ }
+
+ CancelAnimation(ref _summaryAnimationCts);
+ var cts = new CancellationTokenSource();
+ _summaryAnimationCts = cts;
+
+ if (show)
+ {
+ card.IsVisible = true;
+ card.IsHitTestVisible = true;
+ }
+ else
+ {
+ card.IsHitTestVisible = false;
+ }
+
+ var fromY = _summaryCardTransform.Y;
+ var toY = show ? 0 : SummaryHiddenOffset;
+ var fromOpacity = card.Opacity;
+ var toOpacity = show ? 1.0 : 0.0;
+
+ var translationAnimation = new Animation
+ {
+ Duration = ChromeAnimationDuration,
+ FillMode = FillMode.Both,
+ Easing = SlideEasing,
+ Children =
+ {
+ new KeyFrame
+ {
+ Cue = new Cue(0),
+ Setters = { new Setter(TranslateTransform.YProperty, fromY) }
+ },
+ new KeyFrame
+ {
+ Cue = new Cue(1),
+ Setters = { new Setter(TranslateTransform.YProperty, toY) }
+ }
+ }
+ };
+
+ var opacityAnimation = new Animation
+ {
+ Duration = ChromeAnimationDuration,
+ FillMode = FillMode.Both,
+ Easing = SlideEasing,
+ Children =
+ {
+ new KeyFrame
+ {
+ Cue = new Cue(0),
+ Setters = { new Setter(Visual.OpacityProperty, fromOpacity) }
+ },
+ new KeyFrame
+ {
+ Cue = new Cue(1),
+ Setters = { new Setter(Visual.OpacityProperty, toOpacity) }
+ }
+ }
+ };
+
+ return RunCompositeAnimationAsync(
+ card,
+ _summaryCardTransform!,
+ show,
+ cts,
+ completedCts =>
+ {
+ if (ReferenceEquals(_summaryAnimationCts, completedCts))
+ {
+ _summaryAnimationCts = null;
+ }
+
+ completedCts.Dispose();
+ },
+ translationAnimation,
+ opacityAnimation);
+ }
+
+ private Task AnimatePanelAsync(
+ Border? panel,
+ bool show,
+ double hiddenOffset,
+ CancellationTokenSource cts,
+ Action finalize,
+ bool immediate = false)
+ {
+ if (panel is null)
+ {
+ finalize(cts);
+ return Task.CompletedTask;
+ }
+
+ var transform = panel.RenderTransform as TranslateTransform ?? EnsureTranslateTransform(panel);
+
+ if (immediate)
+ {
+ transform.X = show ? 0 : hiddenOffset;
+ panel.Opacity = show ? 1 : 0;
+ panel.IsVisible = show;
+ panel.IsHitTestVisible = show;
+ finalize(cts);
+ return Task.CompletedTask;
+ }
+
+ if (show)
+ {
+ panel.IsVisible = true;
+ panel.IsHitTestVisible = true;
+ }
+ else
+ {
+ panel.IsHitTestVisible = false;
+ }
+
+ var fromX = transform.X;
+ var toX = show ? 0 : hiddenOffset;
+ var fromOpacity = panel.Opacity;
+ var toOpacity = show ? 1.0 : 0.0;
+
+ var translationAnimation = new Animation
+ {
+ Duration = PanelAnimationDuration,
+ FillMode = FillMode.Both,
+ Easing = SlideEasing,
+ Children =
+ {
+ new KeyFrame
+ {
+ Cue = new Cue(0),
+ Setters = { new Setter(TranslateTransform.XProperty, fromX) }
+ },
+ new KeyFrame
+ {
+ Cue = new Cue(1),
+ Setters = { new Setter(TranslateTransform.XProperty, toX) }
+ }
+ }
+ };
+
+ var opacityAnimation = new Animation
+ {
+ Duration = PanelAnimationDuration,
+ FillMode = FillMode.Both,
+ Easing = SlideEasing,
+ Children =
+ {
+ new KeyFrame
+ {
+ Cue = new Cue(0),
+ Setters = { new Setter(Visual.OpacityProperty, fromOpacity) }
+ },
+ new KeyFrame
+ {
+ Cue = new Cue(1),
+ Setters = { new Setter(Visual.OpacityProperty, toOpacity) }
+ }
+ }
+ };
+
+ return RunCompositeAnimationAsync(panel, transform, show, cts, finalize, translationAnimation,
+ opacityAnimation);
+ }
+
+ private static Task RunCompositeAnimationAsync(
+ Control control,
+ TranslateTransform transform,
+ bool show,
+ CancellationTokenSource cts,
+ Action finalize,
+ Animation translation,
+ Animation opacity)
+ {
+ return RunAsync();
+
+ async Task RunAsync()
+ {
+ try
+ {
+ await Task.WhenAll(
+ translation.RunAsync(transform, cts.Token),
+ opacity.RunAsync(control, cts.Token));
+ }
+ catch (OperationCanceledException)
+ {
+ return;
+ }
+ finally
+ {
+ finalize(cts);
+ }
+
+ if (!show && !cts.IsCancellationRequested)
+ {
+ control.IsHitTestVisible = false;
+ control.IsVisible = false;
+ }
+ }
+ }
+
+ private static void CancelAnimation(ref CancellationTokenSource? cts)
+ {
+ if (cts is null)
+ {
+ return;
+ }
+
+ if (!cts.IsCancellationRequested)
+ {
+ cts.Cancel();
+ }
+
+ cts.Dispose();
+ cts = null;
+ }
+
+ private void DisposeAnimationCts()
+ {
+ CancelAnimation(ref _chromeAnimationCts);
+ CancelAnimation(ref _summaryAnimationCts);
+ CancelAnimation(ref _infoPanelAnimationCts);
+ CancelAnimation(ref _settingsPanelAnimationCts);
+ }
+
+ private void ToggleInterfaceVisibility()
+ {
+ var isVisible = !_viewModel.IsUiVisible;
+ _viewModel.IsUiVisible = isVisible;
+
+ if (!isVisible && _viewModel.IsInfoPanelVisible)
+ {
+ _viewModel.IsInfoPanelVisible = false;
+ }
+
+ if (!isVisible && _viewModel.IsSettingsPanelVisible)
+ {
+ _viewModel.IsSettingsPanelVisible = false;
+ }
+
+ _viewModel.StatusText = isVisible
+ ? "Интерфейс отображён"
+ : "Интерфейс скрыт — нажмите Q для возврата";
+ }
+
+ private void ToggleSettingsPanel()
+ {
+ if (!_viewModel.IsUiVisible)
+ {
+ _viewModel.IsUiVisible = true;
+ }
+
+ var show = !_viewModel.IsSettingsPanelVisible;
+ if (show && _viewModel.IsInfoPanelVisible)
+ {
+ _viewModel.IsInfoPanelVisible = false;
+ }
+
+ _viewModel.IsSettingsPanelVisible = show;
+
+ _viewModel.StatusText = show
+ ? "Панель настроек открыта"
+ : "Панель настроек скрыта";
+ }
+
+ private void ToggleInfoPanel()
+ {
+ if (!_viewModel.IsUiVisible)
+ {
+ _viewModel.IsUiVisible = true;
+ }
+
+ var infoVisible = !_viewModel.IsInfoPanelVisible;
+ if (infoVisible && _viewModel.IsSettingsPanelVisible)
+ {
+ _viewModel.IsSettingsPanelVisible = false;
+ }
+
+ _viewModel.IsInfoPanelVisible = infoVisible;
+
+ if (infoVisible && _currentMetadata is null)
+ {
+ _viewModel.StatusText = "Нет метаданных для отображения";
+ }
+ else
+ {
+ _viewModel.StatusText = infoVisible
+ ? "Панель информации открыта"
+ : "Панель информации скрыта";
+ }
+ }
+
+ private static (string Title, string Description) DescribeLoadFailure(ImageFailedEventArgs args)
+ {
+ var path = args.Path;
+ var fileName = string.IsNullOrWhiteSpace(path) ? "Изображение" : Path.GetFileName(path);
+ var ex = args.Exception;
+
+ return ex switch
+ {
+ FileNotFoundException => ($"Файл не найден",
+ $"Не удалось найти файл \"{fileName}\". Проверьте путь и попробуйте снова."),
+ UnauthorizedAccessException => ($"Нет доступа",
+ $"У FreshViewer нет доступа к файлу \"{fileName}\". Разрешите чтение или переместите файл."),
+ MagickMissingDelegateErrorException or MagickException =>
+ ($"Не удалось открыть {fileName}",
+ $"Magick.NET вернул ошибку: {ex.Message}\nПроверьте, поддерживается ли формат и не повреждён ли файл."),
+ InvalidDataException or FormatException => ($"Файл повреждён",
+ $"Файл \"{fileName}\" нельзя прочитать: {ex.Message}"),
+ NotSupportedException => ($"Формат не поддерживается",
+ $"Формат \"{Path.GetExtension(fileName)}\" пока не поддерживается или отключён."),
+ OperationCanceledException => ($"Загрузка прервана", "Операция чтения изображения была отменена."),
+ _ => ($"Ошибка загрузки", $"Не удалось открыть \"{fileName}\": {ex.Message}")
+ };
+ }
+
+ private void OnErrorOverlayDismissed(object? sender, RoutedEventArgs e)
+ {
+ _viewModel.HideError();
+ }
+
+ private async void OnErrorOverlayOpenAnother(object? sender, RoutedEventArgs e)
+ {
+ _viewModel.HideError();
+ await PromptOpenFileAsync();
+ }
+
+ private async void OnOpenButtonClicked(object? sender, RoutedEventArgs e)
+ {
+ await PromptOpenFileAsync();
+ }
+
+ private async void OnPreviousClicked(object? sender, RoutedEventArgs e)
+ {
+ await NavigateAsync(-1);
+ }
+
+ private async void OnNextClicked(object? sender, RoutedEventArgs e)
+ {
+ await NavigateAsync(1);
+ }
+
+ // Fit button removed from UI; keep keyboard shortcuts via ShortcutManager
+
+ private void OnRotateClicked(object? sender, RoutedEventArgs e)
+ {
+ _viewport.RotateClockwise();
+ _viewModel.StatusText = "Поворот по часовой";
+ }
+
+ private void OnToggleSettingsClicked(object? sender, RoutedEventArgs e)
+ {
+ ToggleSettingsPanel();
+ }
+
+ private void OnToggleInfoClicked(object? sender, RoutedEventArgs e)
+ {
+ ToggleInfoPanel();
+ }
+
+ // Fullscreen button removed from UI; F11 shortcut remains
+
+ private async void OnExportShortcutsClicked(object? sender, RoutedEventArgs e)
+ {
+ var json = await _shortcuts.ExportToJsonAsync();
+ var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
+ if (clipboard is null)
+ {
+ return;
+ }
+
+ await clipboard.SetTextAsync(json);
+ _viewModel.StatusText = "Профиль шорткатов скопирован в буфер обмена";
+ }
+
+ private async void OnImportShortcutsClicked(object? sender, RoutedEventArgs e)
+ {
+ var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
+ if (clipboard is null)
+ {
+ return;
+ }
+
+ var text = await clipboard.GetTextAsync();
+ if (!string.IsNullOrWhiteSpace(text))
+ {
+ try
+ {
+ _shortcuts.ImportFromJson(text);
+ _viewModel.StatusText = "Профиль шорткатов импортирован";
+ }
+ catch (Exception ex)
+ {
+ _viewModel.StatusText = $"Ошибка импорта: {ex.Message}";
+ }
+ }
+ }
+
+ private void OnResetShortcutsClicked(object? sender, RoutedEventArgs e)
+ {
+ _shortcuts.ResetToProfile(_viewModel.SelectedShortcutProfile);
+ _viewModel.StatusText = "Шорткаты сброшены к профилю";
+ }
+
+ // Helper methods are unnecessary; property setters trigger PropertyChanged immediately.
+}
diff --git a/FreshViewer/app.manifest b/FreshViewer/app.manifest
new file mode 100644
index 0000000..90026f7
--- /dev/null
+++ b/FreshViewer/app.manifest
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+ true/pm
+ true
+
+
+
diff --git a/legacy/blurviewer/BlurViewer.ico b/legacy/blurviewer/BlurViewer.ico
new file mode 100644
index 0000000..159c2fd
Binary files /dev/null and b/legacy/blurviewer/BlurViewer.ico differ
diff --git a/BlurViewer.py b/legacy/blurviewer/BlurViewer.py
similarity index 100%
rename from BlurViewer.py
rename to legacy/blurviewer/BlurViewer.py
diff --git a/CONTRIBUTING.md b/legacy/blurviewer/CONTRIBUTING.md
similarity index 100%
rename from CONTRIBUTING.md
rename to legacy/blurviewer/CONTRIBUTING.md
diff --git a/LICENSE b/legacy/blurviewer/LICENSE
similarity index 100%
rename from LICENSE
rename to legacy/blurviewer/LICENSE
diff --git a/Makefile b/legacy/blurviewer/Makefile
similarity index 100%
rename from Makefile
rename to legacy/blurviewer/Makefile
diff --git a/README.de.md b/legacy/blurviewer/README.de.md
similarity index 100%
rename from README.de.md
rename to legacy/blurviewer/README.de.md
diff --git a/README.md b/legacy/blurviewer/README.md
similarity index 100%
rename from README.md
rename to legacy/blurviewer/README.md
diff --git a/README.ru.md b/legacy/blurviewer/README.ru.md
similarity index 100%
rename from README.ru.md
rename to legacy/blurviewer/README.ru.md
diff --git a/pyproject.toml b/legacy/blurviewer/pyproject.toml
similarity index 100%
rename from pyproject.toml
rename to legacy/blurviewer/pyproject.toml
diff --git a/requirements.txt b/legacy/blurviewer/requirements.txt
similarity index 100%
rename from requirements.txt
rename to legacy/blurviewer/requirements.txt