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