From e51154dea2de391d696fe88fdcf5ba0a8a5d4f6d Mon Sep 17 00:00:00 2001 From: amtiYo Date: Fri, 26 Sep 2025 11:35:25 +0300 Subject: [PATCH 01/10] ci(windows): add GitHub Actions workflow for self-contained publish --- .github/workflows/build-windows.yml | 38 + .gitignore | 284 +--- BlurViewer.py | 1106 --------------- CONTRIBUTING.md | 133 +- FreshViewer.sln | 18 + FreshViewer/App.axaml | 15 + FreshViewer/App.axaml.cs | 78 ++ FreshViewer/Assets/AppIcon.ico | Bin 0 -> 15406 bytes .../Assets/Icons/Arrow_Undo_Down_Left.svg | 3 + FreshViewer/Assets/Icons/Chevron_Left_MD.svg | 4 + FreshViewer/Assets/Icons/Chevron_Right_MD.svg | 4 + FreshViewer/Assets/Icons/Folder_Open.svg | 4 + FreshViewer/Assets/Icons/open.svg | 4 + FreshViewer/Assets/Icons/rotate.svg | 4 + FreshViewer/Assets/i18n/Strings.de.axaml | 12 + FreshViewer/Assets/i18n/Strings.en.axaml | 12 + FreshViewer/Assets/i18n/Strings.ru.axaml | 12 + FreshViewer/Assets/i18n/Strings.uk.axaml | 12 + FreshViewer/Controls/ImageViewport.cs | 905 +++++++++++++ .../Converters/BooleanToValueConverter.cs | 37 + FreshViewer/FreshViewer.csproj | 38 + .../DisplacementMaps/polar_displacement.jpeg | Bin 0 -> 4319 bytes .../prominent_displacement.jpeg | Bin 0 -> 21909 bytes .../standard_displacement.jpeg | Bin 0 -> 4451 bytes .../Assets/LiquidGlassShaderOld.sksl | 57 + .../Assets/Shaders/LiquidGlassShader.sksl | 163 +++ .../Shaders/LiquidGlassShader_Fixed.sksl | 181 +++ .../Assets/Shaders/LiquidGlassShader_New.sksl | 136 ++ .../LiquidGlass/DisplacementMapManager.cs | 298 +++++ .../LiquidGlass/DraggableLiquidGlassCard.cs | 326 +++++ FreshViewer/LiquidGlass/LiquidGlassButton.cs | 448 +++++++ FreshViewer/LiquidGlass/LiquidGlassCard.cs | 386 ++++++ FreshViewer/LiquidGlass/LiquidGlassControl.cs | 411 ++++++ .../LiquidGlass/LiquidGlassControlOld.cs | 245 ++++ .../LiquidGlass/LiquidGlassDecorator.cs | 279 ++++ .../LiquidGlass/LiquidGlassDrawOperation.cs | 453 +++++++ .../LiquidGlass/LiquidGlassPlatform.cs | 39 + FreshViewer/LiquidGlass/ShaderDebugger.cs | 125 ++ FreshViewer/Models/ImageMetadata.cs | 42 + FreshViewer/Program.cs | 25 + FreshViewer/Services/ImageLoader.cs | 280 ++++ FreshViewer/Services/LocalizationService.cs | 70 + FreshViewer/Services/MetadataBuilder.cs | 234 ++++ FreshViewer/Services/ShortcutManager.cs | 246 ++++ FreshViewer/Services/ThemeManager.cs | 48 + FreshViewer/Styles/LiquidGlass.Windows.axaml | 54 + FreshViewer/Styles/LiquidGlass.axaml | 54 + .../ViewModels/ImageMetadataViewModels.cs | 32 + FreshViewer/ViewModels/MainWindowViewModel.cs | 299 +++++ FreshViewer/Views/MainWindow.axaml | 493 +++++++ FreshViewer/Views/MainWindow.axaml.cs | 1189 +++++++++++++++++ FreshViewer/app.manifest | 15 + LICENSE | 42 +- Makefile | 45 - README.de.md | 210 --- README.md | 269 +--- README.ru.md | 210 --- pyproject.toml | 162 --- requirements.txt | 14 - 59 files changed, 7983 insertions(+), 2320 deletions(-) create mode 100644 .github/workflows/build-windows.yml delete mode 100644 BlurViewer.py create mode 100644 FreshViewer.sln create mode 100644 FreshViewer/App.axaml create mode 100644 FreshViewer/App.axaml.cs create mode 100644 FreshViewer/Assets/AppIcon.ico create mode 100644 FreshViewer/Assets/Icons/Arrow_Undo_Down_Left.svg create mode 100644 FreshViewer/Assets/Icons/Chevron_Left_MD.svg create mode 100644 FreshViewer/Assets/Icons/Chevron_Right_MD.svg create mode 100644 FreshViewer/Assets/Icons/Folder_Open.svg create mode 100644 FreshViewer/Assets/Icons/open.svg create mode 100644 FreshViewer/Assets/Icons/rotate.svg create mode 100644 FreshViewer/Assets/i18n/Strings.de.axaml create mode 100644 FreshViewer/Assets/i18n/Strings.en.axaml create mode 100644 FreshViewer/Assets/i18n/Strings.ru.axaml create mode 100644 FreshViewer/Assets/i18n/Strings.uk.axaml create mode 100644 FreshViewer/Controls/ImageViewport.cs create mode 100644 FreshViewer/Converters/BooleanToValueConverter.cs create mode 100644 FreshViewer/FreshViewer.csproj create mode 100644 FreshViewer/LiquidGlass/Assets/DisplacementMaps/polar_displacement.jpeg create mode 100644 FreshViewer/LiquidGlass/Assets/DisplacementMaps/prominent_displacement.jpeg create mode 100644 FreshViewer/LiquidGlass/Assets/DisplacementMaps/standard_displacement.jpeg create mode 100644 FreshViewer/LiquidGlass/Assets/LiquidGlassShaderOld.sksl create mode 100644 FreshViewer/LiquidGlass/Assets/Shaders/LiquidGlassShader.sksl create mode 100644 FreshViewer/LiquidGlass/Assets/Shaders/LiquidGlassShader_Fixed.sksl create mode 100644 FreshViewer/LiquidGlass/Assets/Shaders/LiquidGlassShader_New.sksl create mode 100644 FreshViewer/LiquidGlass/DisplacementMapManager.cs create mode 100644 FreshViewer/LiquidGlass/DraggableLiquidGlassCard.cs create mode 100644 FreshViewer/LiquidGlass/LiquidGlassButton.cs create mode 100644 FreshViewer/LiquidGlass/LiquidGlassCard.cs create mode 100644 FreshViewer/LiquidGlass/LiquidGlassControl.cs create mode 100644 FreshViewer/LiquidGlass/LiquidGlassControlOld.cs create mode 100644 FreshViewer/LiquidGlass/LiquidGlassDecorator.cs create mode 100644 FreshViewer/LiquidGlass/LiquidGlassDrawOperation.cs create mode 100644 FreshViewer/LiquidGlass/LiquidGlassPlatform.cs create mode 100644 FreshViewer/LiquidGlass/ShaderDebugger.cs create mode 100644 FreshViewer/Models/ImageMetadata.cs create mode 100644 FreshViewer/Program.cs create mode 100644 FreshViewer/Services/ImageLoader.cs create mode 100644 FreshViewer/Services/LocalizationService.cs create mode 100644 FreshViewer/Services/MetadataBuilder.cs create mode 100644 FreshViewer/Services/ShortcutManager.cs create mode 100644 FreshViewer/Services/ThemeManager.cs create mode 100644 FreshViewer/Styles/LiquidGlass.Windows.axaml create mode 100644 FreshViewer/Styles/LiquidGlass.axaml create mode 100644 FreshViewer/ViewModels/ImageMetadataViewModels.cs create mode 100644 FreshViewer/ViewModels/MainWindowViewModel.cs create mode 100644 FreshViewer/Views/MainWindow.axaml create mode 100644 FreshViewer/Views/MainWindow.axaml.cs create mode 100644 FreshViewer/app.manifest delete mode 100644 Makefile delete mode 100644 README.de.md delete mode 100644 README.ru.md delete mode 100644 pyproject.toml delete mode 100644 requirements.txt diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml new file mode 100644 index 0000000..9c7e441 --- /dev/null +++ b/.github/workflows/build-windows.yml @@ -0,0 +1,38 @@ +name: Build (Windows) + +on: + push: + branches: [ liquid-glass ] + pull_request: + branches: [ liquid-glass ] + +jobs: + build: + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Restore + run: dotnet restore FreshViewer.sln + + - name: Build + run: dotnet build FreshViewer.sln -c Release --no-restore + + - name: Publish (self-contained, single file) + run: | + dotnet publish FreshViewer/FreshViewer.csproj -c Release -r win-x64 --self-contained true \ + -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true \ + -o artifacts/win-x64 + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: FreshViewer-win-x64 + path: artifacts/win-x64 + 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/BlurViewer.py b/BlurViewer.py deleted file mode 100644 index ac49c0e..0000000 --- a/BlurViewer.py +++ /dev/null @@ -1,1106 +0,0 @@ -""" -BlurViewer v0.8.1 -Professional image viewer with advanced format support and smooth animations -""" - -import sys -import os -from pathlib import Path -from typing import Optional -import math - -from PySide6.QtCore import Qt, QTimer, QPointF, QRectF, QThread, Signal, QEasingCurve -from PySide6.QtGui import (QPixmap, QImageReader, QPainter, QWheelEvent, QMouseEvent, - QColor, QImage, QGuiApplication, QMovie) -from PySide6.QtWidgets import QApplication, QWidget, QFileDialog - - -class ImageLoader(QThread): - """Background thread for loading heavy image formats""" - imageLoaded = Signal(QPixmap) - animatedImageLoaded = Signal(str) - loadFailed = Signal(str) - - def __init__(self, path): - super().__init__() - self.path = path - - def run(self): - try: - # Quick format validation for common misnamed files - if not os.path.exists(self.path): - self.loadFailed.emit("File does not exist") - return - - # Check file header for common format issues - try: - with open(self.path, 'rb') as f: - header = f.read(16) - - # Check for video files with wrong extensions - if self.path.lower().endswith(('.gif', '.png', '.jpg')) and header[4:8] == b'ftyp': - self.loadFailed.emit("This is a video file (MP4), not an image. Use a video player instead.") - return - except Exception: - pass - - # Check if it's an animated format first - if Path(self.path).suffix.lower() in BlurViewer.ANIMATED_EXTENSIONS: - movie = self._try_load_animated(self.path) - if movie and movie.isValid(): - # Try to start movie to verify it works - try: - movie.jumpToFrame(0) - first_frame = movie.currentPixmap() - if not first_frame.isNull(): - self.animatedImageLoaded.emit(self.path) - return - except Exception: - pass - - # Load as static image - pixmap = self._load_image_comprehensive(self.path) - if pixmap and not pixmap.isNull(): - self.imageLoaded.emit(pixmap) - else: - self.loadFailed.emit("Failed to load image") - except Exception as e: - self.loadFailed.emit(str(e)) - - def _try_load_animated(self, path: str) -> QMovie: - """Try to load animated image formats using QMovie""" - try: - normalized_path = os.path.normpath(path) - movie = QMovie(normalized_path) - if movie.isValid(): - return movie - except Exception: - pass - return None - - def _register_all_plugins(self): - """Register all available image format plugins""" - try: - import pillow_heif - pillow_heif.register_heif_opener() - except: - pass - - try: - import pillow_avif - except: - pass - - def _load_image_comprehensive(self, path: str) -> QPixmap: - """Comprehensive image loader supporting all formats""" - self._register_all_plugins() - - # Try Qt native formats first - normalized_path = os.path.normpath(path) - - reader = QImageReader(normalized_path) - if reader.canRead(): - qimg = reader.read() - if qimg and not qimg.isNull(): - return QPixmap.fromImage(qimg) - - # Try with Pillow for other formats - try: - from PIL import Image - with open(normalized_path, 'rb') as f: - im = Image.open(f) - im.load() - - if im.mode not in ('RGBA', 'RGB'): - im = im.convert('RGBA') - elif im.mode == 'RGB': - im = im.convert('RGBA') - - data = im.tobytes('raw', 'RGBA') - qimg = QImage(data, im.width, im.height, QImage.Format_RGBA8888) - if not qimg.isNull(): - return QPixmap.fromImage(qimg) - except Exception: - pass - - return None - - -class BlurViewer(QWidget): - # Animation constants - optimized values - LERP_FACTOR = 0.18 # Slightly faster interpolation - PAN_FRICTION = 0.85 # Slightly less friction for smoother panning - NAVIGATION_SPEED = 0.045 # Slower for smoother transitions - ZOOM_FACTOR = 1.2 - ZOOM_STEP = 0.15 - - # Scale limits - MIN_SCALE = 0.1 - MAX_SCALE = 20.0 - MIN_REFRESH_INTERVAL = 8 # 125 FPS max - - # Opacity values - WINDOWED_BG_OPACITY = 200.0 - FULLSCREEN_BG_OPACITY = 250.0 - - # Supported file extensions - ANIMATED_EXTENSIONS = {'.gif', '.mng'} - RAW_EXTENSIONS = {'.cr2', '.cr3', '.nef', '.arw', '.dng', '.raf', '.orf', - '.rw2', '.pef', '.srw', '.x3f', '.mrw', '.dcr', '.kdc', - '.erf', '.mef', '.mos', '.ptx', '.r3d', '.fff', '.iiq'} - - def __init__(self, image_path: Optional[str] = None): - super().__init__() - - # Window setup - self.setWindowFlags(Qt.FramelessWindowHint | Qt.Window) - self.setAttribute(Qt.WA_TranslucentBackground) - self.setFocusPolicy(Qt.StrongFocus) - self.setAcceptDrops(True) - - # Cache screen geometry to avoid repeated calls - self._screen_geom = None - self._screen_center = None - self._current_pixmap_cache = None # Cache for current pixmap - - # Image state - self.pixmap: Optional[QPixmap] = None - self.image_path = None - self.movie: Optional[QMovie] = None - self.rotation = 0.0 - - # Directory navigation - self.current_directory = None - self.image_files = [] - self.current_index = -1 - - # Transform state - self.target_scale = 1.0 - self.current_scale = 1.0 - self.target_offset = QPointF(0, 0) - self.current_offset = QPointF(0, 0) - self.fit_scale = 1.0 - - # Animation parameters - self.lerp_factor = self.LERP_FACTOR - self.pan_friction = self.PAN_FRICTION - - # Navigation animation - self.navigation_animation = False - self.navigation_direction = 0 - self.navigation_progress = 0.0 - self.old_pixmap = None - self.new_pixmap = None - - # Interaction state - self.is_panning = False - self.last_mouse_pos = QPointF(0, 0) - self.pan_velocity = QPointF(0, 0) - - # Opening animation - self.opening_animation = True - self.opening_scale = 0.8 - self.opening_opacity = 0.0 - - # Closing animation - self.closing_animation = False - self.closing_scale = 1.0 - self.closing_opacity = 1.0 - - # Background fade - self.background_opacity = 0.0 - self.target_background_opacity = self.WINDOWED_BG_OPACITY - - # Fullscreen state - self.is_fullscreen = False - self.saved_scale = 1.0 - self.saved_offset = QPointF(0, 0) - - # Performance optimization - self.update_pending = False - self.loading_thread: Optional[ImageLoader] = None - self._needs_cache_update = True # Flag for pixmap cache - - # Main animation timer with adaptive FPS - self.timer = QTimer(self) - refresh_interval = self._get_monitor_refresh_interval() - self.timer.setInterval(refresh_interval) - self.timer.timeout.connect(self.animate) - self.timer.start() - - # Load image - if image_path: - self.load_image(image_path) - else: - self.open_dialog_and_load() - - def _get_monitor_refresh_interval(self) -> int: - """Get monitor refresh interval in milliseconds""" - try: - screen = QApplication.primaryScreen() - if screen: - refresh_rate = screen.refreshRate() - if refresh_rate > 0: - interval = int(1000.0 / refresh_rate) - return max(interval, self.MIN_REFRESH_INTERVAL) - except Exception: - pass - - return 16 # Fallback to 60 FPS - - def _get_current_pixmap(self) -> QPixmap: - """Get current pixmap with caching""" - if self._needs_cache_update: - if self.pixmap: - self._current_pixmap_cache = self.pixmap - elif self.movie: - self._current_pixmap_cache = self.movie.currentPixmap() - else: - self._current_pixmap_cache = QPixmap() - self._needs_cache_update = False - - return self._current_pixmap_cache or QPixmap() - - def _invalidate_pixmap_cache(self): - """Invalidate the pixmap cache""" - self._needs_cache_update = True - - def _get_screen_info(self): - """Get cached screen geometry and center""" - if self._screen_geom is None: - self._screen_geom = QApplication.primaryScreen().availableGeometry() - self._screen_center = QPointF(self._screen_geom.center()) - return self._screen_geom, self._screen_center - - def _clear_screen_cache(self): - """Clear cached screen info""" - self._screen_geom = None - self._screen_center = None - - def get_image_files_in_directory(self, directory_path: str): - """Get list of supported image files in directory""" - if not directory_path: - return [] - - directory = Path(directory_path) - if not directory.is_dir(): - return [] - - # Supported extensions - supported_exts = { - '.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' - } | self.RAW_EXTENSIONS - - image_files = [] - try: - for file_path in directory.iterdir(): - if file_path.is_file() and file_path.suffix.lower() in supported_exts: - image_files.append(str(file_path)) - except (OSError, PermissionError): - return [] - - return sorted(image_files, key=lambda x: Path(x).name.lower()) - - def setup_directory_navigation(self, image_path: str): - """Setup directory navigation for the current image""" - if not image_path: - return - - path_obj = Path(image_path) - self.current_directory = str(path_obj.parent) - self.image_files = self.get_image_files_in_directory(self.current_directory) - - try: - self.current_index = self.image_files.index(str(path_obj)) - except ValueError: - self.current_index = -1 - - def navigate_to_image(self, direction: int): - """Navigate to next/previous image in directory""" - if not self.image_files or self.current_index == -1 or self.navigation_animation: - return - - new_index = (self.current_index + direction) % len(self.image_files) - if new_index == self.current_index: - return - - # Setup slide animation only in windowed mode - if not self.is_fullscreen: - self.old_pixmap = self._get_current_pixmap() - self.navigation_direction = direction - self.navigation_progress = 0.0 - self.navigation_animation = True - - self.current_index = new_index - new_path = self.image_files[self.current_index] - - # Load new image in background - if self.loading_thread and self.loading_thread.isRunning(): - self.loading_thread.quit() - self.loading_thread.wait() - - self.loading_thread = ImageLoader(new_path) - self.loading_thread.imageLoaded.connect(self._on_navigation_image_loaded) - self.loading_thread.animatedImageLoaded.connect(self._on_navigation_animated_loaded) - self.loading_thread.loadFailed.connect(self._on_load_failed) - self.loading_thread.start() - - def _on_navigation_image_loaded(self, pixmap: QPixmap): - """Handle successful navigation image loading""" - self._invalidate_pixmap_cache() - - if self.is_fullscreen: - # Stop any movie - if self.movie: - self.movie.stop() - self.movie.deleteLater() - self.movie = None - - self.pixmap = pixmap - self.rotation = 0.0 - self._fit_to_fullscreen_instant() - else: - # Use slide animation in windowed mode - self.new_pixmap = pixmap - self.rotation = 0.0 - - def _on_navigation_animated_loaded(self, path: str): - """Handle successful navigation animated image loading""" - self._invalidate_pixmap_cache() - - if self.is_fullscreen: - # Stop any existing movie - if self.movie: - self.movie.stop() - self.movie.deleteLater() - self.movie = None - - # Create new movie - normalized_path = os.path.normpath(path) - self.movie = QMovie(normalized_path) - - if self.movie and self.movie.isValid(): - self.pixmap = None - self.rotation = 0.0 - - self.movie.frameChanged.connect(self._on_movie_frame_changed) - self.movie.jumpToFrame(0) - first_frame = self.movie.currentPixmap() - if not first_frame.isNull(): - self.pixmap = first_frame - self._fit_to_fullscreen_instant() - self.pixmap = None - self.movie.start() - else: - # Use slide animation in windowed mode - normalized_path = os.path.normpath(path) - temp_movie = QMovie(normalized_path) - - if temp_movie and temp_movie.isValid(): - temp_movie.jumpToFrame(0) - first_frame = temp_movie.currentPixmap() - if not first_frame.isNull(): - self.new_pixmap = first_frame - self.rotation = 0.0 - temp_movie.deleteLater() - - def _on_movie_frame_changed(self): - """Handle movie frame change""" - self._invalidate_pixmap_cache() - self.schedule_update() - - def close_application(self): - """Start closing animation and exit""" - if not self.closing_animation: - self.closing_animation = True - self.target_background_opacity = 0.0 - QTimer.singleShot(300, QApplication.instance().quit) - - def get_supported_formats(self): - """Get comprehensive list of supported formats""" - base_formats = [ - "*.png", "*.jpg", "*.jpeg", "*.bmp", "*.gif", "*.mng", "*.webp", "*.tiff", "*.tif", - "*.ico", "*.svg", "*.pbm", "*.pgm", "*.ppm", "*.xbm", "*.xpm" - ] - - raw_formats = ["*" + ext for ext in self.RAW_EXTENSIONS] - modern_formats = ["*.heic", "*.heif", "*.avif", "*.jxl"] - scientific_formats = ["*.fits", "*.hdr", "*.exr", "*.pic", "*.psd"] - - return base_formats + raw_formats + modern_formats + scientific_formats - - def open_dialog_and_load(self): - formats = self.get_supported_formats() - filter_str = f"All Supported Images ({' '.join(formats)})" - - fname, _ = QFileDialog.getOpenFileName( - self, "Open image", str(Path.home()), filter_str - ) - if fname: - self.load_image(fname) - else: - QTimer.singleShot(0, QApplication.instance().quit) - - def load_image(self, path: str): - """Load image with comprehensive format support""" - self.image_path = path - self.setup_directory_navigation(path) - - # Reset animations - if self.pixmap: - self.opening_animation = True - self.opening_scale = 0.95 - self.opening_opacity = 0.2 - - # Stop any existing movie - if self.movie: - self.movie.stop() - self.movie = None - - self._invalidate_pixmap_cache() - - # Background loading - if self.loading_thread and self.loading_thread.isRunning(): - self.loading_thread.quit() - self.loading_thread.wait() - - self.loading_thread = ImageLoader(path) - self.loading_thread.imageLoaded.connect(self._on_image_loaded) - self.loading_thread.animatedImageLoaded.connect(self._on_animated_image_loaded) - self.loading_thread.loadFailed.connect(self._on_load_failed) - self.loading_thread.start() - - def _on_image_loaded(self, pixmap: QPixmap): - """Handle successful image loading""" - # Stop any existing movie - if self.movie: - self.movie.stop() - self.movie = None - - self.pixmap = pixmap - self.rotation = 0.0 - self._invalidate_pixmap_cache() - self._setup_image_display() - - def _on_animated_image_loaded(self, path: str): - """Handle successful animated image loading""" - # Stop any existing movie - if self.movie: - self.movie.stop() - self.movie.deleteLater() - self.movie = None - - # Create new movie - normalized_path = os.path.normpath(path) - self.movie = QMovie(normalized_path) - - if self.movie.isValid(): - self.pixmap = None - self.rotation = 0.0 - self._invalidate_pixmap_cache() - - # Setup movie for display - self.movie.frameChanged.connect(self._on_movie_frame_changed) - # Get first frame for sizing - self.movie.jumpToFrame(0) - first_frame = self.movie.currentPixmap() - - if not first_frame.isNull(): - self.pixmap = first_frame # Temporary for sizing calculations - self._setup_image_display() - self.pixmap = None # Clear static pixmap, use movie instead - self.movie.start() - else: - self._on_load_failed("QMovie invalid") - - def _on_load_failed(self, error: str): - """Handle loading failure""" - if self.navigation_animation: - self.navigation_animation = False - self.old_pixmap = None - self.new_pixmap = None - - if not self.pixmap and not self.movie: - if "video file" not in error: - print(f"Failed to load image: {error}") - QTimer.singleShot(3000, QApplication.instance().quit) - - def _setup_image_display(self): - """Setup display parameters after image is loaded""" - current_pixmap = self._get_current_pixmap() - if not current_pixmap or current_pixmap.isNull(): - return - - screen_geom, screen_center = self._get_screen_info() - - # Calculate fit-to-screen scale - if current_pixmap.width() > 0 and current_pixmap.height() > 0: - scale_x = (screen_geom.width() * 0.9) / current_pixmap.width() - scale_y = (screen_geom.height() * 0.9) / current_pixmap.height() - self.fit_scale = min(scale_x, scale_y, 1.0) - else: - self.fit_scale = 1.0 - - # Set initial transform - self.target_scale = self.fit_scale - self.current_scale = self.fit_scale * 0.8 - - # Center the image - self.target_offset = screen_center - self.current_offset = screen_center - - # Setup window - self.resize(screen_geom.width(), screen_geom.height()) - self.move(screen_geom.topLeft()) - - # Reset animation states - self.opening_animation = True - self.opening_scale = 0.8 - self.opening_opacity = 0.0 - self.background_opacity = 0.0 - self.target_background_opacity = self.WINDOWED_BG_OPACITY - self.pan_velocity = QPointF(0, 0) - - self.schedule_update() - - def get_image_bounds(self) -> QRectF: - """Get the bounds of the image in screen coordinates""" - current_pixmap = self._get_current_pixmap() - if not current_pixmap or current_pixmap.isNull(): - return QRectF() - - img_w = current_pixmap.width() * self.current_scale - img_h = current_pixmap.height() * self.current_scale - - return QRectF( - self.current_offset.x() - img_w / 2, - self.current_offset.y() - img_h / 2, - img_w, - img_h - ) - - def point_in_image(self, point: QPointF) -> bool: - """Check if point is inside the image""" - bounds = self.get_image_bounds() - return bounds.contains(point) - - def _calculate_effective_dimensions(self): - """Calculate effective image dimensions considering rotation""" - current_pixmap = self._get_current_pixmap() - if not current_pixmap or current_pixmap.isNull(): - return 1, 1 - - if self.rotation % 180 == 90: # 90° or 270° - return current_pixmap.height(), current_pixmap.width() - else: # 0° or 180° - return current_pixmap.width(), current_pixmap.height() - - def zoom_to(self, new_scale: float, focus_point: QPointF = None): - """Zoom to specific scale with focus point""" - if not self.pixmap and not self.movie: - return - - # Restrict zoom in fullscreen mode - if self.is_fullscreen: - screen_geom = QApplication.primaryScreen().geometry() - effective_width, effective_height = self._calculate_effective_dimensions() - if effective_width > 0 and effective_height > 0: - scale_x = screen_geom.width() / effective_width - scale_y = screen_geom.height() / effective_height - min_fullscreen_scale = min(scale_x, scale_y) - new_scale = max(min_fullscreen_scale, new_scale) - - # Clamp scale - new_scale = max(self.MIN_SCALE, min(self.MAX_SCALE, new_scale)) - - # Determine focus point - if focus_point is None: - focus_point = self.current_offset - elif not self.point_in_image(focus_point): - focus_point = self.current_offset - - # Calculate the point in image space that should stay under the focus - old_scale = self.current_scale - if old_scale > 0: - dx = focus_point.x() - self.current_offset.x() - dy = focus_point.y() - self.current_offset.y() - - scale_ratio = new_scale / old_scale - new_dx = dx * scale_ratio - new_dy = dy * scale_ratio - - self.target_offset = QPointF( - focus_point.x() - new_dx, - focus_point.y() - new_dy - ) - - self.target_scale = new_scale - - def fit_to_screen(self): - """Fit image to screen - FIXED centering bug""" - if not self.pixmap and not self.movie: - return - - screen_geom, screen_center = self._get_screen_info() - - # Recalculate fit scale in case of rotation changes - current_pixmap = self._get_current_pixmap() - if current_pixmap and not current_pixmap.isNull(): - effective_width, effective_height = self._calculate_effective_dimensions() - if effective_width > 0 and effective_height > 0: - scale_x = (screen_geom.width() * 0.9) / effective_width - scale_y = (screen_geom.height() * 0.9) / effective_height - self.fit_scale = min(scale_x, scale_y, 1.0) - - self.target_scale = self.fit_scale - self.target_offset = screen_center - # Reset current offset to ensure smooth centering - self.current_offset = QPointF(self.current_offset) # Create a copy to force update - - def toggle_fullscreen(self): - """Toggle fullscreen mode""" - self.is_fullscreen = not self.is_fullscreen - self._clear_screen_cache() - - if self.is_fullscreen: - self.saved_scale = self.target_scale - self.saved_offset = QPointF(self.target_offset) - self.showFullScreen() - self.target_background_opacity = self.FULLSCREEN_BG_OPACITY - self._fit_to_fullscreen() - else: - self.showNormal() - screen_geom, _ = self._get_screen_info() - self.resize(screen_geom.width(), screen_geom.height()) - self.move(screen_geom.topLeft()) - self.target_scale = self.saved_scale - self.target_offset = self.saved_offset - self.target_background_opacity = self.WINDOWED_BG_OPACITY - - def _fit_to_fullscreen(self): - """Fit image to fullscreen with animation""" - if not self.pixmap and not self.movie: - return - - screen_geom = QApplication.primaryScreen().geometry() - screen_center = QPointF(screen_geom.center()) - - effective_width, effective_height = self._calculate_effective_dimensions() - if effective_width > 0 and effective_height > 0: - scale_x = screen_geom.width() / effective_width - scale_y = screen_geom.height() / effective_height - fit_scale = min(scale_x, scale_y) - else: - fit_scale = 1.0 - - self.target_scale = fit_scale - self.target_offset = screen_center - - def _fit_to_fullscreen_instant(self): - """Fit image to fullscreen instantly without animation""" - if not self.pixmap and not self.movie: - return - - screen_geom = QApplication.primaryScreen().geometry() - screen_center = QPointF(screen_geom.center()) - - effective_width, effective_height = self._calculate_effective_dimensions() - if effective_width > 0 and effective_height > 0: - scale_x = screen_geom.width() / effective_width - scale_y = screen_geom.height() / effective_height - fit_scale = min(scale_x, scale_y) - else: - fit_scale = 1.0 - - self.target_scale = fit_scale - self.current_scale = fit_scale - self.target_offset = screen_center - self.current_offset = screen_center - self.schedule_update() - - def wheelEvent(self, e: QWheelEvent): - """Handle zoom with mouse wheel""" - if not self.pixmap and not self.movie: - return - - delta = e.angleDelta().y() / 120.0 - zoom_factor = 1.0 + (delta * self.ZOOM_STEP) - new_scale = self.target_scale * zoom_factor - self.zoom_to(new_scale, e.position()) - e.accept() - - def mousePressEvent(self, e: QMouseEvent): - """Handle mouse press""" - if e.button() == Qt.LeftButton: - if self.point_in_image(e.position()): - self.is_panning = True - self.last_mouse_pos = e.position() - self.pan_velocity = QPointF(0, 0) - e.accept() - else: - # Exit only in windowed mode - if not self.is_fullscreen: - self.close_application() - elif e.button() == Qt.RightButton: - self.close_application() - - def mouseMoveEvent(self, e: QMouseEvent): - """Handle mouse move""" - if self.is_panning: - delta = e.position() - self.last_mouse_pos - self.current_offset += delta - self.target_offset = QPointF(self.current_offset) - self.pan_velocity = delta * 0.6 - self.last_mouse_pos = e.position() - self.schedule_update() - e.accept() - - def mouseReleaseEvent(self, e: QMouseEvent): - """Handle mouse release""" - if e.button() == Qt.LeftButton: - self.is_panning = False - e.accept() - - def mouseDoubleClickEvent(self, e: QMouseEvent): - """Handle double click - toggle between fit and 100%""" - if not self.pixmap and not self.movie: - return - - if self.is_fullscreen: - screen_geom = QApplication.primaryScreen().geometry() - effective_width, effective_height = self._calculate_effective_dimensions() - if effective_width > 0 and effective_height > 0: - scale_x = screen_geom.width() / effective_width - scale_y = screen_geom.height() / effective_height - fullscreen_fit_scale = min(scale_x, scale_y) - - if abs(self.current_scale - fullscreen_fit_scale) < 0.01: - self.zoom_to(1.0, e.position()) - else: - self._fit_to_fullscreen() - e.accept() - return - - if abs(self.target_scale - self.fit_scale) < 0.01: - self.zoom_to(1.0, e.position()) - else: - self.fit_to_screen() - - e.accept() - - def animate(self): - """Main animation loop - optimized""" - needs_update = False - - # Navigation slide animation with improved easing - if self.navigation_animation: - # Use smoother easing curve - self.navigation_progress = min(1.0, self.navigation_progress + self.NAVIGATION_SPEED) - - if self.navigation_progress >= 1.0: - self.navigation_animation = False - - # Handle animated images - if self.new_pixmap: - # Stop any existing movie - if self.movie: - self.movie.stop() - self.movie.deleteLater() - self.movie = None - - self.pixmap = self.new_pixmap - self.new_pixmap = None - self._invalidate_pixmap_cache() - - self.old_pixmap = None - - # Smooth transition to centered position - _, screen_center = self._get_screen_info() - self.target_offset = screen_center - - # Reset to fit scale for new image - current_pixmap = self._get_current_pixmap() - if current_pixmap and not current_pixmap.isNull(): - screen_geom, _ = self._get_screen_info() - scale_x = (screen_geom.width() * 0.9) / current_pixmap.width() - scale_y = (screen_geom.height() * 0.9) / current_pixmap.height() - self.fit_scale = min(scale_x, scale_y, 1.0) - self.target_scale = self.fit_scale - - needs_update = True - - # Opening animation - if self.opening_animation: - self.opening_scale = min(1.0, self.opening_scale + (1.0 - self.opening_scale) * 0.15) - self.opening_opacity = min(1.0, self.opening_opacity + (1.0 - self.opening_opacity) * 0.2) - - if self.opening_scale > 0.99 and self.opening_opacity > 0.99: - self.opening_scale = 1.0 - self.opening_opacity = 1.0 - self.opening_animation = False - - needs_update = True - - # Closing animation - if self.closing_animation: - self.closing_scale += (0.7 - self.closing_scale) * 0.25 - self.closing_opacity += (0.0 - self.closing_opacity) * 0.25 - needs_update = True - - # Background fade animation - bg_diff = self.target_background_opacity - self.background_opacity - if abs(bg_diff) > 1.0: - self.background_opacity += bg_diff * 0.15 - needs_update = True - - # Pan inertia - if not self.is_panning and (abs(self.pan_velocity.x()) > 0.1 or abs(self.pan_velocity.y()) > 0.1): - self.target_offset += self.pan_velocity - self.pan_velocity *= self.pan_friction - needs_update = True - - # Smooth interpolation to target values - scale_diff = self.target_scale - self.current_scale - offset_diff = self.target_offset - self.current_offset - - if abs(scale_diff) > 0.001: - self.current_scale += scale_diff * self.lerp_factor - needs_update = True - - if abs(offset_diff.x()) > 0.1 or abs(offset_diff.y()) > 0.1: - self.current_offset += offset_diff * self.lerp_factor - needs_update = True - - if needs_update: - self.schedule_update() - - def schedule_update(self): - """Schedule update to avoid excessive redraws""" - if not self.update_pending: - self.update_pending = True - QTimer.singleShot(0, self._do_update) - - def _do_update(self): - """Perform the actual update""" - self.update_pending = False - self.update() - - def dragEnterEvent(self, event): - """Handle drag enter""" - if event.mimeData().hasUrls(): - event.accept() - else: - event.ignore() - - def dropEvent(self, event): - """Handle file drop""" - urls = event.mimeData().urls() - if urls: - path = urls[0].toLocalFile() - if Path(path).is_file(): - self.load_image(path) - - def keyPressEvent(self, e): - """Handle keyboard input""" - if e.key() == Qt.Key_Escape: - self.close_application() - return - - if e.key() == Qt.Key_F11: - self.toggle_fullscreen() - e.accept() - return - - # Directory navigation with A and D keys - key_text = e.text().lower() - if e.key() == Qt.Key_A or key_text == 'a' or key_text == 'ф': - self.navigate_to_image(-1) - e.accept() - return - elif e.key() == Qt.Key_D or key_text == 'd' or key_text == 'в': - self.navigate_to_image(1) - e.accept() - return - - # Zoom with +/- keys - if e.key() == Qt.Key_Plus or e.key() == Qt.Key_Equal: - self._keyboard_zoom(self.ZOOM_FACTOR) - e.accept() - return - elif e.key() == Qt.Key_Minus: - self._keyboard_zoom(1.0 / self.ZOOM_FACTOR) - e.accept() - return - - # Copy to clipboard - if e.modifiers() & Qt.ControlModifier: - if e.key() == Qt.Key_C or key_text == 'c' or key_text == 'с': - current_pixmap = self._get_current_pixmap() - if current_pixmap and not current_pixmap.isNull(): - QGuiApplication.clipboard().setPixmap(current_pixmap) - return - - # Rotate - if e.key() == Qt.Key_R or key_text == 'r' or key_text == 'к': - self.rotation = (self.rotation + 90) % 360 - self._invalidate_pixmap_cache() - if self.is_fullscreen: - self._fit_to_fullscreen_instant() - else: - # Recalculate fit scale after rotation - self.fit_to_screen() - return - - # Fit to screen - if (e.key() == Qt.Key_F or key_text == 'f' or key_text == 'а' or - e.key() == Qt.Key_Space): - if self.is_fullscreen: - self._fit_to_fullscreen() - else: - self.fit_to_screen() - return - - super().keyPressEvent(e) - - def _keyboard_zoom(self, factor: float): - """Handle keyboard zoom with given factor""" - if self.pixmap or self.movie: - _, screen_center = self._get_screen_info() - new_scale = self.target_scale * factor - self.zoom_to(new_scale, screen_center) - - def _ease_in_out_cubic(self, t: float) -> float: - """Smooth easing function for animations""" - if t < 0.5: - return 4 * t * t * t - else: - p = 2 * t - 2 - return 1 + p * p * p / 2 - - def paintEvent(self, event): - """Main paint event - optimized""" - painter = QPainter(self) - painter.setRenderHint(QPainter.SmoothPixmapTransform, True) - painter.setRenderHint(QPainter.Antialiasing, True) - - # Draw dark background with smooth fade - bg_color = QColor(0, 0, 0, int(self.background_opacity)) - painter.fillRect(self.rect(), bg_color) - - # Navigation slide animation - if self.navigation_animation and self.old_pixmap and self.new_pixmap: - self._draw_slide_animation(painter) - elif self.pixmap: - self._draw_single_image(painter, self.pixmap) - elif self.movie and self.movie.state() == QMovie.MovieState.Running: - current_pixmap = self.movie.currentPixmap() - if not current_pixmap.isNull(): - self._draw_single_image(painter, current_pixmap) - - def _draw_slide_animation(self, painter): - """Draw sliding animation between two images - improved smoothness""" - # Use smooth easing curve - t = self._ease_in_out_cubic(self.navigation_progress) - - screen_width = self.width() - # Reduced slide distance for less jarring transition - slide_distance = screen_width * 0.8 - - # Add parallax effect for depth - parallax_factor = 0.3 - - if self.navigation_direction > 0: # Next image - old_x_offset = -slide_distance * t * parallax_factor - new_x_offset = slide_distance * (1 - t) - else: # Previous image - old_x_offset = slide_distance * t * parallax_factor - new_x_offset = -slide_distance * (1 - t) - - # Draw old image with fade and scale - painter.save() - painter.translate(old_x_offset, 0) - old_scale = 1.0 - t * 0.05 # Subtle scale down - painter.scale(old_scale, old_scale) - painter.setOpacity(1.0 - t * 0.5) # Smoother fade - self._draw_single_image(painter, self.old_pixmap) - painter.restore() - - # Draw new image with fade and scale - painter.save() - painter.translate(new_x_offset, 0) - new_scale = 0.95 + t * 0.05 # Scale up to normal - painter.scale(new_scale, new_scale) - painter.setOpacity(0.5 + t * 0.5) # Fade in - self._draw_single_image(painter, self.new_pixmap) - painter.restore() - - def _draw_single_image(self, painter, pixmap): - """Draw a single image with current transforms""" - if not pixmap or pixmap.isNull(): - return - - # Calculate image dimensions - final_scale = self.current_scale - if self.opening_animation: - final_scale *= self.opening_scale - elif self.closing_animation: - final_scale *= self.closing_scale - - img_w = pixmap.width() * final_scale - img_h = pixmap.height() * final_scale - - # Draw image - painter.save() - painter.translate(self.current_offset) - - if self.rotation != 0: - painter.rotate(self.rotation) - - # Set opacity for animations - current_opacity = painter.opacity() - if self.opening_animation: - painter.setOpacity(current_opacity * self.opening_opacity) - elif self.closing_animation: - painter.setOpacity(current_opacity * self.closing_opacity) - - # Draw the pixmap centered - target_rect = QRectF(-img_w / 2, -img_h / 2, img_w, img_h) - source_rect = QRectF(pixmap.rect()) - - painter.drawPixmap(target_rect, pixmap, source_rect) - painter.restore() - - def closeEvent(self, event): - """Clean up on close""" - if self.loading_thread and self.loading_thread.isRunning(): - self.loading_thread.quit() - self.loading_thread.wait() - - # Clean up movie - if self.movie: - self.movie.stop() - self.movie.deleteLater() - self.movie = None - - super().closeEvent(event) - - -def main(): - """Main entry point for the application""" - app = QApplication(sys.argv) - - path = None - if len(sys.argv) >= 2: - path = sys.argv[1] - - viewer = BlurViewer(path) - viewer.show() - - sys.exit(app.exec()) - - -if __name__ == '__main__': - main() \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 94220e5..293fa3f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,123 +1,20 @@ -# Contributing to BlurViewer +## Contributing -Thank you for your interest in contributing to BlurViewer! This document provides guidelines and information for contributors. +Спасибо за помощь проекту! Чтобы упростить ревью и сборку, придерживайтесь коротких правил ниже. -## 🚀 Quick Start +### Быстрый старт +1. Форкните репозиторий и создайте ветку: `feature/<кратко-о-змениях>`. +2. Восстановите пакеты: `dotnet restore FreshViewer.sln`. +3. Запустите приложение: `dotnet run --project FreshViewer/FreshViewer.csproj`. -1. **Fork** the repository -2. **Clone** your fork: `git clone https://github.com/yourusername/BlurViewer.git` -3. **Create** a feature branch: `git checkout -b feature/amazing-feature` -4. **Make** your changes and test thoroughly -5. **Commit** with clear messages: `git commit -m 'Add amazing feature'` -6. **Push** to your branch: `git push origin feature/amazing-feature` -7. **Create** a Pull Request with detailed description +### Стиль и качество +- Цель — чистый, читаемый C# с явными именами и проверкой ошибок. +- Избегайте неиспользуемых `using`, не оставляйте закомментированный код. +- Перед PR: `dotnet build -c Release` должен проходить без ошибок. -## 🛠️ Development Setup +### Публикация PR +- Короткое описание задачи и скрин/видео для UI изменений. +- Крупные правки делите на логические коммиты. +- Приветствуются самостоятельные правки документации. -### Prerequisites -- Python 3.8 or higher -- Git - -### Installation -```bash -# Clone the repository -git clone https://github.com/amtiYo/BlurViewer.git -cd BlurViewer - -# Create virtual environment -python -m venv .venv -source .venv/bin/activate # On Windows: .venv\Scripts\activate - -# Install dependencies -pip install -e . - -# Run the application -python BlurViewer.py -``` - -### Using Makefile (optional) -```bash -make install-package # Install package in development mode -make run # Run the application -make format # Format code -make lint # Run linting -``` - -## 📝 Code Style - -- Follow PEP 8 style guidelines -- Use meaningful variable and function names -- Add comments for complex logic -- Keep functions small and focused -- Write docstrings for public functions - -### Code Formatting -```bash -# Format code with black -black BlurViewer.py - -# Check code style with flake8 -flake8 BlurViewer.py -``` - -## 🧪 Testing - -Before submitting a pull request, please ensure: - -1. **Code runs without errors** - Test the application thoroughly -2. **No new warnings** - Fix any linting issues -3. **Backward compatibility** - Don't break existing functionality -4. **Cross-platform compatibility** - Test on different operating systems if possible - -## 📋 Pull Request Guidelines - -### Before submitting a PR: - -1. **Update documentation** if needed -2. **Add tests** for new features -3. **Update requirements.txt** if adding new dependencies -4. **Test on different image formats** if making changes to image processing -5. **Check performance** - ensure no significant performance regressions - -### PR Description should include: - -- **Summary** of changes -- **Motivation** for the change -- **Testing** performed -- **Screenshots** if UI changes -- **Breaking changes** if any - -## 🐛 Bug Reports - -When reporting bugs, please include: - -- **Operating system** and version -- **Python version** -- **Steps to reproduce** -- **Expected behavior** -- **Actual behavior** -- **Screenshots** if applicable -- **Error messages** if any - -## 💡 Feature Requests - -When suggesting features: - -- **Describe the feature** clearly -- **Explain the use case** -- **Provide examples** if possible -- **Consider implementation complexity** - -## 📄 License - -By contributing to BlurViewer, you agree that your contributions will be licensed under the MIT License. - -## 🤝 Questions? - -If you have questions about contributing, feel free to: - -- Open an issue for discussion -- Ask in the discussions section -- Contact the maintainer directly - -Thank you for contributing to BlurViewer! 🎉 +Спасибо! diff --git a/FreshViewer.sln b/FreshViewer.sln new file mode 100644 index 0000000..a239ef1 --- /dev/null +++ b/FreshViewer.sln @@ -0,0 +1,18 @@ +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 +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 + 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..9331b36 --- /dev/null +++ b/FreshViewer/App.axaml.cs @@ -0,0 +1,78 @@ +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; + +public partial class App : Application +{ + public override void Initialize() + { + try + { + AvaloniaXamlLoader.Load(this); + + // Загружаем дополнительные стили LiquidGlass только если основная загрузка прошла успешно + 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/FreshViewer/Assets/AppIcon.ico b/FreshViewer/Assets/AppIcon.ico new file mode 100644 index 0000000000000000000000000000000000000000..159c2fdcc564c19cc2e96ab6352a4a4039a5f5ba GIT binary patch literal 15406 zcmeHOU5p!76`oDfO)H>HA1W0p!9E~bgp^hws#d(9s;Ct4zyl9FL<<55!3%8=G(T-v z)ISNOQh^k~(o(7X2)a$1mM)D1Qrc*h5~azu(c-n&@$TB|pRv9E_sn=^8~Jd)JHE5y z@z2`p@rD&^>C+vL=ic+3bI-l^%)Q5oGNSBO_U%)+98-R6X=_*hz6}w_F6x#1?BfhkB zr=e@q3zqoVmiqDdoo3A-iJGBzY^z)S!rb2}_2y36h|=1&Cjm){U=cB_xN!NW>3 zj_jt;#7&|N=+{^MLkdNoe}IDA2Do&qZ<#epO&_Aj@!K8w(cgcCmY%zZ(*Js#e>=Qk zeU+c7#@}bYH&Xbi+ephUQTm^cQ%$dm`mk#!{%e*=>OT&XRnw{P!AUBI&$#+S>!H5I z^^cHcR7D%6p%G96^or1}ll%<#|A%X9RQk^}rMV685MX_vQ}oq?_0)T>P_a;;g@uI{ z|H))BqCVPKv#bV8hrg<-^<*+h;c)o%si~<^um8F0ueV_m`UJ@3a+HY2+tx)}>2!L3 z%`mP42B7NV^==ocZqpsjPYd#=BI7;8R5R%?I(m8dNcz2(t%K%wpjL+cIZk}^deL?}KY+~(mtSlAK0>+o z{>ayR?J?7co%@j=_sBOeW>a`KllJ(BGM7 zSbwyI*f06&4?P@4C_no*G72eLc;Z%yzVM)kM-dZB`Dh0m`jVeYjU#3SYUQkG3vHTO znU;R@CH~HFJokC()Q_AvupF5YJcK+S<(m~-zKDG}Uer164nO$`S~_tLnR;2okkVGN zY_ET;=k+(`(%+F^s|tI-CKc9IPcJwztduKDf8^V-wpm$q`d{`5N;y7cDP^M^RDOo_ zN30H5l^n|-B}*&X?;qkFL=)GLgMKmL8%9OQ#l2d!AnIVv5`2XJ^=|)W`Kx060SEYW zZx|JIuyze9AMZTq14|$$uHh3Bn4d64>@q{S)N(x)3lor5Qw)_ z`vE-GukEoZ{Xq6Te1YG^>g>OiNF-=wWrdcPmnj$w2BxQ{Z|ybyY$qSGB?qq`Lca7v zeqW>2)m7p)SZ|dgkx1b5>C+$YRW_dMB!69hjE!|0xaDWFS<2`0fs$7q@^h?v zu~@`A?WSZ8Dj&8_r`5pOvuFFVIpoR@uYU)$YSnV1%HJJSDps{pc2F*rEZ*M|;5ch; z5ZT~>_w~)bd!nw?W2gMzw*dA@c#Br~4r3eclQ8R@4=iAh*NM@osrHzdm>3-vAbZeW z2!(x_(goErx%ZXPKf~d#QU3jtlsxq?C139oryk;Z?UA27$vUzQ zt~2$a@aKIHaGyUk!oYsC9~cpOp@016pJ*Dt*vEn0!P~p%+28jOKBK=*>=&Xf^vibP z55AFSK1ZRS-L(DqGxsaMxSdvB`X=`u_W|$OZYTR=KRf&OarPbT`Pn|WZTLUBSLAC% zU-(+netjqYQD4?ypT;ly4S28q`*~XU`OUWN`Cj}mMV?Yu?ijC6e~OBMw-~2+&bd+W4?T7RW#4&LoMjD@zt{d& zO|$tQy`FQO7TG7h+T_bEx*Xk0nYW)Iqmrk^XTM-S%Q_`q{+>7wsBv83e%Ao;>^~1^;jGx8vn1<=&m-v9QM;o0wOg`Xp5r&pABBa~!kk`MAh$lXkwA z{4Mq$F(ZKRWRH7oYoh?5{iaxAc;8UILz4 z;xeC~-b*Wg`WDqns<1uochh0(`LFwro4-8s($gU_iYe#Z3HH90{M8owyZE#JxW|UP zmVBk{W$E=Dd>^*`!C#)QiL*Y_XzM?cyX*(a)vJrVFUxDg|Dc$EOg^K94~cP%vCP+I z<2%*{a5H(16=+=Jo*Otz#$1Lq9r1&smzX~XAHVJGe~@E##6O(-;sekY-buHHze&Xp zUSl6R=)uFnudp__0lNaYC$B~6d5_2YyT~zMyF2kiYzX#8uCvK!%%pW}Wr5Za#+kSaSLo8EO9RjUeAeDHew)U!D|bMQZRqAYKX;F1 z_c(@+a}I&X??yfWp9^yyQB33%@SX{DsB>OJoO28AA9Vk}8~=6Ab`FG{hrWLP@8%EA zZXJ@z1R*B?`3T5OKu!Yk)u9*Xy9Z`wW*!`rJL>nt|HHM;_HX5%6gl!Z*M}Zb2hQur z@HzW^e5ST*EBL$p$?lJ4`@8$VZGYsVARhyH{&sFcy3RfnKXc~H1Ag$9ZER%!R{ov) z0Q@CX=3gq6WKKz){U&hX!UbP^Msi0vEdIzpDwoS5Cx!h!bN<{p-+X4P_;Wr@o&7$} z{_d;K4934_|7Y%MV0L!acb{?d=lS47saVw7f$gWYpit2G{}oLr`ix;o+8$3VqzW{eWI~Lfn!2h!a{tI?u<4OPk literal 0 HcmV?d00001 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..3ea7b29 --- /dev/null +++ b/FreshViewer/Controls/ImageViewport.cs @@ -0,0 +1,905 @@ +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; + +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; + + public event EventHandler? ImagePresented; + public event EventHandler? ViewStateChanged; + public event EventHandler? ImageFailed; + 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; + } + } + } + } + + public bool IsFullscreen + { + get => _isFullscreen; + set + { + if (_isFullscreen == value) + { + return; + } + + _isFullscreen = value; + _targetBackgroundOpacity = value ? 1.0 : 0.68; + AnimateViewportRealignment(value); + _needsRedraw = true; + } + } + + public bool HasImage => _currentImage is not null; + + public bool IsPointWithinImage(Point point) => IsPointOnImage(point); + + public double Rotation => _rotation; + + public double CurrentScale => _currentScale; + + public Vector CurrentOffset => _currentOffset; + + public Point ViewportCenterPoint => new Point(Bounds.Width / 2.0, Bounds.Height / 2.0); + + 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)); + }); + } + } + + public void ResetView() + { + if (!HasImage) + { + return; + } + + _rotation = 0; + RecalculateFitScale(); + ApplyFitToView(); + } + + 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; + } + + public void ZoomIncrement(Point focusPoint, bool zoomIn) + { + var factor = zoomIn ? ZoomFactor : (1.0 / ZoomFactor); + ZoomTo(_targetScale * factor, focusPoint); + } + + public void ZoomWithWheel(Point focusPoint, double wheelDelta) + { + var zoomFactor = 1.0 + wheelDelta * ZoomStep; + if (zoomFactor < 0.05) + { + zoomFactor = 0.05; + } + ZoomTo(_targetScale * zoomFactor, focusPoint); + } + + public void RotateClockwise() + { + if (!HasImage) + { + return; + } + + _panVelocity = Vector.Zero; + _targetRotation = NormalizeAngle(_targetRotation + 90); + RecalculateFitScale(); + _needsRedraw = true; + ViewStateChanged?.Invoke(this, EventArgs.Empty); + } + + public void RotateCounterClockwise() + { + if (!HasImage) + { + return; + } + + _panVelocity = Vector.Zero; + _targetRotation = NormalizeAngle(_targetRotation - 90); + RecalculateFitScale(); + _needsRedraw = true; + ViewStateChanged?.Invoke(this, EventArgs.Empty); + } + + 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); + } + + 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; + } + + 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; + } + + public void Dispose() + { + CancelLoading(); + _currentImage?.Dispose(); + _timer.Stop(); + } +} + +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; + } + + public string Path { get; } + + public (int Width, int Height) Dimensions { get; } + + public bool IsAnimated { get; } + + public ImageMetadata? Metadata { get; } +} + +public enum ImageTransition +{ + None, + FadeIn, + SlideFromLeft, + SlideFromRight, + Instant +} + +public sealed class ImageFailedEventArgs : EventArgs +{ + public ImageFailedEventArgs(string path, Exception exception) + { + Path = path; + Exception = exception; + } + + public string Path { get; } + + public Exception Exception { get; } +} diff --git a/FreshViewer/Converters/BooleanToValueConverter.cs b/FreshViewer/Converters/BooleanToValueConverter.cs new file mode 100644 index 0000000..d191624 --- /dev/null +++ b/FreshViewer/Converters/BooleanToValueConverter.cs @@ -0,0 +1,37 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Data; + +namespace FreshViewer.Converters; + +public sealed class BooleanToValueConverter : IValueConverter +{ + public object? TrueValue { get; set; } + 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/FreshViewer.csproj b/FreshViewer/FreshViewer.csproj new file mode 100644 index 0000000..3653746 --- /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/LiquidGlass/Assets/DisplacementMaps/polar_displacement.jpeg b/FreshViewer/LiquidGlass/Assets/DisplacementMaps/polar_displacement.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..1f88ab7e9caabceafabc56b1f26b411d79a6868b GIT binary patch literal 4319 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 literal 0 HcmV?d00001 diff --git a/FreshViewer/LiquidGlass/Assets/DisplacementMaps/standard_displacement.jpeg b/FreshViewer/LiquidGlass/Assets/DisplacementMaps/standard_displacement.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..cd37aa4002f56e2b1657a86cc8291c8e1942728a GIT binary patch literal 4451 zcma)A3s6&68oqf+1O);?YAqr{AO;5!LIhe~fhdiMk_FOAg~f=1Agwmhf`B5TMpU95 zsDhE2C@g}hC}2kiw`x%oBs@ZSEDl?AFkqP~inZ7(`=5K0RJ+}o{XJan{m%El&x3Kg zfBHwvUby_j2PQeIv&;&bN;E*-JWDR_J zJ8C$NfNMD9Iz%AX&C>@|aOR(@GY{;wx|*rs8O_ zc=hHP^&RWa?Bf0Q&giPbbHe;;cv=^Zu4*0^Uh5QIJ7=9=y;2)T>+(6ESgBQjB^sYg z%f9uj1(X7CrD~+qT)mDuzN#750HtfyHmA6(A3^EhjXVb}hkS}ds(ZJCndd%rzQ2VJ zLB)9`$N3g+_bp7DQ4mebzJr|)>~z99ox^v~hJB7^t!&=8aI|!H%xqFp*4X6!hu^Jt zVRSmp{P^a90shSX8%*|>qy-MC9{OeKjG&M0{x9m06_UocMXNGsu{EIfuLyCdDTYug%>Kv8D8V+S_>1f{RhdjDCN59Va zWl@*Y%+8x7Zh{gwe#wIJ-KVUL9{NSc9eszkocn%FLA@$}2j%(_#*>5la@iBdqF=fv z=lLA{y>C2L%X#PG!9|fU3LPZ@E|9k$RS#5jp z%q}h}WGlN$zj@d;PDAY!Q;!9HvrcraEW)2QV$)u6aqq_YM%&$4R?R!zht=xO?QZRK z*K+0`{oOFWUUM+;^ogLz6G40~JDlYpx?i^Y^MVc3R_9|Oglg)1qbn(EB?LO@x-f`g z2xD{-1!Z>KbUo&n%GDz)kgnQm9QT^{!2cqo<;+;%Ta=>@hnj=G^eT2lqz0 zmN0%j-SwVwa<%q@RKr+UduVfTV0BSh_0#+8hquRzu0b;(*0z&=smQ0|4(K;sU$d`% zHa2N@W@q3;i#CrjR#7(j@{?yv>ROA_W!iLE75o07abfd0nm>7?&7G5eMp2o;GX+$k zRCk?^X9Ou$pUGoSu9k&3_J18Q`Eva;*Sg|j&ieWg<=LV=r@a?m@Wz_1XYWQnam5Lv zNRurek~BS7)0#i3s&qLpzFM2(*k9ZtdcE!!lB8XH8dC*tKqWi;aGqZ370KLcsA&Er^+Jg9^w{~f*WU#Pj(1eO&loGdKG~{FEBHQS zUrsv&+ys6VEoFY>4ISALeup12^$IVUB32UM*k68NT;RHDXX5h-Nz2AO#(+wc)mE42 zHhQ>t$E>~+ejD{=@}aeNRKY2zXhj>S`VZCPwrJ&~dur3Y>J*9ZPkqsMQ{bYUY|(}+ zz3o$$wf_t^Xw-jb^PLj`5AT-AJ;Nbuwoka|M%e*uTz1R8s`yjS`)T)!*)P9%+Hv0c z_HT?)mFRQ3&fKMA&WG6klm0`wFHZQSwr9(I){d1pvOOhhk!t6<8H=m*pL*)k7v3Jd zl7p*W+4>+1RQDwnOdL}NyG2ygKWv(M%u9isABs+u?Uq}Qc<7^N?6LOSJ4c?n%yGpW zfxs}0mA+E&RzxK#CtYVv(Y{|2k+nzbGHY_~PoYpO|Ba#ngRf6A@+pUHc!TLCWDc~e z*PMh^JdKU)N}PQ(=9uU=E6{t=b|jQBVHFvI#iTY(8F-*BC#O2y^;RDXS^&HF22&$Y z58ON&bJA}0>-gtgA0Iv{aTfi);X!ElleXqi`%&X4jh>YI;<#g|Lhr4P^{sX=vgrK1 zbNPkr`h)Hl-~fJP*BW|d9^<07gBD8LaU|4jSQVA)#&^N$Z1gkLj)7HMV_1653~id< zUb^qzIV)1)toU&u5mRA(kuqtdjK^9I2bAhiZe+JBJw_F^nEb2=8`>Jf)C;NFXkQIo zl9DFjOVJ@6+2i^p6hl_y%%xCSvkR>uRp>D(ieP4q#GM>?)yrP@e%I%|P-KO}6b)XPs2hAePZMGayH z{e>AHx!M?vqD{Iqh}$kr*z}KD;bpyCjl5nJUmDCrq0|_}oCY(h%j6AaHXyV{$Eh&} z>E)0!V&Y_B4G2!{F}wv{jt8rtMi2@8nk)6{np(&f=~~nQGzLL9CJ+*dkB|RI3bBG8 z9!|V8h}_rIo?L_H`>6L#^l~Dgs1JiC7@`MqW36{7RQl3j-aKTvs(dZP9-q)tOQ4yP zCUoSZpw>q}l%nFBBDjQ8mGQU*z(4y$oDK;YgIXKUR+Uz6sv;tI4t0ZysEwRn zov^z>$HNnT)OTKb4pr)_Qpl!qQ$l9diOfsS8_!F15USc3D>St{lMAKoy$T(~VX-%{ z62uKqA;XTw3}_0prpI9BqtXR{637QZuv({JAo30 zrHTFyq<0ynKQApz64`-)nYIj>1Y2g}L5phs2U&mdD=c^+5-;;KU8z*6glugQ33?U1>4bndleCowjWJTMk+l+HV_Y!s<@4!uGhX#O2!T}qwH00pLRdXOH9Hz8ES_CSe%+E+{^ZqvEYG7ZMd3Pl{+@`5UEdgr5D}r~#y$lV<7$w94rl)VMAN zW3Gm+aX?Lil1dTzG3s=bkibx!83Yj)3U0O`&kkjq(3dj=?LVYlh<7pVNr?ndffE9i zB`l)1ru7I*0_1=>+CQz)C(05q5d)81Xgk2Fwk&e|g_KNe2Qmq^LxUg(NY40nc#B3N z6W0d??FF4_hZ6hL{0wL{zr8)Zwg$%o-bTd%P6=>wrGwxny7|tiDjz}Dj7iKWPUSQh z#AY!Ws{z{qtvYVc#dh(^01ykVF7*l*AgA6YTO4TKd?sE>_} zmE)4x4&iW+5s+FlD$i^u$QKdZV2}eqh*&HH9;XH#kVWRV5I6{w0fh`G2s)AnapSSE ze*?6Q^FQELgzKP61l`15jI&XVpi*) 0.0001 ? centered / dist : float2(0.0, 0.0); + float2 radialOffset = dir * wave; + + // карта смещения + half2 disp = read_displacement(uv) * half(strength * 0.03); + + return uv + radialOffset + float2(disp); +} + +half4 apply_chromatic_aberration(float2 uv, float strength) +{ + float baseStrength = strength * 0.0025; + + float2 rUV = distort_uv(uv + float2(baseStrength * chromaticAberrationScales.r, 0.0), strength); + float2 gUV = distort_uv(uv, strength); + float2 bUV = distort_uv(uv - float2(baseStrength * chromaticAberrationScales.b, 0.0), strength); + + half4 r = sample_background(rUV); + half4 g = sample_background(gUV); + half4 b = sample_background(bUV); + + return half4(r.r, g.g, b.b, g.a); +} + +half4 apply_interaction_lighting(float2 uv, half4 color) +{ + if (isHovered > 0.5) + { + float2 center = float2(0.5, 0.0); + float dist = length(uv - center); + float mask = smoothstep_local(0.6, 0.0, dist); + color.rgb += half3(0.18, 0.2, 0.25) * half(mask); + } + + if (isActive > 0.5) + { + float2 center = float2(0.5, 0.1); + float dist = length(uv - center); + float mask = smoothstep_local(0.8, 0.0, dist); + color.rgb += half3(0.12, 0.16, 0.25) * half(mask); + } + + return color; +} + +// Имитация дополнительного размытия за счёт усреднения нескольких сэмплов +half4 apply_soft_blur(float2 uv, float blurStrength) +{ + if (blurStrength < 0.01) + { + return sample_background(uv); + } + + float radius = blurStrength * 0.012; + float2 offsets[4] = float2[4]( + float2( radius, radius), + float2(-radius, radius), + float2( radius, -radius), + float2(-radius, -radius) + ); + + half4 acc = half4(0.0); + for (int i = 0; i < 4; ++i) + { + acc += sample_background(uv + offsets[i]); + } + + return acc * 0.25; +} + +// ---- Основная функция ------------------------------------------------------- + +half4 main(float2 coord) +{ + float2 uv = coord / resolution; + + float displacementStrength = clamp(displacementScale / 100.0, 0.0, 1.5); + float aberrationStrength = clamp(aberrationIntensity, 0.0, 10.0); + + half4 baseColor = apply_soft_blur(uv, blurAmount); + half4 liquidColor = apply_chromatic_aberration(distort_uv(uv, displacementStrength), aberrationStrength); + + // Смешиваем размытие и жидкое искажение + half4 color = mix(baseColor, liquidColor, half(0.65)); + + color = adjust_saturation(color, saturation); + color = apply_interaction_lighting(uv, color); + + if (overLight > 0.5) + { + color.rgb *= half3(0.85, 0.85, 0.9); + } + + color.a = 1.0; + return color; +} diff --git a/FreshViewer/LiquidGlass/Assets/Shaders/LiquidGlassShader_Fixed.sksl b/FreshViewer/LiquidGlass/Assets/Shaders/LiquidGlassShader_Fixed.sksl new file mode 100644 index 0000000..e255ee7 --- /dev/null +++ b/FreshViewer/LiquidGlass/Assets/Shaders/LiquidGlassShader_Fixed.sksl @@ -0,0 +1,181 @@ +// Liquid Glass SKSL Shader - 修正版本 +// 正确分离 Displacement Scale 和 Chromatic Aberration 效果 + +// Uniform 变量定义 +uniform float2 resolution; // 分辨率 +uniform float displacementScale; // 位移缩放强度 (用于位移贴图) +uniform float blurAmount; // 模糊量 +uniform float saturation; // 饱和度 (0-2 范围,1为正常) +uniform float aberrationIntensity; // 色差强度 (用于聚焦扭曲) +uniform float cornerRadius; // 圆角半径 +uniform float2 mouseOffset; // 鼠标相对偏移 (百分比) +uniform float2 globalMouse; // 全局鼠标位置 +uniform float isHovered; // 是否悬停 (0.0 或 1.0) +uniform float isActive; // 是否激活 (0.0 或 1.0) +uniform float overLight; // 是否在亮色背景上 (0.0 或 1.0) +uniform float edgeMaskOffset; // 边缘遮罩偏移 +uniform float3 chromaticAberrationScales; // 色差缩放系数 [R, G, B] +uniform float hasDisplacementMap; // 是否有位移贴图 (0.0 或 1.0) + +// 着色器输入 +uniform shader backgroundTexture; // 背景纹理 +uniform shader displacementTexture; // 位移贴图纹理 + +// 有符号距离场 - 圆角矩形 +float roundedRectSDF(float2 p, float2 b, float r) { + float2 q = abs(p) - b + r; + return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r; +} + +// 饱和度调整函数 +half4 adjustSaturation(half4 color, half saturationLevel) { + // RGB to grayscale weights (Rec. 709) + half3 gray = half3(0.299, 0.587, 0.114); + half luminance = dot(color.rgb, gray); + return half4(mix(half3(luminance), color.rgb, saturationLevel), color.a); +} + +// 位移贴图应用函数 (Displacement Scale效果) +float2 applyDisplacementMap(float2 uv) { + // 如果有位移贴图,使用位移贴图数据来控制边缘色散 + if (hasDisplacementMap > 0.5) { + // 从位移贴图采样 + half4 displacementSample = displacementTexture.eval(uv * resolution); + + // 将RGB值从[0,1]范围转换为[-1,1]范围的位移向量 + // 注意:0.5表示无位移,<0.5向负方向,>0.5向正方向 + float2 displacement = float2( + (displacementSample.r - 0.5) * 2.0, + (displacementSample.g - 0.5) * 2.0 + ); + + // 应用位移缩放 - 使用displacementScale参数 + float displacementStrength = displacementScale / 1000.0; + displacement *= displacementStrength; + + // 限制位移范围以防止过度扭曲 + displacement = clamp(displacement, float2(-0.2, -0.2), float2(0.2, 0.2)); + + // 将位移应用到原始UV坐标 + float2 distortedUV = uv + displacement; + + // 确保结果在有效范围内 + return clamp(distortedUV, float2(0.0, 0.0), float2(1.0, 1.0)); + } + else { + // 没有位移贴图时直接返回原始UV + return uv; + } +} + +// 色差聚焦扭曲效果 (Chromatic Aberration效果) +float2 applyChromaticAberrationDistortion(float2 uv, float intensityMultiplier) { + // 将坐标中心化,使 (0,0) 位于控件中心 + float2 centeredUV = uv - 0.5; + + // 扭曲矩形的半尺寸 - 根据TypeScript版本调整 + float2 rectHalfSize = float2(0.3, 0.2); + float cornerRadiusNormalized = 0.6; + + // 计算当前像素到圆角矩形边缘的距离 + float distanceToEdge = roundedRectSDF(centeredUV, rectHalfSize, cornerRadiusNormalized); + + // 使用色差强度来控制扭曲程度 + float aberrationOffset = (aberrationIntensity * intensityMultiplier) / 100.0; + + // 计算聚焦效果 + float displacement = smoothstep(0.8, 0.0, distanceToEdge - aberrationOffset); + float scaled = smoothstep(0.0, 1.0, displacement); + + // 应用聚焦变换 + float2 distortedUV = centeredUV * scaled + 0.5; + + return distortedUV; +} + +// 主色差效果函数 +half4 applyChromaticAberration(float2 uv) { + if (aberrationIntensity < 0.001) { + // 没有色差效果,只应用位移贴图 + float2 distortedUV = applyDisplacementMap(uv); + return backgroundTexture.eval(distortedUV * resolution); + } + + // 为不同颜色通道计算不同的聚焦强度 + float2 redUV = applyChromaticAberrationDistortion(uv, 1.2); // 红色最强聚焦 + float2 greenUV = applyChromaticAberrationDistortion(uv, 1.0); // 绿色标准聚焦 + float2 blueUV = applyChromaticAberrationDistortion(uv, 0.8); // 蓝色最弱聚焦 + + // 对每个颜色通道应用位移贴图 + redUV = applyDisplacementMap(redUV); + greenUV = applyDisplacementMap(greenUV); + blueUV = applyDisplacementMap(blueUV); + + // 采样各个颜色通道 + half4 redSample = backgroundTexture.eval(redUV * resolution); + half4 greenSample = backgroundTexture.eval(greenUV * resolution); + half4 blueSample = backgroundTexture.eval(blueUV * resolution); + + // 组合颜色通道,使用绿色通道的alpha作为基准 + return half4(redSample.r, greenSample.g, blueSample.b, greenSample.a); +} + +// 交互效果 (悬停和激活状态) +half4 applyInteractionEffects(half2 uv, half4 baseColor) { + if (isHovered > 0.5) { + // 悬停效果 - 径向渐变从顶部 + half2 hoverCenter = half2(0.5, 0.0); + half hoverDist = length(uv - hoverCenter); + half hoverMask = smoothstep(0.5, 0.0, hoverDist); + + half3 hoverColor = half3(1.0, 1.0, 1.0) * 0.3 * hoverMask; + baseColor.rgb = baseColor.rgb + hoverColor * 0.5; + } + + if (isActive > 0.5) { + // 激活效果 - 更强的径向渐变 + half2 activeCenter = half2(0.5, 0.0); + half activeDist = length(uv - activeCenter); + half activeMask = smoothstep(1.0, 0.0, activeDist); + + half3 activeColor = half3(1.0, 1.0, 1.0) * 0.6 * activeMask; + baseColor.rgb = baseColor.rgb + activeColor * 0.7; + } + + return baseColor; +} + +// 主着色器函数 +half4 main(float2 coord) { + half2 uv = coord / resolution; + + // 应用色差效果(包含聚焦扭曲和位移贴图) + half4 color = applyChromaticAberration(uv); + + // 应用饱和度调整 + color = adjustSaturation(color, saturation); + + // 根据overLight状态调整颜色 + if (overLight > 0.5) { + color.rgb *= 0.7; // 在亮色背景上减弱效果 + } + + // 应用交互效果 + color = applyInteractionEffects(uv, color); + + // 创建边界遮罩 - 基于距离中心的简单渐变 + float2 centeredUV = uv - 0.5; + float distanceFromCenter = length(centeredUV); + + // 创建平滑的径向遮罩,在边缘处渐变到透明 + float edgeMask = 1.0 - smoothstep(0.45, 0.5, distanceFromCenter); + + // 同时创建矩形遮罩来防止四角的液态玻璃效果 + float2 rectMask = step(abs(centeredUV), float2(0.48, 0.48)); + float combinedMask = edgeMask * rectMask.x * rectMask.y; + + // 应用组合遮罩 + color.a = color.a * combinedMask; + + return color; +} diff --git a/FreshViewer/LiquidGlass/Assets/Shaders/LiquidGlassShader_New.sksl b/FreshViewer/LiquidGlass/Assets/Shaders/LiquidGlassShader_New.sksl new file mode 100644 index 0000000..6a37fd8 --- /dev/null +++ b/FreshViewer/LiquidGlass/Assets/Shaders/LiquidGlassShader_New.sksl @@ -0,0 +1,136 @@ +// Liquid Glass SKSL Shader - 基于旧版本的简化实现 +// 结合旧版本的位移逻辑和新的饱和度、色差效果 + +// Uniform 变量定义 +uniform float2 resolution; // 分辨率 +uniform float displacementScale; // 位移缩放强度 +uniform float blurAmount; // 模糊量 +uniform float saturation; // 饱和度 (0-2 范围,1为正常) +uniform float aberrationIntensity; // 色差强度 +uniform float cornerRadius; // 圆角半径 +uniform float2 mouseOffset; // 鼠标相对偏移 (百分比) +uniform float2 globalMouse; // 全局鼠标位置 +uniform float isHovered; // 是否悬停 (0.0 或 1.0) +uniform float isActive; // 是否激活 (0.0 或 1.0) +uniform float overLight; // 是否在亮色背景上 (0.0 或 1.0) +uniform float edgeMaskOffset; // 边缘遮罩偏移 +uniform float3 chromaticAberrationScales; // 色差缩放系数 [R, G, B] +uniform float hasDisplacementMap; // 是否有位移贴图 (0.0 或 1.0) + +// 着色器输入 +uniform shader backgroundTexture; // 背景纹理 +uniform shader displacementTexture; // 位移贴图纹理 + +// 有符号距离场 - 圆角矩形 (来自旧版本) +float roundedRectSDF(float2 p, float2 b, float r) { + float2 q = abs(p) - b + r; + return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r; +} + +// 饱和度调整函数 +half4 adjustSaturation(half4 color, half saturationLevel) { + // RGB to grayscale weights (Rec. 709) + half3 gray = half3(0.299, 0.587, 0.114); + half luminance = dot(color.rgb, gray); + return half4(mix(half3(luminance), color.rgb, saturationLevel), color.a); +} + +// 基础液态玻璃变形 (基于旧版本逻辑) +float2 applyLiquidGlassDistortion(float2 uv) { + // 将坐标中心化,使 (0,0) 位于控件中心 + float2 centeredUV = uv - 0.5; + + // 扭曲矩形的半尺寸 + float2 rectHalfSize = float2(0.3, 0.2); + + // 扭曲形状的圆角半径 + float cornerRadiusNormalized = 0.6; + + // 计算当前像素到圆角矩形边缘的距离 + float distanceToEdge = roundedRectSDF(centeredUV, rectHalfSize, cornerRadiusNormalized); + + // 使用 smoothstep 基于距离创建位移因子 + // displacementScale uniform 控制扭曲强度 + float displacement = smoothstep(0.8, 0.0, distanceToEdge - (displacementScale / 1000.0)); + float scaled = smoothstep(0.0, 1.0, displacement); + + // 通过应用位移计算新的扭曲纹理坐标 + float2 distortedUV = centeredUV * scaled; + + // 将坐标移回 [0, 1] 范围进行采样 + return distortedUV + 0.5; +} + +// 色差效果 (简化版本) +half4 applyChromaticAberration(float2 uv) { + if (aberrationIntensity < 0.1) { + // 没有色差效果,直接采样 + float2 distortedUV = applyLiquidGlassDistortion(uv); + return backgroundTexture.eval(distortedUV * resolution); + } + + // 计算色差偏移 + float aberrationOffset = aberrationIntensity * 0.001; + + // 为每个颜色通道应用不同的位移 + float2 redUV = applyLiquidGlassDistortion(uv + float2(aberrationOffset * chromaticAberrationScales.r, 0.0)); + float2 greenUV = applyLiquidGlassDistortion(uv + float2(aberrationOffset * chromaticAberrationScales.g, 0.0)); + float2 blueUV = applyLiquidGlassDistortion(uv + float2(aberrationOffset * chromaticAberrationScales.b, 0.0)); + + // 采样各个颜色通道 + half4 redSample = backgroundTexture.eval(redUV * resolution); + half4 greenSample = backgroundTexture.eval(greenUV * resolution); + half4 blueSample = backgroundTexture.eval(blueUV * resolution); + + // 组合颜色通道 + return half4(redSample.r, greenSample.g, blueSample.b, redSample.a); +} + +// 交互效果 (悬停和激活状态) +half4 applyInteractionEffects(half2 uv, half4 baseColor) { + if (isHovered > 0.5) { + // 悬停效果 - 径向渐变从顶部 + half2 hoverCenter = half2(0.5, 0.0); + half hoverDist = length(uv - hoverCenter); + half hoverMask = smoothstep(0.5, 0.0, hoverDist); + + half3 hoverColor = half3(1.0, 1.0, 1.0) * 0.3 * hoverMask; + baseColor.rgb = baseColor.rgb + hoverColor * 0.5; + } + + if (isActive > 0.5) { + // 激活效果 - 更强的径向渐变 + half2 activeCenter = half2(0.5, 0.0); + half activeDist = length(uv - activeCenter); + half activeMask = smoothstep(1.0, 0.0, activeDist); + + half3 activeColor = half3(1.0, 1.0, 1.0) * 0.6 * activeMask; + baseColor.rgb = baseColor.rgb + activeColor * 0.7; + } + + return baseColor; +} + +// 主着色器函数 - 简化版本 +half4 main(float2 coord) { + half2 uv = coord / resolution; + + // 应用色差效果(同时包含液态玻璃变形) + half4 color = applyChromaticAberration(uv); + + // 应用饱和度调整 + color = adjustSaturation(color, saturation); + + // 根据overLight状态调整颜色 + if (overLight > 0.5) { + color.rgb *= 0.7; // 在亮色背景上减弱效果 + } + + // 应用交互效果 + color = applyInteractionEffects(uv, color); + + // 确保alpha通道正确 + color.a = 1.0; + + return color; +} diff --git a/FreshViewer/LiquidGlass/DisplacementMapManager.cs b/FreshViewer/LiquidGlass/DisplacementMapManager.cs new file mode 100644 index 0000000..3697335 --- /dev/null +++ b/FreshViewer/LiquidGlass/DisplacementMapManager.cs @@ -0,0 +1,298 @@ +using Avalonia; +using Avalonia.Platform; +using Avalonia.Rendering.SceneGraph; +using Avalonia.Skia; +using SkiaSharp; +using System; +using System.IO; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Diagnostics; + +namespace FreshViewer.UI.LiquidGlass +{ + /// + /// 位移贴图管理器 - 负责加载和管理不同模式的位移贴图 + /// + public static class DisplacementMapManager + { + private static readonly Dictionary _preloadedMaps = new(); + private static readonly Dictionary _shaderGeneratedMaps = new(); + private static readonly object _lockObject = new object(); // 添加线程安全锁 + private static bool _mapsLoaded = false; + + /// + /// 预加载所有位移贴图 + /// + public static void LoadDisplacementMaps() + { + lock (_lockObject) + { + if (_mapsLoaded) return; + _mapsLoaded = true; + + try + { + // 加载标准位移贴图 + _preloadedMaps[LiquidGlassMode.Standard] = + LoadMapFromAssets("DisplacementMaps/standard_displacement.jpeg"); + + // 加载极坐标位移贴图 + _preloadedMaps[LiquidGlassMode.Polar] = + LoadMapFromAssets("DisplacementMaps/polar_displacement.jpeg"); + + // 加载突出边缘位移贴图 + _preloadedMaps[LiquidGlassMode.Prominent] = + LoadMapFromAssets("DisplacementMaps/prominent_displacement.jpeg"); + + // Shader模式的贴图将动态生成 + _preloadedMaps[LiquidGlassMode.Shader] = null; + } + catch (Exception ex) + { + DebugLog($"[DisplacementMapManager] Error loading displacement maps: {ex.Message}"); + } + } + } + + /// + /// 从资源文件加载位移贴图 + /// + private static SKBitmap? LoadMapFromAssets(string resourcePath) + { + try + { + var assetUri = new Uri($"avares://FreshViewer/LiquidGlass/Assets/{resourcePath}"); + using var stream = AssetLoader.Open(assetUri); + return SKBitmap.Decode(stream); + } + catch (Exception ex) + { + DebugLog($"[DisplacementMapManager] Failed to load {resourcePath}: {ex.Message}"); + return null; + } + } + + /// + /// 获取指定模式的位移贴图 + /// + public static SKBitmap? GetDisplacementMap(LiquidGlassMode mode, int width = 0, int height = 0) + { + lock (_lockObject) + { + LoadDisplacementMaps(); + + if (mode == LiquidGlassMode.Shader) + { + // 为Shader模式生成动态位移贴图 + var key = $"shader_{width}x{height}"; + if (!_shaderGeneratedMaps.ContainsKey(key) && width > 0 && height > 0) + { + DebugLog($"[DisplacementMapManager] Generating shader displacement map: {width}x{height}"); + try + { + var bitmap = GenerateShaderDisplacementMap(width, height); + _shaderGeneratedMaps[key] = bitmap; + } + catch (Exception ex) + { + DebugLog($"[DisplacementMapManager] Error generating shader map: {ex.Message}"); + _shaderGeneratedMaps[key] = null; + } + } + + var result = _shaderGeneratedMaps.TryGetValue(key, out var shaderBitmap) ? shaderBitmap : null; + + // 验证位图是否有效 + if (result != null && (result.IsEmpty || result.IsNull)) + { + DebugLog("[DisplacementMapManager] Shader bitmap is invalid, removing from cache"); + _shaderGeneratedMaps.Remove(key); + result = null; + } + + DebugLog($"[DisplacementMapManager] Shader mode map: {(result != null ? "Found" : "Not found")}"); + return result; + } + + var preloadedResult = + _preloadedMaps.TryGetValue(mode, out var preloadedBitmap) ? preloadedBitmap : null; + + // 验证预加载位图是否有效 + if (preloadedResult != null && (preloadedResult.IsEmpty || preloadedResult.IsNull)) + { + DebugLog($"[DisplacementMapManager] Preloaded bitmap for {mode} is invalid"); + preloadedResult = null; + } + + DebugLog( + $"[DisplacementMapManager] {mode} mode map: {(preloadedResult != null ? "Found" : "Not found")}"); + return preloadedResult; + } + } + + /// + /// 生成Shader模式的位移贴图(对应TS版本的ShaderDisplacementGenerator) + /// + private static SKBitmap GenerateShaderDisplacementMap(int width, int height) + { + var bitmap = new SKBitmap(width, height, SKColorType.Rgba8888, SKAlphaType.Premul); + var canvas = new SKCanvas(bitmap); + + try + { + // 实现液态玻璃Shader算法 + var pixels = new uint[width * height]; + var maxScale = 0f; + var rawValues = new List<(float dx, float dy)>(); + + // 第一遍:计算所有位移值 + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + var uv = new LiquidVector2(x / (float)width, y / (float)height); + var pos = LiquidGlassShader(uv); + + var dx = pos.X * width - x; + var dy = pos.Y * height - y; + + maxScale = Math.Max(maxScale, Math.Max(Math.Abs(dx), Math.Abs(dy))); + rawValues.Add((dx, dy)); + } + } + + // 确保最小缩放值防止过度归一化 + if (maxScale > 0) + maxScale = Math.Max(maxScale, 1); + else + maxScale = 1; + + // 第二遍:转换为图像数据 + int rawIndex = 0; + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + var (dx, dy) = rawValues[rawIndex++]; + + // 边缘平滑化 + var edgeDistance = Math.Min(Math.Min(x, y), Math.Min(width - x - 1, height - y - 1)); + var edgeFactor = Math.Min(1f, edgeDistance / 2f); + + var smoothedDx = dx * edgeFactor; + var smoothedDy = dy * edgeFactor; + + var r = smoothedDx / maxScale + 0.5f; + var g = smoothedDy / maxScale + 0.5f; + + var red = (byte)Math.Max(0, Math.Min(255, r * 255)); + var green = (byte)Math.Max(0, Math.Min(255, g * 255)); + var blue = (byte)Math.Max(0, Math.Min(255, g * 255)); // 蓝色通道复制绿色以兼容SVG + var alpha = (byte)255; + + pixels[y * width + x] = (uint)((alpha << 24) | (blue << 16) | (green << 8) | red); + } + } + + // 将像素数据写入bitmap + var handle = GCHandle.Alloc(pixels, GCHandleType.Pinned); + try + { + bitmap.SetPixels(handle.AddrOfPinnedObject()); + } + finally + { + handle.Free(); + } + } + finally + { + canvas.Dispose(); + } + + return bitmap; + } + + /// + /// 液态玻璃Shader函数 (对应TS版本的liquidGlass shader) + /// + private static LiquidVector2 LiquidGlassShader(LiquidVector2 uv) + { + var ix = uv.X - 0.5f; + var iy = uv.Y - 0.5f; + var distanceToEdge = RoundedRectSDF(ix, iy, 0.3f, 0.2f, 0.6f); + var displacement = SmoothStep(0.8f, 0f, distanceToEdge - 0.15f); + var scaled = SmoothStep(0f, 1f, displacement); + return new LiquidVector2(ix * scaled + 0.5f, iy * scaled + 0.5f); + } + + /// + /// 有符号距离场 - 圆角矩形 + /// + private static float RoundedRectSDF(float x, float y, float width, float height, float radius) + { + var qx = Math.Abs(x) - width + radius; + var qy = Math.Abs(y) - height + radius; + return Math.Min(Math.Max(qx, qy), 0) + Length(Math.Max(qx, 0), Math.Max(qy, 0)) - radius; + } + + /// + /// 向量长度计算 + /// + private static float Length(float x, float y) + { + return (float)Math.Sqrt(x * x + y * y); + } + + /// + /// 平滑步进函数 (Hermite插值) + /// + private static float SmoothStep(float a, float b, float t) + { + t = Math.Max(0, Math.Min(1, (t - a) / (b - a))); + return t * t * (3 - 2 * t); + } + + /// + /// 清理资源 + /// + public static void Cleanup() + { + foreach (var bitmap in _preloadedMaps.Values) + { + bitmap?.Dispose(); + } + + _preloadedMaps.Clear(); + + foreach (var bitmap in _shaderGeneratedMaps.Values) + { + bitmap?.Dispose(); + } + + _shaderGeneratedMaps.Clear(); + + _mapsLoaded = false; + } + + [Conditional("DEBUG")] + private static void DebugLog(string message) + => Console.WriteLine(message); + } + + /// + /// LiquidVector2结构体 - 避免与系统Vector2冲突 + /// + public struct LiquidVector2 + { + public float X { get; set; } + public float Y { get; set; } + + public LiquidVector2(float x, float y) + { + X = x; + Y = y; + } + } +} diff --git a/FreshViewer/LiquidGlass/DraggableLiquidGlassCard.cs b/FreshViewer/LiquidGlass/DraggableLiquidGlassCard.cs new file mode 100644 index 0000000..f4da6a0 --- /dev/null +++ b/FreshViewer/LiquidGlass/DraggableLiquidGlassCard.cs @@ -0,0 +1,326 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; +using System; +using System.Diagnostics; + +namespace FreshViewer.UI.LiquidGlass +{ + /// + /// 可拖拽的液态玻璃卡片 - 悬浮在所有内容之上,支持鼠标拖拽移动 + /// + public class DraggableLiquidGlassCard : Control + { + #region Avalonia Properties + + /// + /// 位移缩放强度 + /// + public static readonly StyledProperty DisplacementScaleProperty = + AvaloniaProperty.Register(nameof(DisplacementScale), 20.0); + + /// + /// 模糊量 + /// + public static readonly StyledProperty BlurAmountProperty = + AvaloniaProperty.Register(nameof(BlurAmount), 0.15); + + /// + /// 饱和度 + /// + public static readonly StyledProperty SaturationProperty = + AvaloniaProperty.Register(nameof(Saturation), 120.0); + + /// + /// 色差强度 + /// + public static readonly StyledProperty AberrationIntensityProperty = + AvaloniaProperty.Register(nameof(AberrationIntensity), 7.0); + + /// + /// 圆角半径 + /// + public static readonly StyledProperty CornerRadiusProperty = + AvaloniaProperty.Register(nameof(CornerRadius), 12.0); + + /// + /// 液态玻璃效果模式 + /// + public static readonly StyledProperty ModeProperty = + AvaloniaProperty.Register(nameof(Mode), + LiquidGlassMode.Standard); + + /// + /// 是否在亮色背景上 + /// + public static readonly StyledProperty OverLightProperty = + AvaloniaProperty.Register(nameof(OverLight), false); + + /// + /// X位置 + /// + public static readonly StyledProperty XProperty = + AvaloniaProperty.Register(nameof(X), 100.0); + + /// + /// Y位置 + /// + public static readonly StyledProperty YProperty = + AvaloniaProperty.Register(nameof(Y), 100.0); + + #endregion + + #region Properties + + public double DisplacementScale + { + get => GetValue(DisplacementScaleProperty); + set => SetValue(DisplacementScaleProperty, value); + } + + public double BlurAmount + { + get => GetValue(BlurAmountProperty); + set => SetValue(BlurAmountProperty, value); + } + + public double Saturation + { + get => GetValue(SaturationProperty); + set => SetValue(SaturationProperty, value); + } + + public double AberrationIntensity + { + get => GetValue(AberrationIntensityProperty); + set => SetValue(AberrationIntensityProperty, value); + } + + public double CornerRadius + { + get => GetValue(CornerRadiusProperty); + set => SetValue(CornerRadiusProperty, value); + } + + public LiquidGlassMode Mode + { + get => GetValue(ModeProperty); + set => SetValue(ModeProperty, value); + } + + public bool OverLight + { + get => GetValue(OverLightProperty); + set => SetValue(OverLightProperty, value); + } + + public double X + { + get => GetValue(XProperty); + set => SetValue(XProperty, value); + } + + public double Y + { + get => GetValue(YProperty); + set => SetValue(YProperty, value); + } + + #endregion + + #region Drag State + + private bool _isDragging = false; + private Point _dragStartPoint; + private double _dragStartX; + private double _dragStartY; + + #endregion + + static DraggableLiquidGlassCard() + { + // 当任何属性变化时,触发重新渲染 + AffectsRender( + DisplacementScaleProperty, + BlurAmountProperty, + SaturationProperty, + AberrationIntensityProperty, + CornerRadiusProperty, + ModeProperty, + OverLightProperty + ); + + // 位置变化时触发重新布局 + AffectsArrange(XProperty, YProperty); + } + + public DraggableLiquidGlassCard() + { + // 监听所有属性变化并立即重新渲染 + PropertyChanged += OnPropertyChanged; + + // 监听DataContext变化,确保绑定生效后立即重新渲染 + PropertyChanged += (_, args) => + { + if (args.Property == DataContextProperty) + { + DebugLog("[DraggableLiquidGlassCard] DataContext changed - forcing re-render"); + // 延迟一下确保绑定完全生效 + Avalonia.Threading.Dispatcher.UIThread.Post(() => InvalidateVisual(), + Avalonia.Threading.DispatcherPriority.Background); + } + }; + + // 确保控件加载完成后立即重新渲染,以应用正确的初始参数 + Loaded += (_, _) => + { + DebugLog("[DraggableLiquidGlassCard] Loaded event - forcing re-render with current values"); + InvalidateVisual(); + }; + + // 在属性系统完全初始化后再次渲染 + AttachedToVisualTree += (_, _) => + { + DebugLog("[DraggableLiquidGlassCard] AttachedToVisualTree - forcing re-render"); + InvalidateVisual(); + }; + + // 设置默认大小 + Width = 200; + Height = 150; + + // 设置鼠标光标为手型,表示可拖拽 + Cursor = new Cursor(StandardCursorType.Hand); + } + + private void OnPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) + { + // 强制立即重新渲染 + if (e.Property == DisplacementScaleProperty || + e.Property == BlurAmountProperty || + e.Property == SaturationProperty || + e.Property == AberrationIntensityProperty || + e.Property == CornerRadiusProperty || + e.Property == ModeProperty || + e.Property == OverLightProperty) + { + DebugLog( + $"[DraggableLiquidGlassCard] Property {e.Property.Name} changed from {e.OldValue} to {e.NewValue}"); + InvalidateVisual(); + } + } + + #region Mouse Events for Dragging + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + + if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + _isDragging = true; + _dragStartPoint = e.GetPosition(Parent as Visual); + _dragStartX = X; + _dragStartY = Y; + + // 捕获指针,确保即使鼠标移出控件范围也能继续拖拽 + e.Pointer.Capture(this); + + // 改变光标为拖拽状态 + Cursor = new Cursor(StandardCursorType.SizeAll); + + DebugLog($"[DraggableLiquidGlassCard] Drag started at ({_dragStartX}, {_dragStartY})"); + } + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + + if (_isDragging) + { + var currentPoint = e.GetPosition(Parent as Visual); + var deltaX = currentPoint.X - _dragStartPoint.X; + var deltaY = currentPoint.Y - _dragStartPoint.Y; + + X = _dragStartX + deltaX; + Y = _dragStartY + deltaY; + + DebugLog($"[DraggableLiquidGlassCard] Dragging to ({X}, {Y})"); + } + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + + if (_isDragging) + { + _isDragging = false; + e.Pointer.Capture(null); + + // 恢复光标为手型 + Cursor = new Cursor(StandardCursorType.Hand); + + DebugLog($"[DraggableLiquidGlassCard] Drag ended at ({X}, {Y})"); + } + } + + protected override void OnPointerCaptureLost(PointerCaptureLostEventArgs e) + { + base.OnPointerCaptureLost(e); + + if (_isDragging) + { + _isDragging = false; + Cursor = new Cursor(StandardCursorType.Hand); + DebugLog("[DraggableLiquidGlassCard] Drag cancelled"); + } + } + + #endregion + + protected override Size ArrangeOverride(Size finalSize) + { + // 使用X和Y属性来定位控件 + return base.ArrangeOverride(finalSize); + } + + [Conditional("DEBUG")] + private static void DebugLog(string message) + => Console.WriteLine(message); + + public override void Render(DrawingContext context) + { + var bounds = new Rect(0, 0, Bounds.Width, Bounds.Height); + + // 调试:输出当前参数值 + DebugLog( + $"[DraggableLiquidGlassCard] Rendering at ({X}, {Y}) with DisplacementScale={DisplacementScale}, Saturation={Saturation}"); + + // 创建液态玻璃效果参数 - 悬浮卡片模式 + var parameters = new LiquidGlassParameters + { + DisplacementScale = DisplacementScale, + BlurAmount = BlurAmount, + Saturation = Saturation, + AberrationIntensity = AberrationIntensity, + Elasticity = 0.0, // 悬浮卡片不需要弹性效果 + CornerRadius = CornerRadius, + Mode = Mode, + IsHovered = _isDragging, // 拖拽时显示悬停效果 + IsActive = false, + OverLight = OverLight, + MouseOffsetX = 0.0, // 静态位置 + MouseOffsetY = 0.0, // 静态位置 + GlobalMouseX = 0.0, + GlobalMouseY = 0.0, + ActivationZone = 0.0 // 无激活区域 + }; + + // 静态渲染,无变换 + context.Custom(new LiquidGlassDrawOperation(bounds, parameters)); + } + } +} diff --git a/FreshViewer/LiquidGlass/LiquidGlassButton.cs b/FreshViewer/LiquidGlass/LiquidGlassButton.cs new file mode 100644 index 0000000..16f6523 --- /dev/null +++ b/FreshViewer/LiquidGlass/LiquidGlassButton.cs @@ -0,0 +1,448 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using System; +using System.Diagnostics; + +namespace FreshViewer.UI.LiquidGlass +{ + /// + /// 液态玻璃按钮控件 - 完整的鼠标交互响应 + /// + public class LiquidGlassButton : Control + { + #region Avalonia Properties + + /// + /// 位移缩放强度 + /// + public static readonly StyledProperty DisplacementScaleProperty = + AvaloniaProperty.Register(nameof(DisplacementScale), 20.0); + + /// + /// 模糊量 + /// + public static readonly StyledProperty BlurAmountProperty = + AvaloniaProperty.Register(nameof(BlurAmount), 0.15); + + /// + /// 饱和度 + /// + public static readonly StyledProperty SaturationProperty = + AvaloniaProperty.Register(nameof(Saturation), 120.0); + + /// + /// 色差强度 + /// + public static readonly StyledProperty AberrationIntensityProperty = + AvaloniaProperty.Register(nameof(AberrationIntensity), 7.0); + + /// + /// 弹性系数 + /// + public static readonly StyledProperty ElasticityProperty = + AvaloniaProperty.Register(nameof(Elasticity), 0.15); + + /// + /// 圆角半径 + /// + public static readonly StyledProperty CornerRadiusProperty = + AvaloniaProperty.Register(nameof(CornerRadius), 999.0); + + /// + /// 液态玻璃效果模式 + /// + public static readonly StyledProperty ModeProperty = + AvaloniaProperty.Register(nameof(Mode), LiquidGlassMode.Standard); + + /// + /// 是否处于悬停状态 + /// + public static readonly StyledProperty IsHoveredProperty = + AvaloniaProperty.Register(nameof(IsHovered), false); + + /// + /// 是否处于激活状态 + /// + public static readonly StyledProperty IsActiveProperty = + AvaloniaProperty.Register(nameof(IsActive), false); + + /// + /// 是否在亮色背景上 + /// + public static readonly StyledProperty OverLightProperty = + AvaloniaProperty.Register(nameof(OverLight), false); + + /// + /// 鼠标相对偏移 X (百分比) + /// + public static readonly StyledProperty MouseOffsetXProperty = + AvaloniaProperty.Register(nameof(MouseOffsetX), 0.0); + + /// + /// 鼠标相对偏移 Y (百分比) + /// + public static readonly StyledProperty MouseOffsetYProperty = + AvaloniaProperty.Register(nameof(MouseOffsetY), 0.0); + + /// + /// 全局鼠标位置 X + /// + public static readonly StyledProperty GlobalMouseXProperty = + AvaloniaProperty.Register(nameof(GlobalMouseX), 0.0); + + /// + /// 全局鼠标位置 Y + /// + public static readonly StyledProperty GlobalMouseYProperty = + AvaloniaProperty.Register(nameof(GlobalMouseY), 0.0); + + /// + /// 激活区域距离 (像素) + /// + public static readonly StyledProperty ActivationZoneProperty = + AvaloniaProperty.Register(nameof(ActivationZone), 200.0); + + #endregion + + #region Properties + + public double DisplacementScale + { + get => GetValue(DisplacementScaleProperty); + set => SetValue(DisplacementScaleProperty, value); + } + + public double BlurAmount + { + get => GetValue(BlurAmountProperty); + set => SetValue(BlurAmountProperty, value); + } + + public double Saturation + { + get => GetValue(SaturationProperty); + set => SetValue(SaturationProperty, value); + } + + public double AberrationIntensity + { + get => GetValue(AberrationIntensityProperty); + set => SetValue(AberrationIntensityProperty, value); + } + + public double Elasticity + { + get => GetValue(ElasticityProperty); + set => SetValue(ElasticityProperty, value); + } + + public double CornerRadius + { + get => GetValue(CornerRadiusProperty); + set => SetValue(CornerRadiusProperty, value); + } + + public LiquidGlassMode Mode + { + get => GetValue(ModeProperty); + set => SetValue(ModeProperty, value); + } + + public bool IsHovered + { + get => GetValue(IsHoveredProperty); + set => SetValue(IsHoveredProperty, value); + } + + public bool IsActive + { + get => GetValue(IsActiveProperty); + set => SetValue(IsActiveProperty, value); + } + + public bool OverLight + { + get => GetValue(OverLightProperty); + set => SetValue(OverLightProperty, value); + } + + public double MouseOffsetX + { + get => GetValue(MouseOffsetXProperty); + set => SetValue(MouseOffsetXProperty, value); + } + + public double MouseOffsetY + { + get => GetValue(MouseOffsetYProperty); + set => SetValue(MouseOffsetYProperty, value); + } + + public double GlobalMouseX + { + get => GetValue(GlobalMouseXProperty); + set => SetValue(GlobalMouseXProperty, value); + } + + public double GlobalMouseY + { + get => GetValue(GlobalMouseYProperty); + set => SetValue(GlobalMouseYProperty, value); + } + + public double ActivationZone + { + get => GetValue(ActivationZoneProperty); + set => SetValue(ActivationZoneProperty, value); + } + + #endregion + + static LiquidGlassButton() + { + // 当任何属性变化时,触发重新渲染 + AffectsRender( + DisplacementScaleProperty, + BlurAmountProperty, + SaturationProperty, + AberrationIntensityProperty, + ElasticityProperty, + CornerRadiusProperty, + ModeProperty, + IsHoveredProperty, + IsActiveProperty, + OverLightProperty, + MouseOffsetXProperty, + MouseOffsetYProperty, + GlobalMouseXProperty, + GlobalMouseYProperty, + ActivationZoneProperty + ); + } + + public LiquidGlassButton() + { + // 监听所有属性变化并立即重新渲染 + PropertyChanged += OnPropertyChanged; + + // 监听DataContext变化,确保绑定生效后立即重新渲染 + PropertyChanged += (_, args) => + { + if (args.Property == DataContextProperty) + { + DebugLog("[LiquidGlassButton] DataContext changed - forcing re-render"); + // 延迟一下确保绑定完全生效 + Avalonia.Threading.Dispatcher.UIThread.Post(() => InvalidateVisual(), + Avalonia.Threading.DispatcherPriority.Background); + } + }; + + // 确保控件加载完成后立即重新渲染,以应用正确的初始参数 + Loaded += (_, _) => + { + DebugLog("[LiquidGlassButton] Loaded event - forcing re-render with current values"); + InvalidateVisual(); + }; + + // 在属性系统完全初始化后再次渲染 + AttachedToVisualTree += (_, _) => + { + DebugLog("[LiquidGlassButton] AttachedToVisualTree - forcing re-render"); + InvalidateVisual(); + }; + } + + private void OnPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) + { + // 强制立即重新渲染 + if (e.Property == DisplacementScaleProperty || + e.Property == BlurAmountProperty || + e.Property == SaturationProperty || + e.Property == AberrationIntensityProperty || + e.Property == ElasticityProperty || + e.Property == CornerRadiusProperty || + e.Property == ModeProperty || + e.Property == IsHoveredProperty || + e.Property == IsActiveProperty || + e.Property == OverLightProperty || + e.Property == MouseOffsetXProperty || + e.Property == MouseOffsetYProperty || + e.Property == GlobalMouseXProperty || + e.Property == GlobalMouseYProperty || + e.Property == ActivationZoneProperty) + { + DebugLog($"[LiquidGlassButton] Property {e.Property.Name} changed from {e.OldValue} to {e.NewValue}"); + InvalidateVisual(); + } + } + + #region Mouse Event Handlers + + protected override void OnPointerEntered(Avalonia.Input.PointerEventArgs e) + { + base.OnPointerEntered(e); + IsHovered = true; + UpdateMousePosition(e.GetPosition(this)); + } + + protected override void OnPointerExited(Avalonia.Input.PointerEventArgs e) + { + base.OnPointerExited(e); + IsHovered = false; + // 鼠标离开时重置位置 + MouseOffsetX = 0.0; + MouseOffsetY = 0.0; + GlobalMouseX = 0.0; + GlobalMouseY = 0.0; + } + + protected override void OnPointerMoved(Avalonia.Input.PointerEventArgs e) + { + base.OnPointerMoved(e); + UpdateMousePosition(e.GetPosition(this)); + } + + protected override void OnPointerPressed(Avalonia.Input.PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + IsActive = true; + } + + protected override void OnPointerReleased(Avalonia.Input.PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + IsActive = false; + } + + #endregion + + #region Helper Methods + + /// + /// 更新鼠标位置并计算相对偏移 + /// + private void UpdateMousePosition(Point position) + { + if (Bounds.Width == 0 || Bounds.Height == 0) return; + + var centerX = Bounds.Width / 2; + var centerY = Bounds.Height / 2; + + // 计算相对偏移 (百分比) + MouseOffsetX = ((position.X - centerX) / Bounds.Width) * 100; + MouseOffsetY = ((position.Y - centerY) / Bounds.Height) * 100; + + // 设置全局鼠标位置(相对于控件) + GlobalMouseX = position.X; + GlobalMouseY = position.Y; + } + + /// + /// 计算淡入因子(基于鼠标距离元素边缘的距离) + /// + private double CalculateFadeInFactor() + { + if (GlobalMouseX == 0 && GlobalMouseY == 0) return 0; + + var centerX = Bounds.Width / 2; + var centerY = Bounds.Height / 2; + var pillWidth = Bounds.Width; + var pillHeight = Bounds.Height; + + var edgeDistanceX = Math.Max(0, Math.Abs(GlobalMouseX - centerX) - pillWidth / 2); + var edgeDistanceY = Math.Max(0, Math.Abs(GlobalMouseY - centerY) - pillHeight / 2); + var edgeDistance = Math.Sqrt(edgeDistanceX * edgeDistanceX + edgeDistanceY * edgeDistanceY); + + return edgeDistance > ActivationZone ? 0 : 1 - edgeDistance / ActivationZone; + } + + /// + /// 计算方向性缩放变换 + /// + private (double scaleX, double scaleY) CalculateDirectionalScale() + { + if (GlobalMouseX == 0 && GlobalMouseY == 0) return (1.0, 1.0); + + var centerX = Bounds.Width / 2; + var centerY = Bounds.Height / 2; + var deltaX = GlobalMouseX - centerX; + var deltaY = GlobalMouseY - centerY; + + var centerDistance = Math.Sqrt(deltaX * deltaX + deltaY * deltaY); + if (centerDistance == 0) return (1.0, 1.0); + + var normalizedX = deltaX / centerDistance; + var normalizedY = deltaY / centerDistance; + var fadeInFactor = CalculateFadeInFactor(); + var stretchIntensity = Math.Min(centerDistance / 300, 1) * Elasticity * fadeInFactor; + + // X轴缩放:左右移动时水平拉伸,上下移动时压缩 + var scaleX = 1 + Math.Abs(normalizedX) * stretchIntensity * 0.3 - + Math.Abs(normalizedY) * stretchIntensity * 0.15; + + // Y轴缩放:上下移动时垂直拉伸,左右移动时压缩 + var scaleY = 1 + Math.Abs(normalizedY) * stretchIntensity * 0.3 - + Math.Abs(normalizedX) * stretchIntensity * 0.15; + + return (Math.Max(0.8, scaleX), Math.Max(0.8, scaleY)); + } + + /// + /// 计算弹性位移 + /// + private (double x, double y) CalculateElasticTranslation() + { + var fadeInFactor = CalculateFadeInFactor(); + var centerX = Bounds.Width / 2; + var centerY = Bounds.Height / 2; + + return ( + (GlobalMouseX - centerX) * Elasticity * 0.1 * fadeInFactor, + (GlobalMouseY - centerY) * Elasticity * 0.1 * fadeInFactor + ); + } + + #endregion + + public override void Render(DrawingContext context) + { + var bounds = new Rect(0, 0, Bounds.Width, Bounds.Height); + + // 创建液态玻璃效果参数 + var parameters = new LiquidGlassParameters + { + DisplacementScale = DisplacementScale, + BlurAmount = BlurAmount, + Saturation = Saturation, + AberrationIntensity = AberrationIntensity, + Elasticity = Elasticity, + CornerRadius = CornerRadius, + Mode = Mode, + IsHovered = IsHovered, + IsActive = IsActive, + OverLight = OverLight, + MouseOffsetX = MouseOffsetX, + MouseOffsetY = MouseOffsetY, + GlobalMouseX = GlobalMouseX, + GlobalMouseY = GlobalMouseY, + ActivationZone = ActivationZone + }; + + // 计算变换 + var (scaleX, scaleY) = CalculateDirectionalScale(); + var (translateX, translateY) = CalculateElasticTranslation(); + + // 应用变换 + using (context.PushTransform(Matrix.CreateScale(scaleX, scaleY) * + Matrix.CreateTranslation(translateX, translateY))) + { + context.Custom(new LiquidGlassDrawOperation(bounds, parameters)); + } + } + + [Conditional("DEBUG")] + private static void DebugLog(string message) + => Console.WriteLine(message); + } +} diff --git a/FreshViewer/LiquidGlass/LiquidGlassCard.cs b/FreshViewer/LiquidGlass/LiquidGlassCard.cs new file mode 100644 index 0000000..019af6b --- /dev/null +++ b/FreshViewer/LiquidGlass/LiquidGlassCard.cs @@ -0,0 +1,386 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using System; + +namespace FreshViewer.UI.LiquidGlass +{ + /// + /// 液态玻璃卡片控件 - 静态显示,不响应鼠标交互 + /// + public class LiquidGlassCard : Control + { + #region Avalonia Properties + + /// + /// 位移缩放强度 + /// + public static readonly StyledProperty DisplacementScaleProperty = + AvaloniaProperty.Register(nameof(DisplacementScale), 20.0); + + /// + /// 模糊量 + /// + public static readonly StyledProperty BlurAmountProperty = + AvaloniaProperty.Register(nameof(BlurAmount), 0.15); + + /// + /// 饱和度 + /// + public static readonly StyledProperty SaturationProperty = + AvaloniaProperty.Register(nameof(Saturation), 120.0); + + /// + /// 色差强度 + /// + public static readonly StyledProperty AberrationIntensityProperty = + AvaloniaProperty.Register(nameof(AberrationIntensity), 7.0); + + /// + /// 圆角半径 + /// + public static readonly StyledProperty CornerRadiusProperty = + AvaloniaProperty.Register(nameof(CornerRadius), 12.0); + + /// + /// 液态玻璃效果模式 + /// + public static readonly StyledProperty ModeProperty = + AvaloniaProperty.Register(nameof(Mode), LiquidGlassMode.Standard); + + /// + /// 是否在亮色背景上 + /// + public static readonly StyledProperty OverLightProperty = + AvaloniaProperty.Register(nameof(OverLight), false); + + #endregion + + #region Properties + + public double DisplacementScale + { + get => GetValue(DisplacementScaleProperty); + set => SetValue(DisplacementScaleProperty, value); + } + + public double BlurAmount + { + get => GetValue(BlurAmountProperty); + set => SetValue(BlurAmountProperty, value); + } + + public double Saturation + { + get => GetValue(SaturationProperty); + set => SetValue(SaturationProperty, value); + } + + public double AberrationIntensity + { + get => GetValue(AberrationIntensityProperty); + set => SetValue(AberrationIntensityProperty, value); + } + + public double CornerRadius + { + get => GetValue(CornerRadiusProperty); + set => SetValue(CornerRadiusProperty, value); + } + + public LiquidGlassMode Mode + { + get => GetValue(ModeProperty); + set => SetValue(ModeProperty, value); + } + + public bool OverLight + { + get => GetValue(OverLightProperty); + set => SetValue(OverLightProperty, value); + } + + #endregion + + #region Border Gloss Effect + + private Point _lastMousePosition = new Point(0, 0); + private bool _isMouseTracking = false; + + protected override void OnPointerEntered(Avalonia.Input.PointerEventArgs e) + { + base.OnPointerEntered(e); + _isMouseTracking = true; + _lastMousePosition = e.GetPosition(this); + InvalidateVisual(); + } + + protected override void OnPointerExited(Avalonia.Input.PointerEventArgs e) + { + base.OnPointerExited(e); + _isMouseTracking = false; + InvalidateVisual(); + } + + protected override void OnPointerMoved(Avalonia.Input.PointerEventArgs e) + { + base.OnPointerMoved(e); + if (_isMouseTracking) + { + _lastMousePosition = e.GetPosition(this); + InvalidateVisual(); + } + } + + #endregion + + static LiquidGlassCard() + { + // 当任何属性变化时,触发重新渲染 + AffectsRender( + DisplacementScaleProperty, + BlurAmountProperty, + SaturationProperty, + AberrationIntensityProperty, + CornerRadiusProperty, + ModeProperty, + OverLightProperty + ); + } + + public LiquidGlassCard() + { + // 监听所有属性变化并立即重新渲染 + PropertyChanged += OnPropertyChanged!; + + // 监听DataContext变化,确保绑定生效后立即重新渲染 + PropertyChanged += (sender, args) => + { + if (args.Property == DataContextProperty) + { +#if DEBUG + Console.WriteLine($"[LiquidGlassCard] DataContext changed - forcing re-render"); +#endif + // 延迟一下确保绑定完全生效 + Avalonia.Threading.Dispatcher.UIThread.Post(() => InvalidateVisual(), + Avalonia.Threading.DispatcherPriority.Background); + } + }; + + // 确保控件加载完成后立即重新渲染,以应用正确的初始参数 + Loaded += (_, _) => + { +#if DEBUG + Console.WriteLine($"[LiquidGlassCard] Loaded event - forcing re-render with current values"); +#endif + InvalidateVisual(); + }; + + // 在属性系统完全初始化后再次渲染 + AttachedToVisualTree += (_, _) => + { +#if DEBUG + Console.WriteLine($"[LiquidGlassCard] AttachedToVisualTree - forcing re-render"); +#endif + InvalidateVisual(); + }; + } + + private void OnPropertyChanged(object sender, AvaloniaPropertyChangedEventArgs e) + { + // 强制立即重新渲染 + if (e.Property == DisplacementScaleProperty || + e.Property == BlurAmountProperty || + e.Property == SaturationProperty || + e.Property == AberrationIntensityProperty || + e.Property == CornerRadiusProperty || + e.Property == ModeProperty || + e.Property == OverLightProperty) + { +#if DEBUG + Console.WriteLine( + $"[LiquidGlassCard] Property {e.Property.Name} changed from {e.OldValue} to {e.NewValue}"); +#endif + InvalidateVisual(); + } + } + + public override void Render(DrawingContext context) + { + var bounds = new Rect(0, 0, Bounds.Width, Bounds.Height); + +#if DEBUG + Console.WriteLine( + $"[LiquidGlassCard] Rendering with DisplacementScale={DisplacementScale}, Saturation={Saturation}"); +#endif + + if (!LiquidGlassPlatform.SupportsAdvancedEffects) + { + DrawFallbackCard(context, bounds); + return; + } + + // 创建液态玻璃效果参数 - 卡片模式不使用鼠标交互 + var parameters = new LiquidGlassParameters + { + DisplacementScale = DisplacementScale, + BlurAmount = BlurAmount, + Saturation = Saturation, + AberrationIntensity = AberrationIntensity, + Elasticity = 0.0, // 卡片不需要弹性效果 + CornerRadius = CornerRadius, + Mode = Mode, + IsHovered = false, // 卡片不响应悬停 + IsActive = false, // 卡片不响应激活 + OverLight = OverLight, + MouseOffsetX = 0.0, // 静态位置 + MouseOffsetY = 0.0, // 静态位置 + GlobalMouseX = 0.0, + GlobalMouseY = 0.0, + ActivationZone = 0.0 // 无激活区域 + }; + + // 渲染主要的液态玻璃效果 + context.Custom(new LiquidGlassDrawOperation(bounds, parameters)); + + // 绘制边框光泽效果 + if (_isMouseTracking) + { + DrawBorderGloss(context, bounds); + } + } + + private void DrawFallbackCard(DrawingContext context, Rect bounds) + { + if (bounds.Width <= 0 || bounds.Height <= 0) + { + return; + } + + var cornerRadius = new CornerRadius(Math.Min(CornerRadius, Math.Min(bounds.Width, bounds.Height) / 2)); + var roundedRect = new RoundedRect(bounds, cornerRadius); + + var background = new LinearGradientBrush + { + StartPoint = new RelativePoint(0.05, 0.0, RelativeUnit.Relative), + EndPoint = new RelativePoint(0.95, 1.0, RelativeUnit.Relative), + GradientStops = new GradientStops + { + new GradientStop(Color.FromArgb(0xFA, 0xFF, 0xFF, 0xFF), 0), + new GradientStop(Color.FromArgb(0xE6, 0xF1, 0xF8, 0xFF), 0.55), + new GradientStop(Color.FromArgb(0xD0, 0xE2, 0xF1, 0xFF), 1) + } + }; + + context.DrawRectangle(background, null, roundedRect); + + var highlightRect = new Rect(bounds.X + 2, bounds.Y + 2, bounds.Width - 4, bounds.Height * 0.45); + var highlight = new LinearGradientBrush + { + StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), + EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative), + GradientStops = new GradientStops + { + new GradientStop(Color.FromArgb(0xC0, 0xFF, 0xFF, 0xFF), 0), + new GradientStop(Color.FromArgb(0x2A, 0xFF, 0xFF, 0xFF), 1) + } + }; + + context.DrawRectangle(highlight, null, + new RoundedRect(highlightRect, new CornerRadius(cornerRadius.TopLeft, cornerRadius.TopRight, 0, 0))); + + var glowRect = new Rect(bounds.X + 6, bounds.Bottom - Math.Min(bounds.Height * 0.55, 90), bounds.Width - 12, + Math.Min(bounds.Height * 0.55, 90)); + var glow = new RadialGradientBrush + { + Center = new RelativePoint(0.5, 1.1, RelativeUnit.Relative), + GradientOrigin = new RelativePoint(0.5, 1.1, RelativeUnit.Relative), + RadiusX = new RelativeScalar(1.0, RelativeUnit.Relative), + RadiusY = new RelativeScalar(1.0, RelativeUnit.Relative), + GradientStops = new GradientStops + { + new GradientStop(Color.FromArgb(0x50, 0xD6, 0xF0, 0xFF), 0), + new GradientStop(Color.FromArgb(0x00, 0xD6, 0xF0, 0xFF), 1) + } + }; + + context.DrawRectangle(glow, null, + new RoundedRect(glowRect, + new CornerRadius(cornerRadius.BottomLeft, cornerRadius.BottomRight, cornerRadius.BottomRight, + cornerRadius.BottomLeft))); + + var borderPen = new Pen(new SolidColorBrush(Color.FromArgb(0x6A, 0xFC, 0xFF, 0xFF)), 1.0); + context.DrawRectangle(null, borderPen, roundedRect); + } + + private void DrawBorderGloss(DrawingContext context, Rect bounds) + { + if (bounds.Width <= 0 || bounds.Height <= 0) return; + + // 计算鼠标相对于控件中心的角度 + var centerX = bounds.Width / 2; + var centerY = bounds.Height / 2; + var deltaX = _lastMousePosition.X - centerX; + var deltaY = _lastMousePosition.Y - centerY; + var angle = Math.Atan2(deltaY, deltaX); + + // 计算光泽应该出现的边框位置 + var glossLength = Math.Min(bounds.Width, bounds.Height) * 0.3; // 光泽长度 + var glossWidth = 3.0; // 光泽宽度 + + // 根据鼠标位置决定光泽在哪条边上 + Point glossStart, glossEnd; + if (Math.Abs(deltaX) > Math.Abs(deltaY)) + { + // 光泽在左右边框 + if (deltaX > 0) // 鼠标在右侧,光泽在右边框 + { + var y = Math.Max(glossLength / 2, Math.Min(bounds.Height - glossLength / 2, _lastMousePosition.Y)); + glossStart = new Point(bounds.Width - glossWidth / 2, y - glossLength / 2); + glossEnd = new Point(bounds.Width - glossWidth / 2, y + glossLength / 2); + } + else // 鼠标在左侧,光泽在左边框 + { + var y = Math.Max(glossLength / 2, Math.Min(bounds.Height - glossLength / 2, _lastMousePosition.Y)); + glossStart = new Point(glossWidth / 2, y - glossLength / 2); + glossEnd = new Point(glossWidth / 2, y + glossLength / 2); + } + } + else + { + // 光泽在上下边框 + if (deltaY > 0) // 鼠标在下方,光泽在下边框 + { + var x = Math.Max(glossLength / 2, Math.Min(bounds.Width - glossLength / 2, _lastMousePosition.X)); + glossStart = new Point(x - glossLength / 2, bounds.Height - glossWidth / 2); + glossEnd = new Point(x + glossLength / 2, bounds.Height - glossWidth / 2); + } + else // 鼠标在上方,光泽在上边框 + { + var x = Math.Max(glossLength / 2, Math.Min(bounds.Width - glossLength / 2, _lastMousePosition.X)); + glossStart = new Point(x - glossLength / 2, glossWidth / 2); + glossEnd = new Point(x + glossLength / 2, glossWidth / 2); + } + } + + // 创建光泽渐变 + var glossBrush = new LinearGradientBrush + { + StartPoint = new RelativePoint(glossStart.X / bounds.Width, glossStart.Y / bounds.Height, + RelativeUnit.Relative), + EndPoint = new RelativePoint(glossEnd.X / bounds.Width, glossEnd.Y / bounds.Height, + RelativeUnit.Relative), + GradientStops = new GradientStops + { + new GradientStop(Colors.Transparent, 0.0), + new GradientStop(Color.FromArgb(100, 255, 255, 255), 0.5), + new GradientStop(Colors.Transparent, 1.0) + } + }; + + // 绘制光泽线条 + var pen = new Pen(glossBrush, glossWidth); + context.DrawLine(pen, glossStart, glossEnd); + } + } +} diff --git a/FreshViewer/LiquidGlass/LiquidGlassControl.cs b/FreshViewer/LiquidGlass/LiquidGlassControl.cs new file mode 100644 index 0000000..b4cefa2 --- /dev/null +++ b/FreshViewer/LiquidGlass/LiquidGlassControl.cs @@ -0,0 +1,411 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Platform; +using Avalonia.Rendering.SceneGraph; +using Avalonia.Skia; +using SkiaSharp; +using System; +using System.IO; +using System.Numerics; + +namespace FreshViewer.UI.LiquidGlass +{ + /// + /// 液态玻璃效果模式枚举 + /// + public enum LiquidGlassMode + { + Standard, // 标准径向位移模式 + Polar, // 极坐标位移模式 + Prominent, // 突出边缘位移模式 + Shader // 实时生成的 Shader 位移模式 + } + + /// + /// 液态玻璃控件 - 完全复刻 TypeScript 版本功能 + /// + public class LiquidGlassControl : Control + { + #region Avalonia Properties + + /// + /// 位移缩放强度 (对应 TS 版本的 displacementScale) + /// + public static readonly StyledProperty DisplacementScaleProperty = + AvaloniaProperty.Register(nameof(DisplacementScale), 70.0); + + /// + /// 模糊量 (对应 TS 版本的 blurAmount) + /// + public static readonly StyledProperty BlurAmountProperty = + AvaloniaProperty.Register(nameof(BlurAmount), 0.0625); + + /// + /// 饱和度 (对应 TS 版本的 saturation) + /// + public static readonly StyledProperty SaturationProperty = + AvaloniaProperty.Register(nameof(Saturation), 140.0); + + /// + /// 色差强度 (对应 TS 版本的 aberrationIntensity) + /// + public static readonly StyledProperty AberrationIntensityProperty = + AvaloniaProperty.Register(nameof(AberrationIntensity), 2.0); + + /// + /// 弹性系数 (对应 TS 版本的 elasticity) + /// + public static readonly StyledProperty ElasticityProperty = + AvaloniaProperty.Register(nameof(Elasticity), 0.15); + + /// + /// 圆角半径 (对应 TS 版本的 cornerRadius) + /// + public static readonly StyledProperty CornerRadiusProperty = + AvaloniaProperty.Register(nameof(CornerRadius), 999.0); + + /// + /// 液态玻璃效果模式 + /// + public static readonly StyledProperty ModeProperty = + AvaloniaProperty.Register(nameof(Mode), LiquidGlassMode.Standard); + + /// + /// 是否处于悬停状态 + /// + public static readonly StyledProperty IsHoveredProperty = + AvaloniaProperty.Register(nameof(IsHovered), false); + + /// + /// 是否处于激活状态 + /// + public static readonly StyledProperty IsActiveProperty = + AvaloniaProperty.Register(nameof(IsActive), false); + + /// + /// 是否在亮色背景上 + /// + public static readonly StyledProperty OverLightProperty = + AvaloniaProperty.Register(nameof(OverLight), false); + + /// + /// 鼠标相对偏移 X (百分比) + /// + public static readonly StyledProperty MouseOffsetXProperty = + AvaloniaProperty.Register(nameof(MouseOffsetX), 0.0); + + /// + /// 鼠标相对偏移 Y (百分比) + /// + public static readonly StyledProperty MouseOffsetYProperty = + AvaloniaProperty.Register(nameof(MouseOffsetY), 0.0); + + /// + /// 全局鼠标位置 X + /// + public static readonly StyledProperty GlobalMouseXProperty = + AvaloniaProperty.Register(nameof(GlobalMouseX), 0.0); + + /// + /// 全局鼠标位置 Y + /// + public static readonly StyledProperty GlobalMouseYProperty = + AvaloniaProperty.Register(nameof(GlobalMouseY), 0.0); + + /// + /// 激活区域距离 (像素) + /// + public static readonly StyledProperty ActivationZoneProperty = + AvaloniaProperty.Register(nameof(ActivationZone), 200.0); + + #endregion + + #region Properties + + public double DisplacementScale + { + get => GetValue(DisplacementScaleProperty); + set => SetValue(DisplacementScaleProperty, value); + } + + public double BlurAmount + { + get => GetValue(BlurAmountProperty); + set => SetValue(BlurAmountProperty, value); + } + + public double Saturation + { + get => GetValue(SaturationProperty); + set => SetValue(SaturationProperty, value); + } + + public double AberrationIntensity + { + get => GetValue(AberrationIntensityProperty); + set => SetValue(AberrationIntensityProperty, value); + } + + public double Elasticity + { + get => GetValue(ElasticityProperty); + set => SetValue(ElasticityProperty, value); + } + + public double CornerRadius + { + get => GetValue(CornerRadiusProperty); + set => SetValue(CornerRadiusProperty, value); + } + + public LiquidGlassMode Mode + { + get => GetValue(ModeProperty); + set => SetValue(ModeProperty, value); + } + + public bool IsHovered + { + get => GetValue(IsHoveredProperty); + set => SetValue(IsHoveredProperty, value); + } + + public bool IsActive + { + get => GetValue(IsActiveProperty); + set => SetValue(IsActiveProperty, value); + } + + public bool OverLight + { + get => GetValue(OverLightProperty); + set => SetValue(OverLightProperty, value); + } + + public double MouseOffsetX + { + get => GetValue(MouseOffsetXProperty); + set => SetValue(MouseOffsetXProperty, value); + } + + public double MouseOffsetY + { + get => GetValue(MouseOffsetYProperty); + set => SetValue(MouseOffsetYProperty, value); + } + + public double GlobalMouseX + { + get => GetValue(GlobalMouseXProperty); + set => SetValue(GlobalMouseXProperty, value); + } + + public double GlobalMouseY + { + get => GetValue(GlobalMouseYProperty); + set => SetValue(GlobalMouseYProperty, value); + } + + public double ActivationZone + { + get => GetValue(ActivationZoneProperty); + set => SetValue(ActivationZoneProperty, value); + } + + #endregion + + static LiquidGlassControl() + { + // 当任何属性变化时,触发重新渲染 + AffectsRender( + DisplacementScaleProperty, + BlurAmountProperty, + SaturationProperty, + AberrationIntensityProperty, + ElasticityProperty, + CornerRadiusProperty, + ModeProperty, + IsHoveredProperty, + IsActiveProperty, + OverLightProperty, + MouseOffsetXProperty, + MouseOffsetYProperty, + GlobalMouseXProperty, + GlobalMouseYProperty, + ActivationZoneProperty + ); + } + + protected override void OnPointerEntered(Avalonia.Input.PointerEventArgs e) + { + base.OnPointerEntered(e); + IsHovered = true; + UpdateMousePosition(e.GetPosition(this)); + } + + protected override void OnPointerExited(Avalonia.Input.PointerEventArgs e) + { + base.OnPointerExited(e); + IsHovered = false; + } + + protected override void OnPointerMoved(Avalonia.Input.PointerEventArgs e) + { + base.OnPointerMoved(e); + UpdateMousePosition(e.GetPosition(this)); + } + + protected override void OnPointerPressed(Avalonia.Input.PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + IsActive = true; + } + + protected override void OnPointerReleased(Avalonia.Input.PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + IsActive = false; + } + + /// + /// 更新鼠标位置并计算相对偏移 + /// + private void UpdateMousePosition(Point position) + { + var centerX = Bounds.Width / 2; + var centerY = Bounds.Height / 2; + + // 计算相对偏移 (百分比) + MouseOffsetX = ((position.X - centerX) / Bounds.Width) * 100; + MouseOffsetY = ((position.Y - centerY) / Bounds.Height) * 100; + + // 设置全局鼠标位置(相对于控件) + GlobalMouseX = position.X; + GlobalMouseY = position.Y; + } + + /// + /// 计算淡入因子(基于鼠标距离元素边缘的距离) + /// + private double CalculateFadeInFactor() + { + if (GlobalMouseX == 0 && GlobalMouseY == 0) return 0; + + var centerX = Bounds.Width / 2; + var centerY = Bounds.Height / 2; + var pillWidth = Bounds.Width; + var pillHeight = Bounds.Height; + + var edgeDistanceX = Math.Max(0, Math.Abs(GlobalMouseX - centerX) - pillWidth / 2); + var edgeDistanceY = Math.Max(0, Math.Abs(GlobalMouseY - centerY) - pillHeight / 2); + var edgeDistance = Math.Sqrt(edgeDistanceX * edgeDistanceX + edgeDistanceY * edgeDistanceY); + + return edgeDistance > ActivationZone ? 0 : 1 - edgeDistance / ActivationZone; + } + + /// + /// 计算方向性缩放变换 + /// + private (double scaleX, double scaleY) CalculateDirectionalScale() + { + if (GlobalMouseX == 0 && GlobalMouseY == 0) return (1.0, 1.0); + + var centerX = Bounds.Width / 2; + var centerY = Bounds.Height / 2; + var deltaX = GlobalMouseX - centerX; + var deltaY = GlobalMouseY - centerY; + + var centerDistance = Math.Sqrt(deltaX * deltaX + deltaY * deltaY); + if (centerDistance == 0) return (1.0, 1.0); + + var normalizedX = deltaX / centerDistance; + var normalizedY = deltaY / centerDistance; + var fadeInFactor = CalculateFadeInFactor(); + var stretchIntensity = Math.Min(centerDistance / 300, 1) * Elasticity * fadeInFactor; + + // X轴缩放:左右移动时水平拉伸,上下移动时压缩 + var scaleX = 1 + Math.Abs(normalizedX) * stretchIntensity * 0.3 - + Math.Abs(normalizedY) * stretchIntensity * 0.15; + + // Y轴缩放:上下移动时垂直拉伸,左右移动时压缩 + var scaleY = 1 + Math.Abs(normalizedY) * stretchIntensity * 0.3 - + Math.Abs(normalizedX) * stretchIntensity * 0.15; + + return (Math.Max(0.8, scaleX), Math.Max(0.8, scaleY)); + } + + /// + /// 计算弹性位移 + /// + private (double x, double y) CalculateElasticTranslation() + { + var fadeInFactor = CalculateFadeInFactor(); + var centerX = Bounds.Width / 2; + var centerY = Bounds.Height / 2; + + return ( + (GlobalMouseX - centerX) * Elasticity * 0.1 * fadeInFactor, + (GlobalMouseY - centerY) * Elasticity * 0.1 * fadeInFactor + ); + } + + public override void Render(DrawingContext context) + { + var bounds = new Rect(0, 0, Bounds.Width, Bounds.Height); + + // 创建液态玻璃效果参数 + var parameters = new LiquidGlassParameters + { + DisplacementScale = DisplacementScale, + BlurAmount = BlurAmount, + Saturation = Saturation, + AberrationIntensity = AberrationIntensity, + Elasticity = Elasticity, + CornerRadius = CornerRadius, + Mode = Mode, + IsHovered = IsHovered, + IsActive = IsActive, + OverLight = OverLight, + MouseOffsetX = MouseOffsetX, + MouseOffsetY = MouseOffsetY, + GlobalMouseX = GlobalMouseX, + GlobalMouseY = GlobalMouseY, + ActivationZone = ActivationZone + }; + + // 计算变换 + var (scaleX, scaleY) = CalculateDirectionalScale(); + var (translateX, translateY) = CalculateElasticTranslation(); + + // 应用变换 + using (context.PushTransform(Matrix.CreateScale(scaleX, scaleY) * + Matrix.CreateTranslation(translateX, translateY))) + { + context.Custom(new LiquidGlassDrawOperation(bounds, parameters)); + } + } + } + + /// + /// 液态玻璃效果参数集合 + /// + public struct LiquidGlassParameters + { + public double DisplacementScale { get; set; } + public double BlurAmount { get; set; } + public double Saturation { get; set; } + public double AberrationIntensity { get; set; } + public double Elasticity { get; set; } + public double CornerRadius { get; set; } + public LiquidGlassMode Mode { get; set; } + public bool IsHovered { get; set; } + public bool IsActive { get; set; } + public bool OverLight { get; set; } + public double MouseOffsetX { get; set; } + public double MouseOffsetY { get; set; } + public double GlobalMouseX { get; set; } + public double GlobalMouseY { get; set; } + public double ActivationZone { get; set; } + } +} diff --git a/FreshViewer/LiquidGlass/LiquidGlassControlOld.cs b/FreshViewer/LiquidGlass/LiquidGlassControlOld.cs new file mode 100644 index 0000000..237b8a3 --- /dev/null +++ b/FreshViewer/LiquidGlass/LiquidGlassControlOld.cs @@ -0,0 +1,245 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Platform; +using Avalonia.Rendering.SceneGraph; +using Avalonia.Skia; +using SkiaSharp; +using System; +using System.IO; + +namespace FreshViewer.UI.LiquidGlass +{ + public class LiquidGlassControlOld : Control + { + #region Avalonia Properties + + public static readonly StyledProperty RadiusProperty = + AvaloniaProperty.Register(nameof(Radius), 25.0); + + // Radius 属性控制扭曲效果的强度。 + public double Radius + { + get => GetValue(RadiusProperty); + set => SetValue(RadiusProperty, value); + } + + #endregion + + static LiquidGlassControlOld() + { + // 当 Radius 属性变化时,触发重新渲染。 + AffectsRender(RadiusProperty); + } + + /// + /// 重写标准的 Render 方法来执行所有的绘图操作。 + /// + public override void Render(DrawingContext context) + { + // 使用 Custom 方法将我们的 Skia 绘图逻辑插入到渲染管线中。 + // 关键改动:在这里直接传递 Radius 的值,而不是在渲染线程中访问它。 + if (!LiquidGlassPlatform.SupportsAdvancedEffects) + { + DrawFallback(context); + return; + } + + context.Custom(new LiquidGlassDrawOperation(new Rect(0, 0, Bounds.Width, Bounds.Height), Radius)); + + // 因为这个控件没有子元素,所以我们不再调用 base.Render()。 + } + + /// + /// 一个处理 Skia 渲染的自定义绘图操作。 + /// + private class LiquidGlassDrawOperation : ICustomDrawOperation + { + private readonly Rect _bounds; + + // 存储从 UI 线程传递过来的 Radius 值。 + private readonly double _radius; + + private static SKRuntimeEffect? _effect; + private static bool _isShaderLoaded; + + // 构造函数现在接收一个 double 类型的 radius。 + public LiquidGlassDrawOperation(Rect bounds, double radius) + { + _bounds = bounds; + _radius = radius; + } + + public void Dispose() + { + } + + public bool HitTest(Point p) => _bounds.Contains(p); + + public Rect Bounds => _bounds; + + public bool Equals(ICustomDrawOperation? other) => false; + + public void Render(ImmediateDrawingContext context) + { + var leaseFeature = context.TryGetFeature(); + if (leaseFeature is null) return; + + // 确保着色器只被加载一次。 + LoadShader(); + + using var lease = leaseFeature.Lease(); + var canvas = lease.SkCanvas; + + if (_effect is null) + { + DrawErrorHint(canvas); + } + else + { + DrawLiquidGlassEffect(canvas, lease); + } + } + + private void LoadShader() + { + if (_isShaderLoaded) return; + _isShaderLoaded = true; + + try + { + // 确保你的 .csproj 文件中包含了正确的 AvaloniaResource。 + // + var assetUri = new Uri("avares://FreshViewer/LiquidGlass/Assets/Shaders/LiquidGlassShader.sksl"); + using var stream = AssetLoader.Open(assetUri); + using var reader = new StreamReader(stream); + var shaderCode = reader.ReadToEnd(); + + _effect = SKRuntimeEffect.Create(shaderCode, out var errorText); + if (_effect == null) + { + Console.WriteLine($"[SKIA ERROR] Failed to create SKRuntimeEffect: {errorText}"); + } + } + catch (Exception ex) + { + Console.WriteLine($"[AVALONIA ERROR] Exception while loading shader: {ex.Message}"); + } + } + + private void DrawErrorHint(SKCanvas canvas) + { + using var errorPaint = new SKPaint + { + Color = new SKColor(255, 0, 0, 120), // 半透明红色 + Style = SKPaintStyle.Fill + }; + canvas.DrawRect(SKRect.Create(0, 0, (float)_bounds.Width, (float)_bounds.Height), errorPaint); + + using var textPaint = new SKPaint + { + Color = SKColors.White, + TextSize = 14, + IsAntialias = true, + TextAlign = SKTextAlign.Center + }; + canvas.DrawText("Shader Failed to Load!", (float)_bounds.Width / 2, (float)_bounds.Height / 2, + textPaint); + } + + private void DrawLiquidGlassEffect(SKCanvas canvas, ISkiaSharpApiLease lease) + { + if (_effect is null) return; + + // 获取背景的快照 + using var backgroundSnapshot = lease.SkSurface?.Snapshot(); + if (backgroundSnapshot is null) return; + + if (!canvas.TotalMatrix.TryInvert(out var currentInvertedTransform)) + return; + + using var backdropShader = SKShader.CreateImage(backgroundSnapshot, SKShaderTileMode.Clamp, + SKShaderTileMode.Clamp, currentInvertedTransform); + + var pixelSize = new PixelSize((int)_bounds.Width, (int)_bounds.Height); + var uniforms = new SKRuntimeEffectUniforms(_effect); + + // 关键改动:使用从构造函数中存储的 _radius 值。 + uniforms["radius"] = (float)_radius; + uniforms["resolution"] = new[] { (float)pixelSize.Width, (float)pixelSize.Height }; + + var children = new SKRuntimeEffectChildren(_effect) { { "content", backdropShader } }; + using var finalShader = _effect.ToShader(false, uniforms, children); + + using var paint = new SKPaint { Shader = finalShader }; + canvas.DrawRect(SKRect.Create(0, 0, (float)_bounds.Width, (float)_bounds.Height), paint); + + if (children is IDisposable disposableChildren) + { + disposableChildren.Dispose(); + } + + if (uniforms is IDisposable disposableUniforms) + { + disposableUniforms.Dispose(); + } + } + } + + private void DrawFallback(DrawingContext context) + { + var bounds = new Rect(0, 0, Bounds.Width, Bounds.Height); + if (bounds.Width <= 0 || bounds.Height <= 0) + { + return; + } + + var rounded = new RoundedRect(bounds, new CornerRadius(Radius)); + var fill = new LinearGradientBrush + { + StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), + EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative), + GradientStops = new GradientStops + { + new GradientStop(Color.FromArgb(0xF2, 0xFF, 0xFF, 0xFF), 0), + new GradientStop(Color.FromArgb(0xDC, 0xEC, 0xF7, 0xFF), 1) + } + }; + context.DrawRectangle(fill, null, rounded); + + var sheen = new LinearGradientBrush + { + StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), + EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative), + GradientStops = new GradientStops + { + new GradientStop(Color.FromArgb(0xB0, 0xFF, 0xFF, 0xFF), 0), + new GradientStop(Color.FromArgb(0x1B, 0xFF, 0xFF, 0xFF), 1) + } + }; + context.DrawRectangle(sheen, null, + new RoundedRect(new Rect(bounds.X + 4, bounds.Y + 4, bounds.Width - 8, bounds.Height * 0.55), + new CornerRadius(Radius))); + + var glow = new RadialGradientBrush + { + Center = new RelativePoint(0.5, 1.2, RelativeUnit.Relative), + GradientOrigin = new RelativePoint(0.5, 1.2, RelativeUnit.Relative), + RadiusX = new RelativeScalar(1.0, RelativeUnit.Relative), + RadiusY = new RelativeScalar(1.0, RelativeUnit.Relative), + GradientStops = new GradientStops + { + new GradientStop(Color.FromArgb(0x38, 0xD6, 0xF0, 0xFF), 0), + new GradientStop(Color.FromArgb(0x00, 0xD6, 0xF0, 0xFF), 1) + } + }; + context.DrawRectangle(glow, null, + new RoundedRect( + new Rect(bounds.X + 8, bounds.Bottom - Math.Min(bounds.Height * 0.5, 120), bounds.Width - 16, + Math.Min(bounds.Height * 0.5, 120)), new CornerRadius(Radius))); + + var borderPen = new Pen(new SolidColorBrush(Color.FromArgb(0x60, 0xD9, 0xEE, 0xFF)), 1.0); + context.DrawRectangle(null, borderPen, rounded); + } + } +} diff --git a/FreshViewer/LiquidGlass/LiquidGlassDecorator.cs b/FreshViewer/LiquidGlass/LiquidGlassDecorator.cs new file mode 100644 index 0000000..7ee4948 --- /dev/null +++ b/FreshViewer/LiquidGlass/LiquidGlassDecorator.cs @@ -0,0 +1,279 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using Avalonia.Rendering; +using Avalonia.Rendering.SceneGraph; +using Avalonia.Skia; +using Avalonia.VisualTree; +using SkiaSharp; +using System; +using System.IO; + +namespace FreshViewer.UI.LiquidGlass +{ + // 注意:此控件现在应用的是“液态玻璃”扭曲效果,而不是之前的毛玻璃模糊。 + public class LiquidGlassDecorator : Decorator + { + #region Re-entrancy Guard + + // 一个防止 Render 方法被递归调用的标志。 + private bool _isRendering; + + #endregion + + #region Avalonia Properties + + public static readonly StyledProperty RadiusProperty = + AvaloniaProperty.Register(nameof(Radius), 25.0); + + // Radius 属性现在控制扭曲效果。 + public double Radius + { + get => GetValue(RadiusProperty); + set => SetValue(RadiusProperty, value); + } + + #endregion + + static LiquidGlassDecorator() + { + // 当 Radius 属性变化时,触发重绘。 + AffectsRender(RadiusProperty); + } + + /// + /// 重写标准的 Render 方法来执行所有绘制操作。 + /// + public override void Render(DrawingContext context) + { + // 重入守卫:如果我们已经在渲染中,则不开始新的渲染。 + // 这会打破递归循环。 + if (_isRendering) + return; + + try + { + _isRendering = true; + + // 1. 首先,调用 base.Render(context) 让 Avalonia 绘制所有子控件。 + // 这样就完成了标准的渲染通道。 + base.Render(context); + + if (!LiquidGlassPlatform.SupportsAdvancedEffects) + { + DrawFallbackOverlay(context); + return; + } + + // 2. 在子控件被绘制之后,插入我们的自定义模糊操作。 + // 这会在已渲染的子控件之上绘制我们的效果, + // 但效果本身采样的是原始背景,从而产生子控件在玻璃之上的错觉。 + // 这个机制打破了渲染循环。 + context.Custom(new LiquidGlassDrawOperation(new Rect(0, 0, Bounds.Width, Bounds.Height), this)); + } + finally + { + _isRendering = false; + } + } + + private void DrawFallbackOverlay(DrawingContext context) + { + var bounds = new Rect(0, 0, Bounds.Width, Bounds.Height); + if (bounds.Width <= 0 || bounds.Height <= 0) + { + return; + } + + var rounded = new RoundedRect(bounds, new CornerRadius(Radius)); + + var backdrop = new LinearGradientBrush + { + StartPoint = new RelativePoint(0.05, 0.0, RelativeUnit.Relative), + EndPoint = new RelativePoint(0.95, 1.0, RelativeUnit.Relative), + GradientStops = new GradientStops + { + new GradientStop(Color.FromArgb(0xF5, 0xFF, 0xFF, 0xFF), 0), + new GradientStop(Color.FromArgb(0xE2, 0xF3, 0xFF, 0xFF), 0.5), + new GradientStop(Color.FromArgb(0xCC, 0xE6, 0xF5, 0xFF), 1) + } + }; + + context.DrawRectangle(backdrop, null, rounded); + + var topHighlight = new LinearGradientBrush + { + StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), + EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative), + GradientStops = new GradientStops + { + new GradientStop(Color.FromArgb(0xBE, 0xFF, 0xFF, 0xFF), 0), + new GradientStop(Color.FromArgb(0x25, 0xFF, 0xFF, 0xFF), 1) + } + }; + + context.DrawRectangle(topHighlight, null, + new RoundedRect(new Rect(bounds.X + 6, bounds.Y + 6, bounds.Width - 12, bounds.Height * 0.5), + new CornerRadius(Radius))); + + var bottomGlowRect = new Rect(bounds.X + 10, bounds.Bottom - Math.Min(bounds.Height * 0.5, 120), + bounds.Width - 20, Math.Min(bounds.Height * 0.5, 120)); + var bottomGlow = new RadialGradientBrush + { + Center = new RelativePoint(0.5, 1.15, RelativeUnit.Relative), + GradientOrigin = new RelativePoint(0.5, 1.15, RelativeUnit.Relative), + RadiusX = new RelativeScalar(1.0, RelativeUnit.Relative), + RadiusY = new RelativeScalar(1.0, RelativeUnit.Relative), + GradientStops = new GradientStops + { + new GradientStop(Color.FromArgb(0x45, 0xD6, 0xF0, 0xFF), 0), + new GradientStop(Color.FromArgb(0x00, 0xD6, 0xF0, 0xFF), 1) + } + }; + + context.DrawRectangle(bottomGlow, null, new RoundedRect(bottomGlowRect, new CornerRadius(Radius))); + + var borderPen = new Pen(new SolidColorBrush(Color.FromArgb(0x5A, 0xC4, 0xD8, 0xFF)), 1.0); + context.DrawRectangle(null, borderPen, rounded); + } + + /// + /// 处理 Skia 渲染的自定义绘制操作。 + /// + private class LiquidGlassDrawOperation : ICustomDrawOperation + { + private readonly Rect _bounds; + private readonly LiquidGlassDecorator _owner; + + private static SKRuntimeEffect? _effect; + private static bool _isShaderLoaded; + + public LiquidGlassDrawOperation(Rect bounds, LiquidGlassDecorator owner) + { + _bounds = bounds; + _owner = owner; + } + + public void Dispose() + { + } + + public bool HitTest(Point p) => _bounds.Contains(p); + + public Rect Bounds => _bounds; + + public bool Equals(ICustomDrawOperation? other) => false; + + public void Render(ImmediateDrawingContext context) + { + var leaseFeature = context.TryGetFeature(); + if (leaseFeature is null) return; + + LoadShader(); + + using var lease = leaseFeature.Lease(); + var canvas = lease.SkCanvas; + + if (_effect is null) + { + DrawErrorHint(canvas); + } + else + { + DrawLiquidGlassEffect(canvas, lease); + } + } + + private void LoadShader() + { + if (_isShaderLoaded) return; + _isShaderLoaded = true; + + try + { + // 更新为加载新的液态玻璃着色器。 + var assetUri = new Uri("avares://FreshViewer/LiquidGlass/Assets/Shaders/LiquidGlassShader.sksl"); + using var stream = AssetLoader.Open(assetUri); + using var reader = new StreamReader(stream); + var shaderCode = reader.ReadToEnd(); + + _effect = SKRuntimeEffect.Create(shaderCode, out var errorText); + if (_effect == null) + { + Console.WriteLine($"创建 SKRuntimeEffect 失败: {errorText}"); + } + } + catch (Exception ex) + { + Console.WriteLine($"加载着色器时发生异常: {ex.Message}"); + } + } + + private void DrawErrorHint(SKCanvas canvas) + { + using var errorPaint = new SKPaint + { + Color = new SKColor(255, 0, 0, 120), // 半透明红色 + Style = SKPaintStyle.Fill + }; + canvas.DrawRect(SKRect.Create(0, 0, (float)_bounds.Width, (float)_bounds.Height), errorPaint); + + using var textPaint = new SKPaint + { + Color = SKColors.White, + TextSize = 14, + IsAntialias = true, + TextAlign = SKTextAlign.Center + }; + canvas.DrawText("着色器加载失败!", (float)_bounds.Width / 2, (float)_bounds.Height / 2, textPaint); + } + + private void DrawLiquidGlassEffect(SKCanvas canvas, ISkiaSharpApiLease lease) + { + if (_effect is null) return; + + // 1. 截取当前绘图表面的快照。这会捕获到目前为止绘制的所有内容。 + using var backgroundSnapshot = lease.SkSurface?.Snapshot(); + if (backgroundSnapshot is null) return; + + // 2. 获取画布的反转变换矩阵。这对于将全屏快照正确映射到 + // 我们本地控件的坐标空间至关重要。 + if (!canvas.TotalMatrix.TryInvert(out var currentInvertedTransform)) + return; + + // 3. 从背景快照创建一个着色器,并应用反转变换。 + // 这个着色器现在将正确地采样我们控件正后方的像素。 + using var backdropShader = SKShader.CreateImage(backgroundSnapshot, SKShaderTileMode.Clamp, + SKShaderTileMode.Clamp, currentInvertedTransform); + + // 4. 为我们的 SKSL 扭曲着色器准备 uniforms。 + var pixelSize = new PixelSize((int)_bounds.Width, (int)_bounds.Height); + var uniforms = new SKRuntimeEffectUniforms(_effect); + + // 更新为传递 "radius" 而不是 "blurRadius"。 + uniforms["radius"] = (float)_owner.Radius; + uniforms["resolution"] = new[] { (float)pixelSize.Width, (float)pixelSize.Height }; + + // 5. 通过将我们的背景着色器作为 'content' 输入提供给 SKSL 效果,来创建最终的着色器。 + var children = new SKRuntimeEffectChildren(_effect) { { "content", backdropShader } }; + using var finalShader = _effect.ToShader(false, uniforms, children); + + // 6. 创建一个带有最终着色器的画笔并进行绘制。 + using var paint = new SKPaint { Shader = finalShader }; + canvas.DrawRect(SKRect.Create(0, 0, (float)_bounds.Width, (float)_bounds.Height), paint); + + if (children is IDisposable disposableChildren) + { + disposableChildren.Dispose(); + } + + if (uniforms is IDisposable disposableUniforms) + { + disposableUniforms.Dispose(); + } + } + } + } +} diff --git a/FreshViewer/LiquidGlass/LiquidGlassDrawOperation.cs b/FreshViewer/LiquidGlass/LiquidGlassDrawOperation.cs new file mode 100644 index 0000000..98d4756 --- /dev/null +++ b/FreshViewer/LiquidGlass/LiquidGlassDrawOperation.cs @@ -0,0 +1,453 @@ +using Avalonia; +using Avalonia.Platform; +using Avalonia.Rendering.SceneGraph; +using Avalonia.Skia; +using SkiaSharp; +using System; +using System.IO; +using Avalonia.Media; +using System.Diagnostics; + +namespace FreshViewer.UI.LiquidGlass +{ + /// + /// 液态玻璃绘制操作 - 处理 Skia 渲染的自定义绘图操作 + /// + internal class LiquidGlassDrawOperation : ICustomDrawOperation + { + private readonly Rect _bounds; + private readonly LiquidGlassParameters _parameters; + + private static SKRuntimeEffect? _liquidGlassEffect; + private static bool _isShaderLoaded; + + public LiquidGlassDrawOperation(Rect bounds, LiquidGlassParameters parameters) + { + _bounds = bounds; + _parameters = parameters; + } + + public void Dispose() + { + } + + public bool HitTest(Point p) => _bounds.Contains(p); + + public Rect Bounds => _bounds; + + public bool Equals(ICustomDrawOperation? other) => false; + + public void Render(ImmediateDrawingContext context) + { + var leaseFeature = context.TryGetFeature(); + if (leaseFeature is null) return; + + // 确保位移贴图已加载 + DisplacementMapManager.LoadDisplacementMaps(); + + // 确保着色器只被加载一次 + LoadShader(); + + using var lease = leaseFeature.Lease(); + var canvas = lease.SkCanvas; + + if (_liquidGlassEffect is null) + { + DrawErrorHint(canvas); + } + else + { + DrawLiquidGlassEffect(canvas, lease); + } + } + + /// + /// 加载液态玻璃着色器 + /// + private void LoadShader() + { + if (_isShaderLoaded) return; + _isShaderLoaded = true; + + try + { + // 加载SKSL着色器代码 + var assetUri = new Uri("avares://FreshViewer/LiquidGlass/Assets/Shaders/LiquidGlassShader.sksl"); + using var stream = AssetLoader.Open(assetUri); + using var reader = new StreamReader(stream); + var shaderCode = reader.ReadToEnd(); + + _liquidGlassEffect = SKRuntimeEffect.Create(shaderCode, out var errorText); + if (_liquidGlassEffect == null) + { + DebugLog($"[SKIA ERROR] Failed to create liquid glass effect: {errorText}"); + } + } + catch (Exception ex) + { + DebugLog($"[AVALONIA ERROR] Exception while loading liquid glass shader: {ex.Message}"); + } + } + + /// + /// 绘制错误提示 + /// + private void DrawErrorHint(SKCanvas canvas) + { + using var errorPaint = new SKPaint + { + Color = new SKColor(255, 0, 0, 120), // 半透明红色 + Style = SKPaintStyle.Fill + }; + canvas.DrawRect(SKRect.Create(0, 0, (float)_bounds.Width, (float)_bounds.Height), errorPaint); + + using var textPaint = new SKPaint + { + Color = SKColors.White, + TextSize = 14, + IsAntialias = true, + TextAlign = SKTextAlign.Center + }; + canvas.DrawText("Liquid Glass Shader Failed to Load!", + (float)_bounds.Width / 2, (float)_bounds.Height / 2, textPaint); + } + + /// + /// 绘制液态玻璃效果 + /// + private void DrawLiquidGlassEffect(SKCanvas canvas, ISkiaSharpApiLease lease) + { + if (_liquidGlassEffect is null) return; + + // 获取背景快照 + using var backgroundSnapshot = lease.SkSurface?.Snapshot(); + if (backgroundSnapshot is null) return; + + if (!canvas.TotalMatrix.TryInvert(out var currentInvertedTransform)) + return; + + // 创建背景着色器 + using var backdropShader = SKShader.CreateImage( + backgroundSnapshot, + SKShaderTileMode.Clamp, + SKShaderTileMode.Clamp, + currentInvertedTransform); + + // 获取位移贴图 + var displacementMap = DisplacementMapManager.GetDisplacementMap( + _parameters.Mode, + (int)_bounds.Width, + (int)_bounds.Height); + + SKShader? displacementShader = null; + if (displacementMap != null && !displacementMap.IsEmpty && !displacementMap.IsNull) + { + try + { + // 额外验证位图数据完整性 + var info = displacementMap.Info; + if (info.Width > 0 && info.Height > 0 && info.BytesSize > 0) + { + displacementShader = SKShader.CreateBitmap( + displacementMap, + SKShaderTileMode.Clamp, + SKShaderTileMode.Clamp); + } + else + { + DebugLog("[LiquidGlassDrawOperation] Displacement bitmap has invalid dimensions or size"); + } + } + catch (Exception ex) + { + DebugLog($"[LiquidGlassDrawOperation] Error creating displacement shader: {ex.Message}"); + displacementShader = null; + } + } + else + { + DebugLog($"[LiquidGlassDrawOperation] No valid displacement map for mode: {_parameters.Mode}"); + } + + // 设置 Uniform 变量 + var uniforms = new SKRuntimeEffectUniforms(_liquidGlassEffect); + + // 基础参数 - 修复参数范围和计算 + uniforms["resolution"] = new[] { (float)_bounds.Width, (float)_bounds.Height }; + + var displacementValue = (float)(_parameters.DisplacementScale * GetModeScale()); + uniforms["displacementScale"] = displacementValue; + DebugLog( + $"[LiquidGlassDrawOperation] DisplacementScale: {_parameters.DisplacementScale} -> {displacementValue}"); + + uniforms["blurAmount"] = (float)_parameters.BlurAmount; // 直接传递,不要额外缩放 + + // 修复饱和度计算 - TypeScript版本使用0-2范围,1为正常 + uniforms["saturation"] = (float)(_parameters.Saturation / 100.0); // 140/100 = 1.4 + + uniforms["aberrationIntensity"] = (float)_parameters.AberrationIntensity; + uniforms["cornerRadius"] = (float)_parameters.CornerRadius; + + // 鼠标交互参数 - 修复坐标传递 + uniforms["mouseOffset"] = new[] + { (float)(_parameters.MouseOffsetX / 100.0), (float)(_parameters.MouseOffsetY / 100.0) }; + uniforms["globalMouse"] = new[] { (float)_parameters.GlobalMouseX, (float)_parameters.GlobalMouseY }; + + // 状态参数 + uniforms["isHovered"] = _parameters.IsHovered ? 1.0f : 0.0f; + uniforms["isActive"] = _parameters.IsActive ? 1.0f : 0.0f; + uniforms["overLight"] = _parameters.OverLight ? 1.0f : 0.0f; + + // 修复边缘遮罩参数计算 + var edgeMaskOffset = (float)Math.Max(0.1, (100.0 - _parameters.AberrationIntensity * 10.0) / 100.0); + uniforms["edgeMaskOffset"] = edgeMaskOffset; + + // 修复色差偏移计算 - 根据TypeScript版本调整 + var baseScale = _parameters.Mode == LiquidGlassMode.Shader ? 1.0f : 1.0f; + var redScale = baseScale; + var greenScale = baseScale - (float)_parameters.AberrationIntensity * 0.002f; + var blueScale = baseScale - (float)_parameters.AberrationIntensity * 0.004f; + + uniforms["chromaticAberrationScales"] = new[] { redScale, greenScale, blueScale }; + + // 设置纹理 + var children = new SKRuntimeEffectChildren(_liquidGlassEffect); + children["backgroundTexture"] = backdropShader; + + if (displacementShader != null) + { + children["displacementTexture"] = displacementShader; + uniforms["hasDisplacementMap"] = 1.0f; + } + else + { + uniforms["hasDisplacementMap"] = 0.0f; + } + + // 创建最终着色器 + try + { + using var finalShader = _liquidGlassEffect.ToShader(false, uniforms, children); + if (finalShader == null) + { + DebugLog("[LiquidGlassDrawOperation] Failed to create final shader"); + return; + } + + using var paint = new SKPaint { Shader = finalShader, IsAntialias = true }; + + // 应用背景模糊效果 - 修复模糊计算 + if (_parameters.BlurAmount > 0.001) // 只有真正需要模糊时才应用 + { + // 使用更线性和可控的模糊半径计算 + var blurRadius = (float)(_parameters.BlurAmount * 20.0); // 调整缩放因子 + + // 根据OverLight状态增加基础模糊(可选) + if (_parameters.OverLight) + { + blurRadius += 2.0f; // 在亮色背景上增加轻微的基础模糊 + } + + // 确保模糊半径在合理范围内 + blurRadius = Math.Max(0.1f, Math.Min(blurRadius, 50.0f)); + + using var blurFilter = SKImageFilter.CreateBlur(blurRadius, blurRadius); + paint.ImageFilter = blurFilter; + } + + // 绘制带圆角的效果 - 关键修复:只在圆角矩形内绘制 + var cornerRadius = + (float)Math.Min(_parameters.CornerRadius, Math.Min(_bounds.Width, _bounds.Height) / 2); + var rect = SKRect.Create(0, 0, (float)_bounds.Width, (float)_bounds.Height); + + // 创建圆角矩形路径 + using var path = new SKPath(); + path.AddRoundRect(rect, cornerRadius, cornerRadius); + + // 裁剪到圆角矩形并绘制 + canvas.Save(); + canvas.ClipPath(path, SKClipOperation.Intersect, true); + canvas.DrawRect(rect, paint); + canvas.Restore(); + } + catch (Exception ex) + { + DebugLog($"[LiquidGlassDrawOperation] Error creating or using shader: {ex.Message}"); + } + finally + { + if (children is IDisposable disposableChildren) + { + disposableChildren.Dispose(); + } + + if (uniforms is IDisposable disposableUniforms) + { + disposableUniforms.Dispose(); + } + } + + // 绘制边框效果 + DrawBorderEffects(canvas); + + // 绘制悬停和激活状态效果 + if (_parameters.IsHovered || _parameters.IsActive) + { + DrawInteractionEffects(canvas); + } + + // 清理位移着色器 + displacementShader?.Dispose(); + } + + /// + /// 获取模式缩放系数 - 修复版本 + /// + private double GetModeScale() + { + // 所有模式都使用正向缩放,通过Shader内部逻辑区分 + return _parameters.Mode switch + { + LiquidGlassMode.Standard => 1.0, + LiquidGlassMode.Polar => 1.2, // Polar模式稍微增强效果 + LiquidGlassMode.Prominent => 1.5, // Prominent模式显著增强效果 + LiquidGlassMode.Shader => 1.0, + _ => 1.0 + }; + } + + /// + /// 绘制边框效果 (对应TS版本的多层边框) + /// + private void DrawBorderEffects(SKCanvas canvas) + { + var cornerRadius = (float)Math.Min(_parameters.CornerRadius, Math.Min(_bounds.Width, _bounds.Height) / 2); + var rect = SKRect.Create(1.5f, 1.5f, (float)_bounds.Width - 3f, (float)_bounds.Height - 3f); + + // 第一层边框 (Screen blend mode) + var angle = 135f + (float)_parameters.MouseOffsetX * 1.2f; + var startOpacity = 0.12f + Math.Abs((float)_parameters.MouseOffsetX) * 0.008f; + var midOpacity = 0.4f + Math.Abs((float)_parameters.MouseOffsetX) * 0.012f; + var startPos = Math.Max(10f, 33f + (float)_parameters.MouseOffsetY * 0.3f) / 100f; + var endPos = Math.Min(90f, 66f + (float)_parameters.MouseOffsetY * 0.4f) / 100f; + + using var borderPaint1 = new SKPaint + { + Style = SKPaintStyle.Stroke, + StrokeWidth = 1f, + IsAntialias = true, + BlendMode = SKBlendMode.Screen + }; + + var colors = new SKColor[] + { + new SKColor(255, 255, 255, 0), + new SKColor(255, 255, 255, (byte)(startOpacity * 255)), + new SKColor(255, 255, 255, (byte)(midOpacity * 255)), + new SKColor(255, 255, 255, 0) + }; + + var positions = new float[] { 0f, startPos, endPos, 1f }; + + using var gradient1 = SKShader.CreateLinearGradient( + new SKPoint(0, 0), + new SKPoint((float)_bounds.Width, (float)_bounds.Height), + colors, + positions, + SKShaderTileMode.Clamp); + + borderPaint1.Shader = gradient1; + canvas.DrawRoundRect(rect, cornerRadius, cornerRadius, borderPaint1); + + // 第二层边框 (Overlay blend mode) + using var borderPaint2 = new SKPaint + { + Style = SKPaintStyle.Stroke, + StrokeWidth = 1f, + IsAntialias = true, + BlendMode = SKBlendMode.Overlay + }; + + var colors2 = new SKColor[] + { + new SKColor(255, 255, 255, 0), + new SKColor(255, 255, 255, (byte)((startOpacity + 0.2f) * 255)), + new SKColor(255, 255, 255, (byte)((midOpacity + 0.2f) * 255)), + new SKColor(255, 255, 255, 0) + }; + + using var gradient2 = SKShader.CreateLinearGradient( + new SKPoint(0, 0), + new SKPoint((float)_bounds.Width, (float)_bounds.Height), + colors2, + positions, + SKShaderTileMode.Clamp); + + borderPaint2.Shader = gradient2; + canvas.DrawRoundRect(rect, cornerRadius, cornerRadius, borderPaint2); + } + + /// + /// 绘制交互效果 (悬停和激活状态) + /// + private void DrawInteractionEffects(SKCanvas canvas) + { + var cornerRadius = (float)Math.Min(_parameters.CornerRadius, Math.Min(_bounds.Width, _bounds.Height) / 2); + var rect = SKRect.Create(0, 0, (float)_bounds.Width, (float)_bounds.Height); + + if (_parameters.IsHovered) + { + // 悬停效果 + using var hoverPaint = new SKPaint + { + Style = SKPaintStyle.Fill, + IsAntialias = true, + BlendMode = SKBlendMode.Overlay + }; + + using var hoverGradient = SKShader.CreateRadialGradient( + new SKPoint((float)_bounds.Width / 2, 0), + (float)_bounds.Width / 2, + new SKColor[] + { + new SKColor(255, 255, 255, 127), // 50% opacity + new SKColor(255, 255, 255, 0) + }, + new float[] { 0f, 0.5f }, + SKShaderTileMode.Clamp); + + hoverPaint.Shader = hoverGradient; + canvas.DrawRoundRect(rect, cornerRadius, cornerRadius, hoverPaint); + } + + if (_parameters.IsActive) + { + // 激活效果 + using var activePaint = new SKPaint + { + Style = SKPaintStyle.Fill, + IsAntialias = true, + BlendMode = SKBlendMode.Overlay + }; + + using var activeGradient = SKShader.CreateRadialGradient( + new SKPoint((float)_bounds.Width / 2, 0), + (float)_bounds.Width, + new SKColor[] + { + new SKColor(255, 255, 255, 204), // 80% opacity + new SKColor(255, 255, 255, 0) + }, + new float[] { 0f, 1f }, + SKShaderTileMode.Clamp); + + activePaint.Shader = activeGradient; + canvas.DrawRoundRect(rect, cornerRadius, cornerRadius, activePaint); + } + } + + [Conditional("DEBUG")] + private static void DebugLog(string message) + => Console.WriteLine(message); + } +} diff --git a/FreshViewer/LiquidGlass/LiquidGlassPlatform.cs b/FreshViewer/LiquidGlass/LiquidGlassPlatform.cs new file mode 100644 index 0000000..5a494bb --- /dev/null +++ b/FreshViewer/LiquidGlass/LiquidGlassPlatform.cs @@ -0,0 +1,39 @@ +using System; + +namespace FreshViewer.UI.LiquidGlass +{ + /// + /// Определяет, доступен ли полный набор эффектов Liquid Glass. + /// Теперь FreshViewer поддерживает только Windows, но оставляем переменную + /// окружения для принудительного включения/отключения эффекта: + /// FRESHVIEWER_FORCE_LIQUID_GLASS = "0"/"false" или "1"/"true". + /// + internal static class LiquidGlassPlatform + { + public static bool SupportsAdvancedEffects { get; } = EvaluateSupport(); + + private static bool EvaluateSupport() + { + var env = Environment.GetEnvironmentVariable("FRESHVIEWER_FORCE_LIQUID_GLASS"); + if (!string.IsNullOrWhiteSpace(env)) + { + if (env == "1" || env.Equals("true", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (env == "0" || env.Equals("false", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + + if (!OperatingSystem.IsWindows()) + { + return false; + } + + return true; + } + } +} diff --git a/FreshViewer/LiquidGlass/ShaderDebugger.cs b/FreshViewer/LiquidGlass/ShaderDebugger.cs new file mode 100644 index 0000000..51d4723 --- /dev/null +++ b/FreshViewer/LiquidGlass/ShaderDebugger.cs @@ -0,0 +1,125 @@ +using Avalonia; +using Avalonia.Platform; +using SkiaSharp; +using System; +using System.IO; + +namespace FreshViewer.UI.LiquidGlass +{ + /// + /// Shader 调试工具 - 检查 Shader 是否正确加载和参数是否正确传递 + /// + public static class ShaderDebugger + { + public static void TestShaderLoading() + { + Console.WriteLine("[ShaderDebugger] 开始测试 Shader 加载..."); + + try + { + // 尝试加载 Shader 文件 + var assetUri = new Uri("avares://FreshViewer/LiquidGlass/Assets/Shaders/LiquidGlassShader.sksl"); + using var stream = AssetLoader.Open(assetUri); + using var reader = new StreamReader(stream); + var shaderCode = reader.ReadToEnd(); + + Console.WriteLine($"[ShaderDebugger] Shader 文件大小: {shaderCode.Length} 字符"); + Console.WriteLine( + $"[ShaderDebugger] Shader 前100字符: {shaderCode.Substring(0, Math.Min(100, shaderCode.Length))}"); + + // 尝试编译 Shader + var effect = SKRuntimeEffect.Create(shaderCode, out var errorText); + if (effect != null) + { + Console.WriteLine("[ShaderDebugger] ✅ Shader 编译成功!"); + + // 检查 Uniform 变量 + var uniformSize = effect.UniformSize; + Console.WriteLine($"[ShaderDebugger] Uniform 大小: {uniformSize} 字节"); + + Console.WriteLine($"[ShaderDebugger] 尝试创建 Uniforms 对象..."); + var uniforms = new SKRuntimeEffectUniforms(effect); + Console.WriteLine($"[ShaderDebugger] ✅ Uniforms 对象创建成功"); + + Console.WriteLine($"[ShaderDebugger] 尝试创建 Children 对象..."); + var children = new SKRuntimeEffectChildren(effect); + Console.WriteLine($"[ShaderDebugger] ✅ Children 对象创建成功"); + + if (uniforms is IDisposable disposableUniforms) + { + disposableUniforms.Dispose(); + } + + if (children is IDisposable disposableChildren) + { + disposableChildren.Dispose(); + } + + effect.Dispose(); + } + else + { + Console.WriteLine($"[ShaderDebugger] ❌ Shader 编译失败: {errorText}"); + } + } + catch (Exception ex) + { + Console.WriteLine($"[ShaderDebugger] ❌ 异常: {ex.Message}"); + Console.WriteLine($"[ShaderDebugger] 堆栈跟踪: {ex.StackTrace}"); + } + } + + public static void TestDisplacementMaps() + { + Console.WriteLine("[ShaderDebugger] 开始测试位移贴图加载..."); + + DisplacementMapManager.LoadDisplacementMaps(); + + var modes = new[] + { LiquidGlassMode.Standard, LiquidGlassMode.Polar, LiquidGlassMode.Prominent, LiquidGlassMode.Shader }; + + foreach (var mode in modes) + { + var map = DisplacementMapManager.GetDisplacementMap(mode, 256, 256); + if (map != null) + { + Console.WriteLine($"[ShaderDebugger] ✅ {mode} 位移贴图加载成功 ({map.Width}x{map.Height})"); + } + else + { + Console.WriteLine($"[ShaderDebugger] ❌ {mode} 位移贴图加载失败"); + } + } + } + + public static void TestParameters() + { + Console.WriteLine("[ShaderDebugger] 开始测试参数传递..."); + + var parameters = new LiquidGlassParameters + { + DisplacementScale = 70.0, + BlurAmount = 0.0625, + Saturation = 140.0, + AberrationIntensity = 2.0, + Elasticity = 0.15, + CornerRadius = 25.0, + Mode = LiquidGlassMode.Standard, + IsHovered = false, + IsActive = false, + OverLight = false, + MouseOffsetX = 0.0, + MouseOffsetY = 0.0, + GlobalMouseX = 0.0, + GlobalMouseY = 0.0, + ActivationZone = 200.0 + }; + + Console.WriteLine($"[ShaderDebugger] DisplacementScale: {parameters.DisplacementScale}"); + Console.WriteLine($"[ShaderDebugger] BlurAmount: {parameters.BlurAmount}"); + Console.WriteLine($"[ShaderDebugger] Saturation: {parameters.Saturation}"); + Console.WriteLine($"[ShaderDebugger] AberrationIntensity: {parameters.AberrationIntensity}"); + Console.WriteLine($"[ShaderDebugger] Mode: {parameters.Mode}"); + } + } +} diff --git a/FreshViewer/Models/ImageMetadata.cs b/FreshViewer/Models/ImageMetadata.cs new file mode 100644 index 0000000..143bf5f --- /dev/null +++ b/FreshViewer/Models/ImageMetadata.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; + +namespace FreshViewer.Models; + +public sealed class ImageMetadata +{ + public ImageMetadata(IReadOnlyList sections, IReadOnlyDictionary? raw = null) + { + Sections = sections; + Raw = raw; + } + + public IReadOnlyList Sections { get; } + + public IReadOnlyDictionary? Raw { get; } +} + +public sealed class MetadataSection +{ + public MetadataSection(string title, IReadOnlyList fields) + { + Title = title; + Fields = fields; + } + + public string Title { get; } + + public IReadOnlyList Fields { get; } +} + +public sealed class MetadataField +{ + public MetadataField(string label, string? value) + { + Label = label; + Value = value; + } + + public string Label { get; } + + public string? Value { get; } +} diff --git a/FreshViewer/Program.cs b/FreshViewer/Program.cs new file mode 100644 index 0000000..9b20803 --- /dev/null +++ b/FreshViewer/Program.cs @@ -0,0 +1,25 @@ +using System; +using Avalonia; + +namespace 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); + } + + 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..d741820 --- /dev/null +++ b/FreshViewer/Services/ImageLoader.cs @@ -0,0 +1,280 @@ +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; + +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" + }; + + 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); + } +} + +public sealed class LoadedImage : IDisposable +{ + public LoadedImage(string path, Bitmap? bitmap, AnimatedImage? animated, ImageMetadata? metadata) + { + Path = path; + Bitmap = bitmap; + Animation = animated; + Metadata = metadata; + } + + public string Path { get; } + + public Bitmap? Bitmap { get; } + + public AnimatedImage? Animation { get; } + + public ImageMetadata? Metadata { get; } + + public bool IsAnimated => Animation is not null; + + public PixelSize PixelSize + => Animation?.PixelSize ?? Bitmap?.PixelSize ?? PixelSize.Empty; + + public void Dispose() + { + Bitmap?.Dispose(); + Animation?.Dispose(); + } +} + +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; + } + + public IReadOnlyList Frames { get; } + + public int LoopCount { get; } + + public PixelSize PixelSize { get; } + + public void Dispose() + { + foreach (var frame in Frames) + { + frame.Dispose(); + } + } +} + +public sealed class AnimatedFrame : IDisposable +{ + public AnimatedFrame(Bitmap bitmap, MemoryStream backingStream, TimeSpan duration) + { + Bitmap = bitmap; + BackingStream = backingStream; + Duration = duration; + } + + public Bitmap Bitmap { get; } + + private MemoryStream BackingStream { get; } + + public TimeSpan Duration { get; } + + 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..7ba0748 --- /dev/null +++ b/FreshViewer/Services/LocalizationService.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace FreshViewer.Services; + +/// +/// Простейшая локализация на основе CultureInfo.CurrentUICulture. +/// Сейчас хранит только выбранную культуру; ключи в XAML пока статические. +/// Расширяется до полноценного IStringLocalizer при необходимости. +/// +public static class LocalizationService +{ + private static readonly Dictionary LanguageToCulture = new() + { + ["Русский"] = "ru-RU", + ["English"] = "en-US", + ["Українська"] = "uk-UA", + ["Deutsch"] = "de-DE" + }; + + 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; + } + + // Удаляем предыдущие словари строк + 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..1279623 --- /dev/null +++ b/FreshViewer/Services/ShortcutManager.cs @@ -0,0 +1,246 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Avalonia.Input; + +namespace FreshViewer.Services; + +/// +/// Управляет профилями и ремаппингом горячих клавиш. Позволяет импорт/экспорт JSON. +/// +public sealed class ShortcutManager +{ + private readonly Dictionary> _actionToCombos = new(); + + public ShortcutManager() + { + ResetToProfile("Стандартный"); + } + + public IReadOnlyList Profiles { get; } = new[] { "Стандартный", "Photoshop", "Lightroom" }; + + public void ResetToProfile(string profileName) + { + _actionToCombos.Clear(); + + switch (profileName) + { + case "Photoshop": + ApplyStandardBase(); + // Дополнительно: повороты как в Lightroom/PS: Ctrl+[ и 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() + { + // Навигация + Set(ShortcutAction.Previous, new KeyCombo(Key.Left), new KeyCombo(Key.A)); + Set(ShortcutAction.Next, new KeyCombo(Key.Right), new KeyCombo(Key.D)); + + // Просмотр + 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)); + + // Окно/интерфейс + 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)); + + // Файл/буфер + 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(); + } + + 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; + } + + 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)); + } + + 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; + } + } + } +} + +public enum ShortcutAction +{ + Previous, + Next, + Fit, + ZoomIn, + ZoomOut, + RotateClockwise, + RotateCounterClockwise, + Fullscreen, + ToggleUi, + ToggleInfo, + ToggleSettings, + OpenFile, + CopyFrame +} + +public readonly struct KeyCombo +{ + public KeyCombo(Key key, KeyModifiers modifiers = KeyModifiers.None) + { + Key = key; + Modifiers = modifiers; + } + + public Key Key { get; } + public KeyModifiers Modifiers { get; } + + public bool Matches(KeyEventArgs e) + { + if (e.Key != Key) + { + return false; + } + + // Нормализуем модификаторы (NumLock/Scroll не влияют) + var mods = e.KeyModifiers & (KeyModifiers.Control | KeyModifiers.Shift | KeyModifiers.Alt | KeyModifiers.Meta); + return mods == Modifiers; + } +} + +internal static class KeyComboFormat +{ + 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); + } + + 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..f52eca4 --- /dev/null +++ b/FreshViewer/Services/ThemeManager.cs @@ -0,0 +1,48 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace FreshViewer.Services; + +/// +/// Применение предустановленных тем LiquidGlass на лету. +/// +public static class ThemeManager +{ + 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; + } + + // Удаляем предыдущие LiquidGlass словари + 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..17d0e68 --- /dev/null +++ b/FreshViewer/ViewModels/ImageMetadataViewModels.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using System.Linq; +using FreshViewer.Models; + +namespace FreshViewer.ViewModels; + +public sealed record MetadataItemViewModel(string Label, string? Value) +{ + public string DisplayValue => string.IsNullOrWhiteSpace(Value) ? "—" : Value; +} + +public sealed record MetadataSectionViewModel(string Title, IReadOnlyList Items) +{ + public static MetadataSectionViewModel FromModel(MetadataSection section) + => new(section.Title, section.Fields.Select(f => new MetadataItemViewModel(f.Label, f.Value)).ToList()); +} + +public static class MetadataViewModelFactory +{ + 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..0e0accf --- /dev/null +++ b/FreshViewer/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,299 @@ +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Runtime.CompilerServices; +using FreshViewer.Models; + +namespace FreshViewer.ViewModels; + +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; + + public string? FileName + { + get => _fileName; + set => SetField(ref _fileName, value); + } + + public string? ResolutionText + { + get => _resolutionText; + set => SetField(ref _resolutionText, value); + } + + public string StatusText + { + get => _statusText; + set => SetField(ref _statusText, value); + } + + public bool IsMetadataVisible + { + get => _isMetadataVisible; + set + { + if (SetField(ref _isMetadataVisible, value)) + { + UpdateMetadataCardVisibility(); + } + } + } + + public bool IsUiVisible + { + get => _isUiVisible; + set + { + if (SetField(ref _isUiVisible, value)) + { + UpdateMetadataCardVisibility(); + + if (!value) + { + if (IsInfoPanelVisible) + { + IsInfoPanelVisible = false; + } + + if (IsSettingsPanelVisible) + { + IsSettingsPanelVisible = false; + } + } + } + } + } + + public bool IsMetadataCardVisible + { + get => _isMetadataCardVisible; + private set => SetField(ref _isMetadataCardVisible, value); + } + + public bool IsInfoPanelVisible + { + get => _isInfoPanelVisible; + set + { + if (SetField(ref _isInfoPanelVisible, value) && value) + { + if (IsSettingsPanelVisible) + { + IsSettingsPanelVisible = false; + } + } + } + } + + public bool IsSettingsPanelVisible + { + get => _isSettingsPanelVisible; + set + { + if (SetField(ref _isSettingsPanelVisible, value) && value) + { + if (IsInfoPanelVisible) + { + IsInfoPanelVisible = false; + } + } + } + } + + public IReadOnlyList? MetadataSections + { + get => _metadataSections; + set => SetField(ref _metadataSections, value); + } + + public IReadOnlyList? SummaryItems + { + get => _summaryItems; + private set + { + if (SetField(ref _summaryItems, value)) + { + HasSummaryItems = value is { Count: > 0 }; + } + } + } + + public bool HasSummaryItems + { + get => _hasSummaryItems; + private set => SetField(ref _hasSummaryItems, value); + } + + public bool HasMetadataDetails + { + get => _hasMetadataDetails; + set => SetField(ref _hasMetadataDetails, value); + } + + public bool ShowMetadataPlaceholder + { + get => _showMetadataPlaceholder; + set => SetField(ref _showMetadataPlaceholder, value); + } + + public string? GalleryPositionText + { + get => _galleryPositionText; + set => SetField(ref _galleryPositionText, value); + } + + public bool IsErrorVisible + { + get => _isErrorVisible; + set => SetField(ref _isErrorVisible, value); + } + + public string? ErrorTitle + { + get => _errorTitle; + set => SetField(ref _errorTitle, value); + } + + public string? ErrorDescription + { + get => _errorDescription; + set => SetField(ref _errorDescription, value); + } + + public IReadOnlyList ThemeOptions { get; } = new[] + { + "Liquid Dawn", + "Midnight Flow", + "Frosted Steel" + }; + + public IReadOnlyList LanguageOptions { get; } = new[] + { + "Русский", + "English", + "Українська", + "Deutsch" + }; + + public IReadOnlyList ShortcutProfiles { get; } = new[] + { + "Стандартный", + "Photoshop", + "Lightroom" + }; + + public string SelectedTheme + { + get => _selectedTheme; + set => SetField(ref _selectedTheme, value); + } + + public string SelectedLanguage + { + get => _selectedLanguage; + set => SetField(ref _selectedLanguage, value); + } + + public string SelectedShortcutProfile + { + get => _selectedShortcutProfile; + set => SetField(ref _selectedShortcutProfile, value); + } + + public bool EnableLiquidGlass + { + get => _enableLiquidGlass; + set => SetField(ref _enableLiquidGlass, value); + } + + public bool EnableAmbientAnimations + { + get => _enableAmbientAnimations; + set => SetField(ref _enableAmbientAnimations, value); + } + + 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(); + } + + public void ResetMetadata() + { + FileName = null; + ResolutionText = null; + MetadataSections = null; + SummaryItems = null; + HasMetadataDetails = false; + ShowMetadataPlaceholder = true; + IsMetadataVisible = false; + GalleryPositionText = null; + UpdateMetadataCardVisibility(); + } + + public void ShowError(string title, string description) + { + ErrorTitle = title; + ErrorDescription = description; + IsErrorVisible = true; + } + + 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..5dd8088 --- /dev/null +++ b/FreshViewer/Views/MainWindow.axaml @@ -0,0 +1,493 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/FreshViewer/Views/MainWindow.axaml.cs b/FreshViewer/Views/MainWindow.axaml.cs new file mode 100644 index 0000000..f53e82e --- /dev/null +++ b/FreshViewer/Views/MainWindow.axaml.cs @@ -0,0 +1,1189 @@ +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; + +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); + + // Применяем стартовые тему и язык + LocalizationService.ApplyLanguage(_viewModel.SelectedLanguage); + ThemeManager.Apply(_viewModel.SelectedTheme); + + _viewModel.StatusText = "Откройте изображение или перетащите файл"; + } + + private void ConfigureWindowChrome() + { + TransparencyLevelHint = new[] + { + WindowTransparencyLevel.Mica, + WindowTransparencyLevel.AcrylicBlur, + WindowTransparencyLevel.Blur, + WindowTransparencyLevel.Transparent + }; + + Background = Brushes.Transparent; + ExtendClientAreaToDecorationsHint = true; + ExtendClientAreaChromeHints = Avalonia.Platform.ExtendClientAreaChromeHints.PreferSystemChrome; + ExtendClientAreaTitleBarHeightHint = 32; + } + + 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 = "Шорткаты сброшены к профилю"; + } + + // методы-ссылки не нужны, применяем сразу через PropertyChanged +} 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/LICENSE b/LICENSE index c2cbfb6..0e27856 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,21 @@ -MIT License - -Copyright (c) 2025 Артемий (Amti_Yo) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +MIT License + +Copyright (c) 2025 Артемий (Amti_Yo) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile deleted file mode 100644 index 7cfb949..0000000 --- a/Makefile +++ /dev/null @@ -1,45 +0,0 @@ -.PHONY: help install run test clean build dist - -help: ## Show this help message - @echo "Available commands:" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' - -install: ## Install dependencies - pip install -e . - -install-dev: ## Install development dependencies - pip install -e . - pip install pytest black flake8 mypy - -run: ## Run the application - python BlurViewer.py - -test: ## Run tests - pytest - -lint: ## Run linting - black BlurViewer.py - flake8 BlurViewer.py - mypy BlurViewer.py - -format: ## Format code - black BlurViewer.py - -clean: ## Clean build artifacts - rm -rf build/ - rm -rf dist/ - rm -rf *.egg-info/ - find . -type d -name __pycache__ -delete - find . -type f -name "*.pyc" -delete - -build: ## Build the package - python -m build - -dist: clean ## Create distribution - python -m build - -install-package: ## Install the package in development mode - pip install -e . - -uninstall: ## Uninstall the package - pip uninstall blurviewer -y diff --git a/README.de.md b/README.de.md deleted file mode 100644 index ae05528..0000000 --- a/README.de.md +++ /dev/null @@ -1,210 +0,0 @@ -

📸 BlurViewer

-
- -[English](./README.md) | [Русский](./README.ru.md) | Deutsch - -**BlurViewer** - Professioneller Bildbetrachter mit erweiteter Formatunterstützung und flüssigen Animationen. -Blitzschnelle, minimalistische und funktionsreiche Foto-Betrachtungserfahrung für Windows. - -![Release Download](https://img.shields.io/github/downloads/amtiYo/BlurViewer/total?style=flat-square) -[![Release Version](https://img.shields.io/github/v/release/amtiYo/BlurViewer?style=flat-square)](https://github.com/amtiYo/BlurViewer/releases/latest) -[![GitHub license](https://img.shields.io/github/license/amtiYo/BlurViewer?style=flat-square)](LICENSE) -[![GitHub Star](https://img.shields.io/github/stars/amtiYo/BlurViewer?style=flat-square)](https://github.com/amtiYo/BlurViewer/stargazers) -[![GitHub Fork](https://img.shields.io/github/forks/amtiYo/BlurViewer?style=flat-square)](https://github.com/amtiYo/BlurViewer/network/members) -![GitHub Repo size](https://img.shields.io/github/repo-size/amtiYo/BlurViewer?style=flat-square&color=3cb371) - -Ein Bildbetrachter der nächsten Generation mit **universeller Formatunterstützung**, **immersivem Vollbildmodus** und **professioneller Leistung**. Entwickelt für Fotografen, Designer und anspruchsvolle Benutzer. -
- -## ✨ Hauptfunktionen - -### 🖥️ **Immersive Vollbild-Erfahrung** -- **F11 Echter Vollbildmodus** mit adaptiver Hintergrundverdunkelung -- **Intelligente Zoom-Steuerung** - frei hineinzoomen, begrenztes Herauszoomen für perfekte Bildausschnitte -- **Flüssige Ein-/Ausgangsanimationen** mit Zustandserhaltung -- **Verbesserte Navigation** - nur ESC/Rechtsklick für sicheres Betrachten - -### ⌨️ **Intuitive Bedienung** -- **A/D Navigation** - Mühelos durch Bilder blättern (unterstützt kyrillisches А/В) -- **+/- Zentrum-Zoom** - Präzise Zoom-Kontrolle zum Bildzentrum -- **Sofortige Drehung** - R-Taste mit Auto-Anpassung in allen Modi -- **Intelligentes Schwenken** - Bilder ziehen mit Trägheitsscrollen - -### 🎨 **Erweiterte Animations-System** -- **Kontextabhängige Animationen** - flüssig im Fenstermodus, sofortig im Vollbildmodus -- **Übergangs-Animationen** mit kubischer Glättung zwischen Bildern -- **Professionelle Öffnungs-/Schließeffekte** mit Überblendungsanimationen - -### 📁 **Universelle Formatunterstützung** -- **RAW-Formate**: CR2, CR3, NEF, ARW, DNG, RAF, ORF, RW2, PEF und 20+ weitere -- **Moderne Formate**: HEIC/HEIF, AVIF, WebP, JXL -- **Animierte**: GIF, WebP-Animationen (Anzeige des ersten Frames) -- **Wissenschaftlich**: FITS, HDR, EXR und spezialisierte Formate -- **Standard**: PNG, JPEG, BMP, TIFF, SVG, ICO und mehr - -### ⚡ **Leistungsoptimierungen** -- **15% schnellere** Darstellung dank optimiertem Code -- **Hardware-beschleunigte** flüssige Zoom- und Schwenkfunktionen -- **Hintergrundladen** für sofortiges Bildwechseln -- **Speichereffizient** mit intelligentem Caching - -## 🎮 Vollständige Steuerungsreferenz - -### Grundlegende Steuerung -| Aktion | Tasten | Beschreibung | -|--------|--------|--------------| -| **Vollbild umschalten** | `F11` | Immersiven Vollbildmodus ein-/ausschalten | -| **Bilder navigieren** | `A` / `D` | Vorheriges/Nächstes Bild im Ordner | -| **Hinein-/Herauszoomen** | `+` / `-` | Zentrum-fokussierte Zoom-Steuerung | -| **An Bildschirm anpassen** | `F` / `Leertaste` | Intelligente Anpassung mit Animationen | -| **Bild drehen** | `R` | 90°-Drehung mit Auto-Anpassung | -| **Bild kopieren** | `Strg+C` | In Zwischenablage kopieren | -| **Anwendung beenden** | `Esc` / `Rechtsklick` | Betrachter schließen | - -### Maussteuerung -- **Scrollrad**: Hinein-/Herauszoomen an Cursorposition -- **Linksklick + Ziehen**: Bild schwenken -- **Doppelklick**: Zwischen Bildschirmanpassung und 100%-Skalierung wechseln -- **Drag & Drop**: Neue Bilder sofort öffnen - -## 🚀 Schnellstart - -### Download & Ausführung -1. **Laden** Sie die neueste `BlurViewer.exe` aus den [Releases](https://github.com/amtiYo/BlurViewer/releases/latest) herunter -2. **Keine Installation erforderlich** - führen Sie einfach die ausführbare Datei aus -3. **Ziehen Sie Bilder** in das Fenster oder verwenden Sie Datei → Öffnen - -### Als Standard-Betrachter festlegen -- **Windows 11**: Einstellungen → Apps → Standard-Apps → BlurViewer für Bilddateien wählen -- **Windows 10**: Einstellungen → System → Standard-Apps → Foto-Betrachter → BlurViewer -- **Schnelle Methode**: Rechtsklick auf Bild → Öffnen mit → Andere App auswählen → BlurViewer wählen → Immer diese App verwenden - -### Profi-Tipps -- **Ordner durchsuchen**: Öffnen Sie ein beliebiges Bild, verwenden Sie A/D-Tasten um den ganzen Ordner zu durchsuchen -- **Schneller Vollbildmodus**: F11 für ablenkungsfreie Betrachtung mit perfekter Bildanpassung -- **Schnelle Navigation**: Verwenden Sie +/- für präzisen Zoom, F/Leertaste zum Zurücksetzen -- **Sofortiges Kopieren**: Strg+C um das aktuelle Bild in die Zwischenablage zu kopieren - -## 🖼️ Unterstützte Formate - -
-📷 RAW-Kameraformate (20+) - -- **Canon**: CR2, CR3 -- **Nikon**: NEF -- **Sony**: ARW -- **Adobe**: DNG -- **Fujifilm**: RAF -- **Olympus**: ORF -- **Panasonic**: RW2 -- **Pentax**: PEF, PTX -- **Samsung**: SRW -- **Sigma**: X3F -- **Minolta**: MRW -- **Kodak**: DCR, KDC -- **Epson**: ERF -- **Mamiya**: MEF -- **Leaf**: MOS -- **Phase One**: IIQ -- **Red**: R3D -- **Hasselblad**: 3FR, FFF -
- -
-🆕 Moderne & Next-Gen-Formate - -- **HEIC/HEIF**: Apple Photos-Format mit vollständigen Metadaten -- **AVIF**: Next-Generation-Format mit überlegener Kompression -- **WebP**: Googles effizientes Web-Format (statisch & animiert) -- **JXL**: JPEG XL für zukunftssichere Archivierung -
- -
-🎬 Animierte & Standard-Formate - -- **Animiert**: GIF, WebP (Anzeige des ersten Frames) -- **Standard**: PNG, JPEG/JPG, BMP, TIFF/TIF -- **Vektor**: SVG (gerasterte Anzeige) -- **Legacy**: ICO, XBM, XPM, PBM, PGM, PPM -
- -
-🔬 Wissenschaftliche & Professionelle Formate - -- **Astronomie**: FITS-Dateien -- **HDR**: HDR, EXR (hoher Dynamikbereich) -- **Design**: PSD (Photoshop-Ebenen, teilweise Unterstützung) -- **Medizin**: DICOM (grundlegende Unterstützung) -
- -## 🔧 Erweiterte Funktionen - -### Intelligenter Vollbildmodus -- **Adaptive Anpassung**: Bilder passen sich automatisch an Bildschirmdimensionen mit Drehungsberücksichtigung an -- **Verbesserter Hintergrund**: 25% dunklerer Hintergrund für bessere Fokussierung -- **Eingeschränkter Ausgang**: Nur ESC/Rechtsklick verhindert versehentliches Schließen -- **Zustandserhaltung**: Kehrt zu exaktem Zoom/Position beim Beenden zurück - -### Intelligente Navigation -- **Auto-Erkennung**: Findet automatisch alle Bilder im selben Ordner -- **Nahtloses Durchsuchen**: A/D-Tasten funktionieren bei jeder Zoom-Stufe -- **Intelligente Zentrierung**: Neue Bilder werden automatisch zentriert unter Beibehaltung des Zooms -- **Drehungsreset**: Jedes neue Bild startet bei 0°-Drehung - -### Leistungs-Engineering -- **Hintergrund-Threading**: Bilder laden ohne UI-Blockierung -- **Intelligentes Caching**: Effiziente Speichernutzung für große Dateien -- **Hardware-Beschleunigung**: GPU-unterstützte Darstellung wo verfügbar -- **Optimierte Animationen**: 60 FPS flüssige Interpolation - -## 🤝 Mitwirken - -Wir begrüßen Beiträge! So fangen Sie an: - -1. **Forken** Sie das Repository -2. **Klonen** Sie Ihren Fork: `git clone https://github.com/yourusername/BlurViewer.git` -3. **Erstellen** Sie einen Feature-Branch: `git checkout -b feature/amazing-feature` -4. **Machen** Sie Ihre Änderungen und testen Sie gründlich -5. **Committen** Sie mit klaren Nachrichten: `git commit -m 'Add amazing feature'` -6. **Pushen** Sie zu Ihrem Branch: `git push origin feature/amazing-feature` -7. **Erstellen** Sie einen Pull Request mit detaillierter Beschreibung - -### Entwicklungsumgebung einrichten -```bash -# Repository klonen und im Entwicklungsmodus installieren -git clone https://github.com/amtiYo/BlurViewer.git -cd BlurViewer -pip install -e . - -# Anwendung ausführen -python BlurViewer.py -``` - -## 📝 Lizenz - -Dieses Projekt ist unter der **MIT-Lizenz** lizenziert - siehe [LICENSE](LICENSE)-Datei für Details. - -## 🔗 Erstellt mit - -- **[PySide6](https://doc.qt.io/qtforpython/)** - Moderne Qt-Bindungen für Python -- **[Pillow](https://pillow.readthedocs.io/)** - Python Imaging Library -- **[rawpy](https://github.com/letmaik/rawpy)** - RAW-Bildverarbeitung -- **[ImageIO](https://imageio.github.io/)** - Wissenschaftliche Bild-E/A -- **[OpenCV](https://opencv.org/)** - Computer Vision Bibliothek - -## 🙏 Danksagungen - -- **libraw**-Team für RAW-Verarbeitungsfähigkeiten -- **Qt Project** für das exzellente GUI-Framework -- **Python-Community** für das großartige Ökosystem -- **Mitwirkende**, die helfen, BlurViewer besser zu machen - ---- - -
- -**⭐ Bewerten Sie dieses Repository mit einem Stern, wenn BlurViewer Ihr Foto-Betrachtungserlebnis verbessert!** - -Mit ❤️ für Fotografen, Designer und visuelle Enthusiasten weltweit gemacht. - -
diff --git a/README.md b/README.md index 58d05ee..8e2fe5a 100644 --- a/README.md +++ b/README.md @@ -1,210 +1,73 @@ -

📸 BlurViewer

-
- -English | [Русский](./README.ru.md) | [Deutsch](./README.de.md) - -**BlurViewer** - Professional image viewer with advanced format support and smooth animations. -Lightning-fast, minimalist, and feature-rich photo viewing experience for Windows. - -![Release Download](https://img.shields.io/github/downloads/amtiYo/BlurViewer/total?style=flat-square) -[![Release Version](https://img.shields.io/github/v/release/amtiYo/BlurViewer?style=flat-square)](https://github.com/amtiYo/BlurViewer/releases/latest) -[![GitHub license](https://img.shields.io/github/license/amtiYo/BlurViewer?style=flat-square)](LICENSE) -[![GitHub Star](https://img.shields.io/github/stars/amtiYo/BlurViewer?style=flat-square)](https://github.com/amtiYo/BlurViewer/stargazers) -[![GitHub Fork](https://img.shields.io/github/forks/amtiYo/BlurViewer?style=flat-square)](https://github.com/amtiYo/BlurViewer/network/members) -![GitHub Repo size](https://img.shields.io/github/repo-size/amtiYo/BlurViewer?style=flat-square&color=3cb371) - -A next-generation image viewer with **universal format support**, **immersive fullscreen mode**, and **professional-grade performance**. Built for photographers, designers, and power users. -
- -## ✨ Key Features - -### 🖥️ **Immersive Fullscreen Experience** -- **F11 True Fullscreen** with adaptive background darkening -- **Smart zoom controls** - zoom in freely, limited zoom out for perfect framing -- **Smooth entry/exit animations** with state preservation -- **Enhanced navigation** - ESC/right-click only for safer viewing - -### ⌨️ **Intuitive Controls** -- **A/D Navigation** - Browse images effortlessly (supports Cyrillic А/В) -- **+/- Center Zoom** - Precise zoom control toward image center -- **Instant Rotation** - R key with auto-fitting in all modes -- **Smart Panning** - Drag images with inertial scrolling - -### 🎨 **Advanced Animation System** -- **Context-aware animations** - smooth in windowed, instant in fullscreen -- **Slide transitions** with cubic easing between images -- **Professional opening/closing** effects with fade animations - -### 📁 **Universal Format Support** -- **RAW Formats**: CR2, CR3, NEF, ARW, DNG, RAF, ORF, RW2, PEF, and 20+ more -- **Modern Formats**: HEIC/HEIF, AVIF, WebP, JXL -- **Animated**: GIF, WebP animations (first frame display) -- **Scientific**: FITS, HDR, EXR, and specialized formats -- **Standard**: PNG, JPEG, BMP, TIFF, SVG, ICO, and more - -### ⚡ **Performance Optimizations** -- **15% faster** rendering with optimized codebase -- **Hardware-accelerated** smooth zoom and pan -- **Background loading** for instant image switching -- **Memory efficient** with smart caching - -## 🎮 Complete Control Reference - -### Essential Controls -| Action | Keys | Description | -|--------|------|-------------| -| **Fullscreen Toggle** | `F11` | Enter/exit immersive fullscreen mode | -| **Navigate Images** | `A` / `D` | Previous/Next image in folder | -| **Zoom In/Out** | `+` / `-` | Center-focused zoom control | -| **Fit to Screen** | `F` / `Space` | Smart fit with animations | -| **Rotate Image** | `R` | 90° rotation with auto-fitting | -| **Copy Image** | `Ctrl+C` | Copy to system clipboard | -| **Exit Application** | `Esc` / `Right Click` | Close viewer | - -### Mouse Controls -- **Scroll Wheel**: Zoom in/out at cursor position -- **Left Click + Drag**: Pan image around -- **Double Click**: Toggle between fit-to-screen and 100% scale -- **Drag & Drop**: Open new images instantly - -## 🚀 Getting Started - -### Download & Run -1. **Download** the latest `BlurViewer.exe` from [Releases](https://github.com/amtiYo/BlurViewer/releases/latest) -2. **No installation required** - just run the executable -3. **Drag images** into the window or use File → Open - -### Set as Default Viewer -- **Windows 11**: Settings → Apps → Default apps → Choose BlurViewer for image files -- **Windows 10**: Settings → System → Default apps → Photo viewer → BlurViewer -- **Quick method**: Right-click any image → Open with → Choose BlurViewer → Always use this app - -### Pro Tips -- **Folder browsing**: Open any image, use A/D keys to browse the entire folder -- **Quick fullscreen**: F11 for distraction-free viewing with perfect image fitting -- **Fast navigation**: Use +/- for precise zoom, F/Space to reset fit -- **Instant copy**: Ctrl+C to copy current image to clipboard - -## 🖼️ Supported Formats - -
-📷 RAW Camera Formats (20+) - -- **Canon**: CR2, CR3 -- **Nikon**: NEF -- **Sony**: ARW -- **Adobe**: DNG -- **Fujifilm**: RAF -- **Olympus**: ORF -- **Panasonic**: RW2 -- **Pentax**: PEF, PTX -- **Samsung**: SRW -- **Sigma**: X3F -- **Minolta**: MRW -- **Kodak**: DCR, KDC -- **Epson**: ERF -- **Mamiya**: MEF -- **Leaf**: MOS -- **Phase One**: IIQ -- **Red**: R3D -- **Hasselblad**: 3FR, FFF -
- -
-🆕 Modern & Next-Gen Formats - -- **HEIC/HEIF**: Apple Photos format with full metadata -- **AVIF**: Next-generation format with superior compression -- **WebP**: Google's efficient web format (static & animated) -- **JXL**: JPEG XL for future-proof archiving -
- -
-🎬 Animated & Standard Formats - -- **Animated**: GIF, WebP (first frame display) -- **Standard**: PNG, JPEG/JPG, BMP, TIFF/TIF -- **Vector**: SVG (rasterized display) -- **Legacy**: ICO, XBM, XPM, PBM, PGM, PPM -
- -
-🔬 Scientific & Professional - -- **Astronomy**: FITS files -- **HDR**: HDR, EXR (high dynamic range) -- **Design**: PSD (Photoshop layers, partial support) -- **Medical**: DICOM (basic support) -
- -## 🔧 Advanced Features - -### Smart Fullscreen Mode -- **Adaptive fitting**: Images auto-fit to screen dimensions with rotation awareness -- **Enhanced background**: 25% darker backdrop for better focus -- **Restricted exit**: Only ESC/right-click prevents accidental closure -- **State preservation**: Returns to exact zoom/position when exiting - -### Intelligent Navigation -- **Auto-discovery**: Automatically finds all images in the same folder -- **Seamless browsing**: A/D keys work in any zoom level -- **Smart centering**: New images center automatically while preserving zoom -- **Rotation reset**: Each new image starts at 0° rotation - -### Performance Engineering -- **Background threading**: Images load without UI blocking -- **Smart caching**: Efficient memory usage for large files -- **Hardware acceleration**: GPU-assisted rendering where available -- **Optimized animations**: 60 FPS smooth interpolation - -## 🤝 Contributing - -We welcome contributions! Here's how to get started: - -1. **Fork** the repository -2. **Clone** your fork: `git clone https://github.com/yourusername/BlurViewer.git` -3. **Create** a feature branch: `git checkout -b feature/amazing-feature` -4. **Make** your changes and test thoroughly -5. **Commit** with clear messages: `git commit -m 'Add amazing feature'` -6. **Push** to your branch: `git push origin feature/amazing-feature` -7. **Create** a Pull Request with detailed description - -### Development Setup +

FreshViewer

+ +

+ License + Latest release + Conventional Commits +

+ +A modern, distraction‑free image viewer for Windows built with .NET 8 and Avalonia. FreshViewer features a crisp Liquid Glass interface, smooth navigation, rich format support, and a handy info overlay — all optimized for everyday use. + +## Highlights +- Liquid Glass UI: translucent cards, soft shadows, and subtle motion for a premium feel +- Smooth navigation: kinetic panning, focus‑aware zoom, rotate, and fit‑to‑view +- Info at a glance: compact summary card + detailed metadata panel (EXIF/XMP) +- Powerful formats: stills, animations, modern codecs, and DSLR RAW families +- Personalization: themes, language (ru/en/uk/de), and keyboard‑shortcut profiles + +## Liquid Glass design +FreshViewer embraces a lightweight “Liquid Glass” aesthetic: +- Top app bar with rounded glass buttons (Back, Next, Fit, Rotate, Open, Info, Settings, Fullscreen) +- Left summary card (file name, resolution, position in folder) +- Slide‑in information panel (I) with fluid enter/exit animation +- Compact status pill at the bottom with action hints + +The result is a calm, legible interface that stays out of the way while keeping essential controls at your fingertips. + +## Supported formats +- Common: PNG, JPEG, BMP, TIFF, ICO, SVG +- Modern: WEBP, HEIC/HEIF, AVIF, JXL +- Pro: PSD, HDR, EXR +- DSLR RAW: CR2/CR3, NEF, ARW, DNG, RAF, ORF, RW2, PEF, SRW, MRW, X3F, DCR, KDC, ERF, MEF, MOS, PTX, R3D, FFF, IIQ +- Animation: GIF/APNG (with loop handling) + +## Keyboard & mouse (default) +- Navigate: A/← and D/→ +- Fit to view: Space / F +- Rotate: R / L +- Zoom: mouse wheel, + / − +- Info panel: I +- Settings: P +- Fullscreen: F11 +- Copy current frame: Ctrl+C + +## Requirements +- Windows 10 1809 or newer (x64) +- .NET 8 SDK + +## Build & run ```bash -# Clone and install in development mode -git clone https://github.com/amtiYo/BlurViewer.git -cd BlurViewer -pip install -e . - -# Run the application -python BlurViewer.py +dotnet restore FreshViewer.sln +dotnet build FreshViewer.sln -c Release +dotnet run --project FreshViewer/FreshViewer.csproj -- ``` -## 📝 License - -This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) file for details. - -## 🔗 Built With - -- **[PySide6](https://doc.qt.io/qtforpython/)** - Modern Qt bindings for Python -- **[Pillow](https://pillow.readthedocs.io/)** - Python Imaging Library -- **[rawpy](https://github.com/letmaik/rawpy)** - RAW image processing -- **[ImageIO](https://imageio.github.io/)** - Scientific image I/O -- **[OpenCV](https://opencv.org/)** - Computer vision library - -## 🙏 Acknowledgments - -- **libraw** team for RAW processing capabilities -- **Qt Project** for the excellent GUI framework -- **Python community** for the amazing ecosystem -- **Contributors** who help make BlurViewer better - ---- +Publish (Windows x64, single file): +```bash +dotnet publish FreshViewer/FreshViewer.csproj -c Release -r win-x64 \ + -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true --self-contained=false +``` -
+## Settings +- Themes: switch between pre‑tuned Liquid Glass palettes +- Language: ru / en / uk / de +- Shortcuts: select a profile (Standard, Photoshop, Lightroom) or export/import your own mapping (JSON) -**⭐ Star this repository if BlurViewer enhances your photo viewing experience!** +## Contributing +Contributions are welcome. Please see [CONTRIBUTING.md](./CONTRIBUTING.md) for a short guide. -Made with ❤️ for photographers, designers, and visual enthusiasts worldwide. +## License +MIT — see [LICENSE](./LICENSE). -
+## Credits +Avalonia, SkiaSharp, ImageSharp, Magick.NET, and MetadataExtractor. diff --git a/README.ru.md b/README.ru.md deleted file mode 100644 index acc177b..0000000 --- a/README.ru.md +++ /dev/null @@ -1,210 +0,0 @@ -

📸 BlurViewer

-
- -[English](./README.md) | Русский | [Deutsch](./README.de.md) - -**BlurViewer** - Профессиональный просмотрщик изображений с расширенной поддержкой форматов и плавными анимациями. -Молниеносно быстрый, минималистичный и многофункциональный просмотрщик фото для Windows. - -![Release Download](https://img.shields.io/github/downloads/amtiYo/BlurViewer/total?style=flat-square) -[![Release Version](https://img.shields.io/github/v/release/amtiYo/BlurViewer?style=flat-square)](https://github.com/amtiYo/BlurViewer/releases/latest) -[![GitHub license](https://img.shields.io/github/license/amtiYo/BlurViewer?style=flat-square)](LICENSE) -[![GitHub Star](https://img.shields.io/github/stars/amtiYo/BlurViewer?style=flat-square)](https://github.com/amtiYo/BlurViewer/stargazers) -[![GitHub Fork](https://img.shields.io/github/forks/amtiYo/BlurViewer?style=flat-square)](https://github.com/amtiYo/BlurViewer/network/members) -![GitHub Repo size](https://img.shields.io/github/repo-size/amtiYo/BlurViewer?style=flat-square&color=3cb371) - -Просмотрщик изображений нового поколения с **универсальной поддержкой форматов**, **погружающим полноэкранным режимом** и **профессиональной производительностью**. Создан для фотографов, дизайнеров и требовательных пользователей. -
- -## ✨ Ключевые возможности - -### 🖥️ **Погружающий полноэкранный режим** -- **F11 Настоящий полноэкранный режим** с адаптивным затемнением фона -- **Умное управление зумом** - свободное приближение, ограниченное отдаление для идеального кадрирования -- **Плавные анимации входа/выхода** с сохранением состояния -- **Улучшенная навигация** - только ESC/правый клик для безопасного просмотра - -### ⌨️ **Интуитивное управление** -- **Навигация A/Д** - Просматривайте изображения без усилий (поддержка кириллицы А/В) -- **Зум +/-** - Точное управление зумом к центру изображения -- **Мгновенный поворот** - Клавиша R с авто-подгонкой во всех режимах -- **Умное перетаскивание** - Перетаскивание изображений с инерционной прокруткой - -### 🎨 **Продвинутая система анимации** -- **Контекстно-зависимые анимации** - плавные в оконном режиме, мгновенные в полноэкранном -- **Слайд-переходы** с кубическим сглаживанием между изображениями -- **Профессиональные эффекты** открытия/закрытия с плавным появлением - -### 📁 **Универсальная поддержка форматов** -- **RAW форматы**: CR2, CR3, NEF, ARW, DNG, RAF, ORF, RW2, PEF и 20+ других -- **Современные форматы**: HEIC/HEIF, AVIF, WebP, JXL -- **Анимированные**: GIF, WebP анимации (отображение первого кадра) -- **Научные**: FITS, HDR, EXR и специализированные форматы -- **Стандартные**: PNG, JPEG, BMP, TIFF, SVG, ICO и другие - -### ⚡ **Оптимизация производительности** -- **На 15% быстрее** отрисовка благодаря оптимизированному коду -- **Аппаратное ускорение** плавного зума и панорамирования -- **Фоновая загрузка** для мгновенного переключения изображений -- **Эффективное использование памяти** с умным кэшированием - -## 🎮 Полный справочник управления - -### Основные клавиши -| Действие | Клавиши | Описание | -|----------|---------|----------| -| **Переключить полноэкранный режим** | `F11` | Вход/выход из погружающего полноэкранного режима | -| **Навигация по изображениям** | `A` / `Д` | Предыдущее/Следующее изображение в папке | -| **Приближение/Отдаление** | `+` / `-` | Зум с фокусом на центр изображения | -| **Подогнать под экран** | `F` / `Пробел` | Умная подгонка с анимациями | -| **Повернуть изображение** | `R` | Поворот на 90° с авто-подгонкой | -| **Копировать изображение** | `Ctrl+C` | Копировать в буфер обмена | -| **Выйти из приложения** | `Esc` / `Правый клик` | Закрыть просмотрщик | - -### Управление мышью -- **Колесо прокрутки**: Приближение/отдаление в позиции курсора -- **Левый клик + перетаскивание**: Панорамирование изображения -- **Двойной клик**: Переключение между подгонкой под экран и масштабом 100% -- **Перетаскивание файлов**: Мгновенное открытие новых изображений - -## 🚀 Быстрый старт - -### Скачать и запустить -1. **Скачайте** последний `BlurViewer.exe` из раздела [Релизы](https://github.com/amtiYo/BlurViewer/releases/latest) -2. **Установка не требуется** - просто запустите исполняемый файл -3. **Перетащите изображения** в окно или используйте Файл → Открыть - -### Установить как просмотрщик по умолчанию -- **Windows 11**: Параметры → Приложения → Приложения по умолчанию → Выберите BlurViewer для файлов изображений -- **Windows 10**: Параметры → Система → Приложения по умолчанию → Просмотр фотографий → BlurViewer -- **Быстрый способ**: ПКМ по изображению → Открыть с помощью → Выбрать другое приложение → выберите BlurViewer → Всегда использовать это приложение - -### Профессиональные советы -- **Просмотр папки**: Откройте любое изображение, используйте клавиши A/Д для просмотра всей папки -- **Быстрый полноэкранный режим**: F11 для просмотра без отвлечений с идеальной подгонкой изображения -- **Быстрая навигация**: Используйте +/- для точного зума, F/Пробел для сброса подгонки -- **Мгновенное копирование**: Ctrl+C для копирования текущего изображения в буфер обмена - -## 🖼️ Поддерживаемые форматы - -
-📷 RAW форматы камер (20+) - -- **Canon**: CR2, CR3 -- **Nikon**: NEF -- **Sony**: ARW -- **Adobe**: DNG -- **Fujifilm**: RAF -- **Olympus**: ORF -- **Panasonic**: RW2 -- **Pentax**: PEF, PTX -- **Samsung**: SRW -- **Sigma**: X3F -- **Minolta**: MRW -- **Kodak**: DCR, KDC -- **Epson**: ERF -- **Mamiya**: MEF -- **Leaf**: MOS -- **Phase One**: IIQ -- **Red**: R3D -- **Hasselblad**: 3FR, FFF -
- -
-🆕 Современные форматы нового поколения - -- **HEIC/HEIF**: Формат Apple Photos с полными метаданными -- **AVIF**: Формат нового поколения с превосходным сжатием -- **WebP**: Эффективный веб-формат Google (статичный и анимированный) -- **JXL**: JPEG XL для перспективного архивирования -
- -
-🎬 Анимированные и стандартные форматы - -- **Анимированные**: GIF, WebP (отображение первого кадра) -- **Стандартные**: PNG, JPEG/JPG, BMP, TIFF/TIF -- **Векторные**: SVG (растеризованное отображение) -- **Устаревшие**: ICO, XBM, XPM, PBM, PGM, PPM -
- -
-🔬 Научные и профессиональные форматы - -- **Астрономия**: FITS файлы -- **HDR**: HDR, EXR (высокий динамический диапазон) -- **Дизайн**: PSD (слои Photoshop, частичная поддержка) -- **Медицина**: DICOM (базовая поддержка) -
- -## 🔧 Расширенные возможности - -### Умный полноэкранный режим -- **Адаптивная подгонка**: Изображения автоматически подгоняются под размеры экрана с учетом поворота -- **Улучшенный фон**: На 25% темнее фон для лучшей фокусировки -- **Ограниченный выход**: Только ESC/правый клик предотвращает случайное закрытие -- **Сохранение состояния**: Возвращается к точному зуму/позиции при выходе - -### Интеллектуальная навигация -- **Автообнаружение**: Автоматически находит все изображения в той же папке -- **Беспрепятственный просмотр**: Клавиши A/Д работают при любом уровне зума -- **Умное центрирование**: Новые изображения центрируются автоматически с сохранением зума -- **Сброс поворота**: Каждое новое изображение начинается с поворота 0° - -### Техническая оптимизация -- **Фоновые потоки**: Изображения загружаются без блокировки интерфейса -- **Умное кэширование**: Эффективное использование памяти для больших файлов -- **Аппаратное ускорение**: GPU-ускоренная отрисовка где доступно -- **Оптимизированные анимации**: 60 FPS плавная интерполяция - -## 🤝 Участие в разработке - -Мы приветствуем вклад в развитие! Вот как начать: - -1. **Создайте форк** репозитория -2. **Клонируйте** ваш форк: `git clone https://github.com/yourusername/BlurViewer.git` -3. **Создайте** ветку для функции: `git checkout -b feature/amazing-feature` -4. **Внесите** изменения и тщательно протестируйте -5. **Сделайте коммит** с понятными сообщениями: `git commit -m 'Add amazing feature'` -6. **Отправьте** в вашу ветку: `git push origin feature/amazing-feature` -7. **Создайте** Pull Request с подробным описанием - -### Настройка среды разработки -```bash -# Клонировать и установить в режиме разработки -git clone https://github.com/amtiYo/BlurViewer.git -cd BlurViewer -pip install -e . - -# Запустить приложение -python BlurViewer.py -``` - -## 📝 Лицензия - -Этот проект лицензирован под **лицензией MIT** - см. файл [LICENSE](LICENSE) для подробностей. - -## 🔗 Создано с использованием - -- **[PySide6](https://doc.qt.io/qtforpython/)** - Современные привязки Qt для Python -- **[Pillow](https://pillow.readthedocs.io/)** - Библиотека обработки изображений Python -- **[rawpy](https://github.com/letmaik/rawpy)** - Обработка RAW изображений -- **[ImageIO](https://imageio.github.io/)** - Научный ввод/вывод изображений -- **[OpenCV](https://opencv.org/)** - Библиотека компьютерного зрения - -## 🙏 Благодарности - -- Команде **libraw** за возможности обработки RAW -- **Qt Project** за отличный GUI фреймворк -- **Python сообществу** за потрясающую экосистему -- **Участникам**, которые помогают сделать BlurViewer лучше - ---- - -
- -**⭐ Поставьте звезду этому репозиторию, если BlurViewer улучшил ваш опыт просмотра фотографий!** - -Сделано с ❤️ для фотографов, дизайнеров и визуальных энтузиастов по всему миру. - -
diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index f433b77..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,162 +0,0 @@ -[build-system] -requires = ["setuptools>=61.0", "wheel"] -build-backend = "setuptools.build_meta" - -[project] -name = "blurviewer" -version = "0.8.1" -description = "Professional image viewer with advanced format support and smooth animations" -readme = "README.md" -license = {text = "MIT"} -authors = [ - {name = "amtiYo", email = "artemijtkacenko@gmail.com"} -] -maintainers = [ - {name = "amtiYo", email = "artemijtkacenko@gmail.com"} -] -keywords = [ - "image", "viewer", "photo", "gallery", "raw", "heic", "avif", - "desktop", "gui", "qt", "pyside6", "photography", "image-processing" -] -classifiers = [ - "Development Status :: 4 - Beta", - "Intended Audience :: End Users/Desktop", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Topic :: Multimedia :: Graphics :: Viewers", - "Topic :: Desktop Environment", - "Topic :: Scientific/Engineering :: Image Processing", - "Topic :: Software Development :: User Interfaces", - "Topic :: Multimedia :: Graphics :: Graphics Conversion", -] -requires-python = ">=3.8" -dependencies = [ - "PySide6>=6.5.0", - "Pillow>=10.0.0", - "pillow-heif>=0.13.0", - "pillow-avif-plugin>=1.3.0", - "rawpy>=0.17.0", - "imageio>=2.31.0", - "opencv-python>=4.8.0", -] - -[project.optional-dependencies] -dev = [ - "pytest>=7.0.0", - "black>=23.0.0", - "flake8>=6.0.0", - "mypy>=1.0.0", - "pre-commit>=3.0.0", - "pytest-qt>=4.0.0", -] -build = [ - "build>=0.10.0", - "twine>=4.0.0", - "pyinstaller>=5.0.0", -] - -[project.urls] -Homepage = "https://github.com/amtiYo/BlurViewer" -Repository = "https://github.com/amtiYo/BlurViewer" -Issues = "https://github.com/amtiYo/BlurViewer/issues" -Documentation = "https://github.com/amtiYo/BlurViewer#readme" -"Bug Tracker" = "https://github.com/amtiYo/BlurViewer/issues" -"Source Code" = "https://github.com/amtiYo/BlurViewer" -"Download" = "https://github.com/amtiYo/BlurViewer/releases" -"Release Notes" = "https://github.com/amtiYo/BlurViewer/releases" - -[project.scripts] -blurviewer = "BlurViewer:main" - -[project.gui-scripts] -blurviewer-gui = "BlurViewer:main" - -[tool.setuptools] -packages = ["."] -include-package-data = true - -[tool.setuptools.package-data] -"*" = ["*.ico", "*.png", "*.gif"] - -[tool.setuptools.exclude-package-data] -"*" = ["*.pyc", "__pycache__", "*.spec", "build", "dist", ".venv", "test_*"] - -# Development tools configuration -[tool.black] -line-length = 88 -target-version = ['py38'] -include = '\.pyi?$' -extend-exclude = ''' -/( - # directories - \.eggs - | \.git - | \.hg - | \.mypy_cache - | \.tox - | \.venv - | build - | dist -)/ -''' - -[tool.flake8] -max-line-length = 88 -extend-ignore = ["E203", "W503", "E501"] -exclude = [ - ".git", - "__pycache__", - "build", - "dist", - ".venv", - "*.egg-info", - "test_*", -] - -[tool.mypy] -python_version = "3.8" -warn_return_any = true -warn_unused_configs = true -disallow_untyped_defs = false # Allow untyped defs for Qt compatibility -disallow_incomplete_defs = false -check_untyped_defs = true -disallow_untyped_decorators = false -no_implicit_optional = true -warn_redundant_casts = true -warn_unused_ignores = true -warn_no_return = true -warn_unreachable = true -strict_equality = true -ignore_missing_imports = true - -# PyInstaller configuration -[tool.pyinstaller] -name = "BlurViewer" -onefile = true -windowed = true -icon = "BlurViewer.ico" -add-data = ["BlurViewer.ico;."] -hidden-import = [ - "PIL._tkinter_finder", - "PIL._imagingtk", - "pillow_heif", - "pillow_avif", - "PySide6.QtCore", - "PySide6.QtGui", - "PySide6.QtWidgets", -] -exclude = [ - "*.pyc", - "__pycache__", - "*.spec", - "build", - "dist", - ".venv", - "test_*" -] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 96c2ee8..0000000 --- a/requirements.txt +++ /dev/null @@ -1,14 +0,0 @@ -# Core dependencies for BlurViewer -PySide6>=6.5.0 -Pillow>=10.0.0 -pillow-heif>=0.13.0 -pillow-avif-plugin>=1.3.0 -rawpy>=0.17.0 -imageio>=2.31.0 -opencv-python>=4.8.0 - -# For development (optional) -# pytest>=7.0.0 -# black>=23.0.0 -# flake8>=6.0.0 -# mypy>=1.0.0 From 5a69727602d206882c95407009616076f4914b5f Mon Sep 17 00:00:00 2001 From: amtiyo <134780061+amtiYo@users.noreply.github.com> Date: Fri, 26 Sep 2025 11:42:42 +0300 Subject: [PATCH 02/10] Update README.md --- README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8e2fe5a..54a76bb 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,13 @@

FreshViewer

- License - Latest release - Conventional Commits + +![Release Download](https://img.shields.io/github/downloads/amtiYo/BlurViewer/total?style=flat-square) +[![Release Version](https://img.shields.io/github/v/release/amtiYo/BlurViewer?style=flat-square)](https://github.com/amtiYo/BlurViewer/releases/latest) +[![GitHub license](https://img.shields.io/github/license/amtiYo/BlurViewer?style=flat-square)](LICENSE) +[![GitHub Star](https://img.shields.io/github/stars/amtiYo/BlurViewer?style=flat-square)](https://github.com/amtiYo/BlurViewer/stargazers) +[![GitHub Fork](https://img.shields.io/github/forks/amtiYo/BlurViewer?style=flat-square)](https://github.com/amtiYo/BlurViewer/network/members) +![GitHub Repo size](https://img.shields.io/github/repo-size/amtiYo/BlurViewer?style=flat-square&color=3cb371)

A modern, distraction‑free image viewer for Windows built with .NET 8 and Avalonia. FreshViewer features a crisp Liquid Glass interface, smooth navigation, rich format support, and a handy info overlay — all optimized for everyday use. From bc589baa3125b69a23f3e3f5167ea4babe3db564 Mon Sep 17 00:00:00 2001 From: amtiyo <134780061+amtiYo@users.noreply.github.com> Date: Fri, 26 Sep 2025 11:43:23 +0300 Subject: [PATCH 03/10] Delete .github/workflows directory --- .github/workflows/build-windows.yml | 38 ----------------------------- 1 file changed, 38 deletions(-) delete mode 100644 .github/workflows/build-windows.yml diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml deleted file mode 100644 index 9c7e441..0000000 --- a/.github/workflows/build-windows.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Build (Windows) - -on: - push: - branches: [ liquid-glass ] - pull_request: - branches: [ liquid-glass ] - -jobs: - build: - runs-on: windows-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup .NET 8 - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '8.0.x' - - - name: Restore - run: dotnet restore FreshViewer.sln - - - name: Build - run: dotnet build FreshViewer.sln -c Release --no-restore - - - name: Publish (self-contained, single file) - run: | - dotnet publish FreshViewer/FreshViewer.csproj -c Release -r win-x64 --self-contained true \ - -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true \ - -o artifacts/win-x64 - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: FreshViewer-win-x64 - path: artifacts/win-x64 - From 20046d55895d3165b13e811ccf183c4d00bafe08 Mon Sep 17 00:00:00 2001 From: amtiYo Date: Fri, 26 Sep 2025 09:50:36 +0300 Subject: [PATCH 04/10] Hotfix: revert background to simple gradient; remove Mica/Acrylic/Blur; keep LiquidGlass and UI --- .../LiquidGlass/LiquidGlassDecorator.cs | 70 ++++++++----------- FreshViewer/Program.cs | 3 + FreshViewer/Views/MainWindow.axaml | 34 ++------- FreshViewer/Views/MainWindow.axaml.cs | 11 +-- 4 files changed, 41 insertions(+), 77 deletions(-) diff --git a/FreshViewer/LiquidGlass/LiquidGlassDecorator.cs b/FreshViewer/LiquidGlass/LiquidGlassDecorator.cs index 7ee4948..b0ec991 100644 --- a/FreshViewer/LiquidGlass/LiquidGlassDecorator.cs +++ b/FreshViewer/LiquidGlass/LiquidGlassDecorator.cs @@ -57,21 +57,21 @@ public override void Render(DrawingContext context) { _isRendering = true; - // 1. 首先,调用 base.Render(context) 让 Avalonia 绘制所有子控件。 - // 这样就完成了标准的渲染通道。 - base.Render(context); - - if (!LiquidGlassPlatform.SupportsAdvancedEffects) + // ВАЖНО: сначала рисуем эффект стекла по снимку уже отрисованного фона, + // затем поверх — дочерний контент. Иначе непрозрачный шейдер перекрывает UI. + if (LiquidGlassPlatform.SupportsAdvancedEffects) + { + // Поверх детей накладываем жидкостный шейдер. + context.Custom(new LiquidGlassDrawOperation(new Rect(0, 0, Bounds.Width, Bounds.Height), this)); + } + else { + // Если шейдеры недоступны — рисуем прозрачное стекло поверх. DrawFallbackOverlay(context); - return; } - // 2. 在子控件被绘制之后,插入我们的自定义模糊操作。 - // 这会在已渲染的子控件之上绘制我们的效果, - // 但效果本身采样的是原始背景,从而产生子控件在玻璃之上的错觉。 - // 这个机制打破了渲染循环。 - context.Custom(new LiquidGlassDrawOperation(new Rect(0, 0, Bounds.Width, Bounds.Height), this)); + // Теперь выводим дочерние элементы поверх стекла + base.Render(context); } finally { @@ -234,45 +234,31 @@ private void DrawLiquidGlassEffect(SKCanvas canvas, ISkiaSharpApiLease lease) { if (_effect is null) return; - // 1. 截取当前绘图表面的快照。这会捕获到目前为止绘制的所有内容。 - using var backgroundSnapshot = lease.SkSurface?.Snapshot(); - if (backgroundSnapshot is null) return; + var pixelWidth = (int)Math.Ceiling(_bounds.Width); + var pixelHeight = (int)Math.Ceiling(_bounds.Height); + if (pixelWidth <= 0 || pixelHeight <= 0) return; - // 2. 获取画布的反转变换矩阵。这对于将全屏快照正确映射到 - // 我们本地控件的坐标空间至关重要。 - if (!canvas.TotalMatrix.TryInvert(out var currentInvertedTransform)) - return; + using var snapshot = lease.SkSurface?.Snapshot(); + if (snapshot is null) return; - // 3. 从背景快照创建一个着色器,并应用反转变换。 - // 这个着色器现在将正确地采样我们控件正后方的像素。 - using var backdropShader = SKShader.CreateImage(backgroundSnapshot, SKShaderTileMode.Clamp, - SKShaderTileMode.Clamp, currentInvertedTransform); + if (!canvas.TotalMatrix.TryInvert(out var inverse)) + { + return; + } - // 4. 为我们的 SKSL 扭曲着色器准备 uniforms。 - var pixelSize = new PixelSize((int)_bounds.Width, (int)_bounds.Height); - var uniforms = new SKRuntimeEffectUniforms(_effect); + using var shaderImage = snapshot.ToShader(SKShaderTileMode.Clamp, SKShaderTileMode.Clamp, inverse); - // 更新为传递 "radius" 而不是 "blurRadius"。 - uniforms["radius"] = (float)_owner.Radius; - uniforms["resolution"] = new[] { (float)pixelSize.Width, (float)pixelSize.Height }; + var uniforms = new SKRuntimeEffectUniforms(_effect) + { + ["radius"] = (float)_owner.Radius, + ["resolution"] = new[] { (float)pixelWidth, (float)pixelHeight } + }; - // 5. 通过将我们的背景着色器作为 'content' 输入提供给 SKSL 效果,来创建最终的着色器。 - var children = new SKRuntimeEffectChildren(_effect) { { "content", backdropShader } }; + var children = new SKRuntimeEffectChildren(_effect) { { "content", shaderImage } }; using var finalShader = _effect.ToShader(false, uniforms, children); - // 6. 创建一个带有最终着色器的画笔并进行绘制。 using var paint = new SKPaint { Shader = finalShader }; - canvas.DrawRect(SKRect.Create(0, 0, (float)_bounds.Width, (float)_bounds.Height), paint); - - if (children is IDisposable disposableChildren) - { - disposableChildren.Dispose(); - } - - if (uniforms is IDisposable disposableUniforms) - { - disposableUniforms.Dispose(); - } + canvas.DrawRect(SKRect.Create(0, 0, pixelWidth, pixelHeight), paint); } } } diff --git a/FreshViewer/Program.cs b/FreshViewer/Program.cs index 9b20803..b881328 100644 --- a/FreshViewer/Program.cs +++ b/FreshViewer/Program.cs @@ -1,10 +1,13 @@ using System; +using System.IO; using Avalonia; namespace FreshViewer; internal static class Program { + private static readonly string StartupLogPath = Path.Combine(AppContext.BaseDirectory, "startup.log"); + [STAThread] public static void Main(string[] args) { diff --git a/FreshViewer/Views/MainWindow.axaml b/FreshViewer/Views/MainWindow.axaml index 5dd8088..55395b5 100644 --- a/FreshViewer/Views/MainWindow.axaml +++ b/FreshViewer/Views/MainWindow.axaml @@ -14,7 +14,7 @@ Height="900" MinWidth="900" MinHeight="640" - TransparencyLevelHint="Mica,AcrylicBlur,Blur,Transparent" + TransparencyLevelHint="Transparent" Background="Transparent" SystemDecorations="None" ExtendClientAreaToDecorationsHint="True" @@ -94,7 +94,7 @@ - + - - - - + @@ -211,11 +199,7 @@ Cursor="Hand" Click="OnOpenButtonClicked" ToolTip.Tip="Ctrl+O · Открыть"> - - - - + diff --git a/FreshViewer/Views/MainWindow.axaml.cs b/FreshViewer/Views/MainWindow.axaml.cs index f53e82e..91e6eb2 100644 --- a/FreshViewer/Views/MainWindow.axaml.cs +++ b/FreshViewer/Views/MainWindow.axaml.cs @@ -121,15 +121,10 @@ public MainWindow() private void ConfigureWindowChrome() { - TransparencyLevelHint = new[] - { - WindowTransparencyLevel.Mica, - WindowTransparencyLevel.AcrylicBlur, - WindowTransparencyLevel.Blur, - WindowTransparencyLevel.Transparent - }; + // Упрощённый фон: без Mica/Acrylic/Blur, только обычный непрозрачный градиент. + TransparencyLevelHint = new[] { WindowTransparencyLevel.Transparent }; - Background = Brushes.Transparent; + Background = Brushes.Transparent; // оставляем прозрачный бэкграунд окна, сам фон рисуем в XAML градиентом ExtendClientAreaToDecorationsHint = true; ExtendClientAreaChromeHints = Avalonia.Platform.ExtendClientAreaChromeHints.PreferSystemChrome; ExtendClientAreaTitleBarHeightHint = 32; From 7bcb86472650b9a1ac8d17d749901cdb728844cb Mon Sep 17 00:00:00 2001 From: amtiYo Date: Fri, 26 Sep 2025 09:54:00 +0300 Subject: [PATCH 05/10] docs(pr): add PR notes for background simplification hotfix --- FreshViewer/PR-HOTFIX-Background-Simplification.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 FreshViewer/PR-HOTFIX-Background-Simplification.md diff --git a/FreshViewer/PR-HOTFIX-Background-Simplification.md b/FreshViewer/PR-HOTFIX-Background-Simplification.md new file mode 100644 index 0000000..d16ae4b --- /dev/null +++ b/FreshViewer/PR-HOTFIX-Background-Simplification.md @@ -0,0 +1,4 @@ +Hotfix: Background simplification (no Mica/Acrylic/Blur). + +- Reverted background to simple gradient +- Kept LiquidGlass effects and UI From 6e5c0a88fd32270172e0defc69f2b517501094bb Mon Sep 17 00:00:00 2001 From: amtiyo <134780061+amtiYo@users.noreply.github.com> Date: Fri, 24 Oct 2025 18:02:11 +0300 Subject: [PATCH 06/10] docs: add changelog and describe Liquid Glass requirements --- CHANGELOG.md | 13 + FreshViewer.Tests/FreshViewer.Tests.csproj | 18 + FreshViewer.Tests/MainWindowViewModelTests.cs | 40 ++ FreshViewer.sln | 30 +- FreshViewer/App.axaml.cs | 10 +- FreshViewer/Controls/ImageViewport.cs | 96 ++++ .../Converters/BooleanToValueConverter.cs | 11 + .../Assets/Shaders/LiquidGlassShader.sksl | 80 ++++ .../LiquidGlass/LiquidGlassDecorator.cs | 143 ++++++ .../LiquidGlassFallbackRenderer.cs | 49 ++ .../LiquidGlass/LiquidGlassRenderOperation.cs | 106 ++++ .../LiquidGlass/LiquidGlassRenderer.cs | 110 +++++ .../Effects/LiquidGlass/LiquidGlassSupport.cs | 48 ++ FreshViewer/FreshViewer.csproj | 2 +- .../DisplacementMaps/polar_displacement.jpeg | Bin 4319 -> 0 bytes .../prominent_displacement.jpeg | Bin 21909 -> 0 bytes .../standard_displacement.jpeg | Bin 4451 -> 0 bytes .../Assets/LiquidGlassShaderOld.sksl | 57 --- .../Assets/Shaders/LiquidGlassShader.sksl | 163 ------- .../Shaders/LiquidGlassShader_Fixed.sksl | 181 ------- .../Assets/Shaders/LiquidGlassShader_New.sksl | 136 ------ .../LiquidGlass/DisplacementMapManager.cs | 298 ------------ .../LiquidGlass/DraggableLiquidGlassCard.cs | 326 ------------- FreshViewer/LiquidGlass/LiquidGlassButton.cs | 448 ----------------- FreshViewer/LiquidGlass/LiquidGlassCard.cs | 386 --------------- FreshViewer/LiquidGlass/LiquidGlassControl.cs | 411 ---------------- .../LiquidGlass/LiquidGlassControlOld.cs | 245 ---------- .../LiquidGlass/LiquidGlassDecorator.cs | 265 ---------- .../LiquidGlass/LiquidGlassDrawOperation.cs | 453 ------------------ .../LiquidGlass/LiquidGlassPlatform.cs | 39 -- FreshViewer/LiquidGlass/ShaderDebugger.cs | 125 ----- FreshViewer/Models/ImageMetadata.cs | 27 ++ FreshViewer/Program.cs | 12 +- FreshViewer/Services/ImageLoader.cs | 59 +++ FreshViewer/Services/LocalizationService.cs | 10 +- FreshViewer/Services/ShortcutManager.cs | 55 ++- FreshViewer/Services/ThemeManager.cs | 7 +- .../ViewModels/ImageMetadataViewModels.cs | 18 + FreshViewer/ViewModels/MainWindowViewModel.cs | 95 ++++ FreshViewer/Views/MainWindow.axaml | 34 +- FreshViewer/Views/MainWindow.axaml.cs | 14 +- README.md | 11 +- 42 files changed, 1053 insertions(+), 3578 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 FreshViewer.Tests/FreshViewer.Tests.csproj create mode 100644 FreshViewer.Tests/MainWindowViewModelTests.cs create mode 100644 FreshViewer/Effects/LiquidGlass/Assets/Shaders/LiquidGlassShader.sksl create mode 100644 FreshViewer/Effects/LiquidGlass/LiquidGlassDecorator.cs create mode 100644 FreshViewer/Effects/LiquidGlass/LiquidGlassFallbackRenderer.cs create mode 100644 FreshViewer/Effects/LiquidGlass/LiquidGlassRenderOperation.cs create mode 100644 FreshViewer/Effects/LiquidGlass/LiquidGlassRenderer.cs create mode 100644 FreshViewer/Effects/LiquidGlass/LiquidGlassSupport.cs delete mode 100644 FreshViewer/LiquidGlass/Assets/DisplacementMaps/polar_displacement.jpeg delete mode 100644 FreshViewer/LiquidGlass/Assets/DisplacementMaps/prominent_displacement.jpeg delete mode 100644 FreshViewer/LiquidGlass/Assets/DisplacementMaps/standard_displacement.jpeg delete mode 100644 FreshViewer/LiquidGlass/Assets/LiquidGlassShaderOld.sksl delete mode 100644 FreshViewer/LiquidGlass/Assets/Shaders/LiquidGlassShader.sksl delete mode 100644 FreshViewer/LiquidGlass/Assets/Shaders/LiquidGlassShader_Fixed.sksl delete mode 100644 FreshViewer/LiquidGlass/Assets/Shaders/LiquidGlassShader_New.sksl delete mode 100644 FreshViewer/LiquidGlass/DisplacementMapManager.cs delete mode 100644 FreshViewer/LiquidGlass/DraggableLiquidGlassCard.cs delete mode 100644 FreshViewer/LiquidGlass/LiquidGlassButton.cs delete mode 100644 FreshViewer/LiquidGlass/LiquidGlassCard.cs delete mode 100644 FreshViewer/LiquidGlass/LiquidGlassControl.cs delete mode 100644 FreshViewer/LiquidGlass/LiquidGlassControlOld.cs delete mode 100644 FreshViewer/LiquidGlass/LiquidGlassDecorator.cs delete mode 100644 FreshViewer/LiquidGlass/LiquidGlassDrawOperation.cs delete mode 100644 FreshViewer/LiquidGlass/LiquidGlassPlatform.cs delete mode 100644 FreshViewer/LiquidGlass/ShaderDebugger.cs diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b13b41c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +## [Unreleased] +### Added +- Integrated a shader-driven Liquid Glass decorator with a gradient fallback and sample preview. +- Added a lightweight unit test project that covers key `MainWindowViewModel` behaviors. + +### Changed +- Cleaned and documented core services, view models, and controls with English XML comments. +- Updated window initialization to apply theme and language consistently. + +### Fixed +- Ensured Liquid Glass toggles respect platform support and user settings without crashing. 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 index a239ef1..dd80834 100644 --- a/FreshViewer.sln +++ b/FreshViewer.sln @@ -1,18 +1,24 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 +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}" +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 - EndGlobalSection + 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.cs b/FreshViewer/App.axaml.cs index 9331b36..28ff2ca 100644 --- a/FreshViewer/App.axaml.cs +++ b/FreshViewer/App.axaml.cs @@ -8,15 +8,19 @@ namespace FreshViewer; +/// +/// Configures resources and starts the FreshViewer desktop lifetime. +/// public partial class App : Application { + /// public override void Initialize() { try { AvaloniaXamlLoader.Load(this); - // Загружаем дополнительные стили LiquidGlass только если основная загрузка прошла успешно + // Load Liquid Glass resources only after the core dictionary is in place. try { var baseUri = new Uri("avares://FreshViewer/App.axaml"); @@ -39,13 +43,13 @@ public override void Initialize() } } + /// public override void OnFrameworkInitializationCompleted() { try { if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { - // Создаем главное окно с обработкой ошибок Views.MainWindow? window = null; try @@ -56,14 +60,12 @@ public override void OnFrameworkInitializationCompleted() 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}"); } diff --git a/FreshViewer/Controls/ImageViewport.cs b/FreshViewer/Controls/ImageViewport.cs index 3ea7b29..3ff7d25 100644 --- a/FreshViewer/Controls/ImageViewport.cs +++ b/FreshViewer/Controls/ImageViewport.cs @@ -14,6 +14,9 @@ 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; @@ -65,9 +68,21 @@ public sealed class ImageViewport : Control, IDisposable 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() @@ -107,6 +122,9 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang } } + /// + /// Gets or sets a value indicating whether the viewport is rendered in fullscreen mode. + /// public bool IsFullscreen { get => _isFullscreen; @@ -124,18 +142,39 @@ public bool IsFullscreen } } + /// + /// 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(); @@ -161,6 +200,9 @@ await Dispatcher.UIThread.InvokeAsync(() => } } + /// + /// Resets zoom, rotation, and offset to fit the current image. + /// public void ResetView() { if (!HasImage) @@ -173,6 +215,9 @@ public void ResetView() ApplyFitToView(); } + /// + /// Sets the target zoom so that the image fits inside the viewport bounds. + /// public void FitToView() { if (!HasImage) @@ -235,12 +280,18 @@ public void ZoomTo(double newScale, Point focusPoint) _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; @@ -251,6 +302,9 @@ public void ZoomWithWheel(Point focusPoint, double wheelDelta) ZoomTo(_targetScale * zoomFactor, focusPoint); } + /// + /// Rotates the image clockwise by 90 degrees. + /// public void RotateClockwise() { if (!HasImage) @@ -265,6 +319,9 @@ public void RotateClockwise() ViewStateChanged?.Invoke(this, EventArgs.Empty); } + /// + /// Rotates the image counter-clockwise by 90 degrees. + /// public void RotateCounterClockwise() { if (!HasImage) @@ -279,6 +336,9 @@ public void RotateCounterClockwise() ViewStateChanged?.Invoke(this, EventArgs.Empty); } + /// + /// Returns the pixel dimensions taking rotation into account. + /// public (int width, int height) GetEffectivePixelDimensions() { if (!HasImage) @@ -292,6 +352,9 @@ public void RotateCounterClockwise() : (pixelSize.Width, pixelSize.Height); } + /// + /// Retrieves the bitmap backing the current static image or animation frame. + /// public Bitmap? GetCurrentFrameBitmap() { if (_currentImage is null) @@ -313,6 +376,9 @@ public void RotateCounterClockwise() return _currentImage.Bitmap; } + /// + /// Gets the metadata associated with the currently loaded image. + /// public ImageMetadata? CurrentMetadata => _currentMetadata; public override void Render(DrawingContext context) @@ -855,6 +921,9 @@ private void CancelLoading() _loadingCts = null; } + /// + /// Releases image resources and stops the internal animation timer. + /// public void Dispose() { CancelLoading(); @@ -863,6 +932,9 @@ public void Dispose() } } +/// +/// 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) @@ -873,15 +945,30 @@ public ImagePresentedEventArgs(string path, (int width, int height) dimensions, 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, @@ -891,6 +978,9 @@ public enum ImageTransition Instant } +/// +/// Event arguments describing a failure while loading an image. +/// public sealed class ImageFailedEventArgs : EventArgs { public ImageFailedEventArgs(string path, Exception exception) @@ -899,7 +989,13 @@ public ImageFailedEventArgs(string path, Exception exception) 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 index d191624..98d96d9 100644 --- a/FreshViewer/Converters/BooleanToValueConverter.cs +++ b/FreshViewer/Converters/BooleanToValueConverter.cs @@ -5,11 +5,21 @@ 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) @@ -20,6 +30,7 @@ public sealed class BooleanToValueConverter : IValueConverter return FalseValue; } + /// public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) { if (TrueValue is not null && Equals(value, TrueValue)) 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 index 3653746..6ce3968 100644 --- a/FreshViewer/FreshViewer.csproj +++ b/FreshViewer/FreshViewer.csproj @@ -16,7 +16,7 @@ - + diff --git a/FreshViewer/LiquidGlass/Assets/DisplacementMaps/polar_displacement.jpeg b/FreshViewer/LiquidGlass/Assets/DisplacementMaps/polar_displacement.jpeg deleted file mode 100644 index 1f88ab7e9caabceafabc56b1f26b411d79a6868b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4319 zcma)<2~bmMx`q=#2y2H$#L(CggR+~LfD&b73y=g*b`h{!Rsq?AxG-I}5D^f#8VIsQ z1vD5C6lg#;r%^iyL>df;5W?scWO2Y&L9b5tINv!@+paq^bx)G2!~1{Vd;V0t&;NxF zk3Kxc;90IrR}5AGgNc9_=7SWIg;7>gQd*;=yk?EEsxo}kamva#bqzH&bu~2&yasyV zHMG9Mf2F0NtE;D{tNXQ~q2brxe7>+MDk_>9ng)2h0b#B7TEgdr|L@I*+Zb&e7K6no zVzn^}+E_(x?1#G;3I_hhVlls8SOrClk_uAx2VFr?35!{y{9zKKp@_vO;1%)E^71dO zfA5iXwI>Y8q+RI=OZ=|(Bg;n87PtlV$dp{|K~$Oahqt;=;FYjE(X}&LV@cN1p0qr1 zvL#XLzN}p(YFbutA~F6c)F#VheeLNwlJeB_1S2~sE&8CWa;NeUvC+w!NlU$tw%b0Q zl1h_agx&8>_j9VNbi+ zg$qkNYjd78#oXTE;+}hOYUfFZUxpO#kfW?|e+$*VO>@MJDD7y`55FD4E_`Wa-*EY8 zMQ*#7p{lke$>GMlX78nBx9qT-#;{)s){GF;oN&oF%1>FZH|RXHHfgwgBG~grWlrvm zGF{@n?byiq4cIyZm33>#fv1kHb9BdI$jZdN4Z63F?#YeQZ!3!VQ_;b;irjPI*vH1r zKRO;)bV#|bmz)%cB?ktSt2wB~erK@b4wdpGit^GvY}`10`_seQCCTBcyfWp;lAy># zn*Oh{O~Y~o3Q3zA(hjkGF>xB0Ua^kY@^*%J+$wB~?ED4131cg#i4@p#?c14K29 zKp|yrz-d3Bc=X!7*gMWNwoCi^a`D_jJ0sd(D?B1?3bnoqx4KpmNxqz!>s!~z$irMx z!0kD2@7M1h<7!HY9Xq|1#y(Y%KOGf!J*z>LW-|y_0^o!X$;S+zXwQrp7kf!&BJ)uc$-d<587QcU>iMJ&<^l=H?8-&U=8w%?O8Y1@%8eYGC zVri`g%bDn+Ik%zWOFn#`0^-V3$=EUf zYb`-xe_nkvkXY>RH>UUCI3bo0t-7B?*=pHgCt`;9T}bwz;LYAm&_Z`zq|NiqD*d3> zVWq%qvb*k0P(erMZ;V$Jk+%Le48IHKdCvkD2qu&*(3@!*qIXrz?%mh3wDmM2+PB+$ zxA~kB5?XB>#x%{I?@c>l{c!5ypR1qxzp5~=H?H$!vz^_SHf3dRnmunqW~gTt4dLcy zQj4elR-#o-W7DHpwQFkvYW7cGyAm3!b$w^6Ooj2jFu(4dXHn=me-q*CrF*Xpv!XW5 z6m229ro9&Kda`@_9qX?`bH0(`9#{Win^2pF z$DiBmG15UwbKTp3qofWzQ+8<(3o{=w`W9&HT5CsZ(02a97oCTTgMyYg@1MLA40J&y`fG zmghS0!4GY&H+Y9G&8NQCD&*IL!N7a%NI`S+=qtYw@9Lzim0jm%B0{!&yUkmy`)X#Y zb}Kbvu6lT4_(4C$!DxceemQhnsVz#$^y z+g$_CSjU{c4>?~vGs~%5aZ{_|r402mTGV~U&6Wt`<5yp>kB7Q3C6ZQ70e@4plU4V_ z0M@kmq&aQt@P^m#jPKpM6~hx}y|Pa5LM<{@`qXL)QcRlRYn)B+w^o~Ims@y)#mX$- zF%hwNNMBeRuSs9`V%OV?{aQ{ouRR(gHWj3%M0DpDm5rqOeq*1^>fdts3}HuN0-S#X|ntFY#JecVE5535TUc;H05(GS^enGJK@@A|s` zQK^VKS?>4BtB`d6UP_9swd+s!JwjIR7>4s7SiO2aJe_&qNyEg*!IcO9sFjQkbY@TU zq%c7Z@$ymm#SFzNXe*NOM3|L07_IO-fQnqZgS)!AZrPQUm0d7I?$-a`yT4p{4Ta=z z1JUjZD5=J0ZN#xbF%VJm!%u*6)d?h0%SXjvdU<*A5F)8YMMcq&B9ML^9fme19kEy( z1A>Us_BK5Yhg5+VMNo>A878Ltdj2bo2Aw3NH%34kGdHcuM+#}iOcK|HR1y{LX~WR- zW@yB>y#;lK%OsssC6$DGBBj-N2GaOtLHQvUkhvsoPytQiC-i3M?M3Aj5SgT0c{O+x zQ`)35xd)eNg2*;3p1Eapo>|-UVoWVk%w?L9S_m<9e%>(k7`9Mu2QGM6;Ib*#VtEz; zL6OD2@jauG^Q5zmN;+t{Na)i%XxW?>-`-iCGQ-6YGg0?_V-}X6S69FmD?3P;h|V-A zeQ3iPX0& zW)aY7q;=Z4DzYr@2tF;gsenU z%anx7sBol@@AYXPq#YT^3b?BI1m)VWhEDqmhZi|?{XaB{PnlWv)`zZRj4r0p`C5fC z+qd4NKP8rlXHxAhLc;YS;lKYrMD8d_XFrBpaW|&nCldq%?SF{@5l==d6|jJTSiA}u z?z#o}rBZ*9!SyS0w`;c{q6SVuVt@Whw7k6hg7+5mKX8x%i((|r`Ox-7!UgisFE7Sa zA$e}0QVD44ZGjB|!U3KeR8}fM2{QcL2~ZJGGoY%o96`g02?RmeUJ<3By-O&_a)%ch zc}g*a_@fBfLsgn&J%HeB7~caK9znvI!?Xluu2$!nRuxMV(`XVeH1JGP3q^oX;LXrP zf-T5JiqDa_Vo^ENB5{|V5DF^MxEt%RDFV4{D)?O{UFM?JXMhe4>dj>mp#gGNV8hBY zZ5}YK^#mflLLk`gB(4wBM6N^L`L?JIF-7z!0M#!D`3*_`f&(A0uk*W#U^DPLHmbk@ zY#pU(&O{|vkZJ7?RZBecni_zp0R9!v5<3KG2E%sGwpF{NC%s~XgnJuV#U*fH z)XO1BfPE~cJn2SWpQcs;H zcTl_l6*V6wB|nl6Bnw9uIY(eaI?S&skk!IyYB?B}{tRK))BfVLN71XB0_N9eQBl!->2N)TQScIS{ntV`$1^@DWhf%6xC2NXoaDbjg$&86D^#KB zjRID3kdysbS++dR+tWXu^)M08%p3HhePuYY&aW;q_Ry9s5}zT z`b1YhS`FKvNdPV6jbMZh6Fd{>QUYyylMI6jAwma1sJ!|NI(iTk&y(b+Ty7zR znG6dd&f8kZ_<_2VIqTrBG`|=gNbH zH7_yTlME7JD$oZlfHoabN=3^xY;RM#oDl=mNX?fzrXv{y&_x4*-9!~v1pHa6h`%sx zu?A^ySyS5Bi1m%pOKxDAQ#|8z%D;P8G%}yD&V*WwJB0X22Ldh z)GqDl`_~+}yKv;p(3;%M0`%SJ&IGDrVhpMr!!PkEMWD;pk5mKHreIWU9Q~OXQYnL) z3-po$bSgLKm~iB?@Hy<5g3<`&qRsOlR){aeEise^$8+ejF%0VpxLc;-4m4PnqwZi$ z17U(fZB`CK`5>n#^h>bT;s~AJEBRDv5Mn8bBu+!A24m}eGI)HMs?4qE-y$@8PU* zV}@rOh|9>q09$_`6w6>B4Ba?Fxqn2he}{aM7R^G&IdW8v(n}wLV1%o`eQGP+W45 zm0dex{v<<*_!8V}lFXP`8^p#7&shDIC?8C4`;jt;jcfH!o$wI%VL)uTe&*GpzgqGp z`VpQ3Fnq@z2l`WMYj6!$o$Ao9+>4G`X9Uz;>6+2mMI^uzP_T9g-a-66AIf?vDhZzU^fHqj6f5euv{>vd45%!n^8MT=MeK3#>0+#6a z3$35t2taOqR$HEFVChJbf#erts6IzkscL>rhFqCwpb_f)BTV2%q1PlvQU7Sv>J0~C zrCjb6YxGkyJ=LLK#+~)oZP2jy?>LZfQxp?D7Gc0G`Wek@BT$C99xCwKE59yCMx6c; zrMHLm{ZGpZLy0xxnmBWYg+Tx6uX1>}{zm=VB|9A03;@DhQgIy;Si27W)aJ{Y{ul?U z`ul*m>?9TI2LiOng%Qw9?4eNqry>R1?3Ej!ApkMMUm=%zzCG;zY@ij8CCHw`E-+v80E^rXjo=$)xSFEOAdsi zjk9;#XMuk1ZWH|gzA-N4$2uN}+8tNDRzmeOlBU1(cRq`7Djd=3ewo0j#yMs6-}(IyI6%-8d~czj z=7!j;oRt*6uiD6g5^V(s%9cc=k^`97eTM#X(h>bUF%YH?T8PmDF~h`g3Q?cFv@;P! z_Q5*Z!ZNXbc`*=`YZ?%I73eYJZ4z4wY8kvL(BE>v2d&UAX9d&m&=267wcT+5v1s5! z@A_vBv{8YvgDS%j0f?Clh#5fVc8P;K1Q40`y9=cnnPvpbJ*WS{?=iXYm_IYVUdYYv9&SqR)d-1jMaVl+dq^ z5S*?D0GsL@2sEEDGf2U)x^Qq6aHT!;2ax@EqMnaqdz{#=bXSUCm+>>3=mQBjswC3$t-RPy+J>DL^%xb8Whe?$jDk~-q+t9^nc!< zOr6#rcMW3WQ5KVH(Pkna?5uMp`8=O+AT%f^MRR_Kes1G20ONbb0jPVE9iFbH3=$ro z{|rXWPumFtOk@@MN1O>jYs6JN1VEgYu>L$&w*D`fFDB41LGTJG5>RsZ%j$nK0Jv~m zf&P{QKCuqj`!&*SkAC2M4gdlr$g3fZcpL#+FF3%wS_H(HKxFhA=nMZRhXHiTdINko zW+3vQ29#d+3%Ey-L)qe%6k*IRa5+-T(UYW{KtgR8f-|utE7}|YFv>?Pa&J3B+zjnK=wDHAK_gf5O5K&3j}w$Z1a=@^^H-)=VCNO>HlO$ z2-BJ&uIrpFhOWyapRD(XUy81NAp8}!bcpZjlfDN>zy$rk4K7Bw7xjk7G#Rx65i}?C zx$&U0WkxK6A2ZNVRqmsoI?;tRZLFUe127umy&%M5@fO+>4rE8)p_jZApf&{|LqHF+ zooBh{tow_bJPkUBoEmk#@8VsD%@{Ees|QHz=gIPJueeFeIb`f&f_p@i8-P@s!XX|q zxVj1>RmCzq_!0DLpH*?j0IV|Oy&x$vxb2b`cS2R!$B;LN!fXn_nRyBb(J(+OLgcfd z^e|5eMMPzN`#x7+CpiY%XyUBK4r=% z7mHLh4VbFsW#E8I+X5km1 zUs%mWiUH)gECMto4pw*KIR_N(@<2L~AqN9!93jAcq2OhKxVHd7gm2jCCG-ciSG%7& zm(jy~j!=0mAL=Dqi%f(3?A%9f>;NIBKn&)hI_HO1T`Yfut^)4k**EA`FmLvrs%H+u;BTN~{B7 zRBo;~LV!CwvgsZCf+#DoW(khirU>;y?^V7_N6g2E06}z3Q8*tw*g47KSwSTY2`*R8 z4>S{`(MSoA62KPvWmY!tgn670-ua-e%G9g~jq}3_-ZBOH zGu%H%{~GRGDBz2;$&J^ND96-1n!T}^_X<-4TBLq15ulgnUT_PgZBzM_^ zus5+X;J&j4T{>va77wt3fGY^20ULn={Sw9540{f^I-hHI3ovL=$pW~PO5r5<{u1<; z3JYP{XZj9^S;hb>fxhMdqRJ;lB>`U@49q2E_JWtyGobYKTY;|CKOeK`(axbr*$7A^ zFptGMCS4x1L9>To6LP9_CpIiVAW5_UmMJU-g3unIe+~D(i1Q0EfTA2R29P5AKOjtF z+w)m*U=GC2jFVw$0fO7JbbdN$wn`=It++k;7=+H9-zC@O#@@63AR*8(8v*x@I+qnu z!s2VGq$p#@I}6ZxhM%KxLv}dO-V{Q-WUi!he%eH5 zS%0vg&-`fed2hrTsKhB>PA%~C2qyv)Dxc?)*xUkEH-QywE;d5~nsau9*aE0zBFdQ+ zYxG}I?PwqpUcf!;po%JcF~DrF3JENaLfN}J{+a_+12YA-uV|g0;>N#;fbt#gF?YS* ztqA(CyQnieDHs9DFq+TfRnIs6EfRY}s5ZIqke(Mfp z-7g$K82}^>+m4qo(NNh!lg~9Ie_AUaAZa{6e^g0697(l+dtbDf@cc*lyEZ#lEZ#~nG)*b4?0O%=y&*zQC5v?F5fVpwC(=?Q!;cYikqc6jRB8Yzijak-Q)z=f`%F)xY^gMMz-K=bJ*M zJZW9B{IxZ}=jaz4^Hz4neATQ8zY4Kw3LJcf`}1#)`x_3-%ZmrYJqPH@kczr7VVOkc z$D#Sagy{N*Jtm%5CTktrk}Ys6cp|d$vKWNU6*MbLhW?R<%*b4)|e1LNxfPTpV*^@)%&@7$=*C~GfT$e1-?{cRd6`fRP z%5>Bjc7ae@fQkdQW#$VlOhL(k?xs+wTs}en3h@oxMaVm3PzuSfLnFJ_o2DR=r*Mas zeDIE~b17!;pA1L~sVNutxidOP$|u~r+f$)`fEq+5i0Cy_riK7c1M+svjm#Vf0f2Qi zNd0pW*Ny~tC_jg3gq$&2z+HTOp+V|_&(>`aBz&*^K(m_Id z(f3A=k^^i*Zk(V>1Pt*)>q{{yenmfb#moWqHo2#>DpT})Tx1jXr%wX&^%2nTZ$OYK zXx0BL29F4Lh!+`?mN8M_1^QpOJsHyt8MG71e3!R<(0fF;reHw{&8IK`=@CKZ0Iz~) z$0-Yi#Wy0L10SNwH-S1068gP;?s>fjWjb6YzCuiuKPL3Yv;gGxXaHbxhz8+E2Ekcj zl|v~}-W3%&8x3moSK9rN3lv6dupaD2DfZa%dKvCWST`DoN0bS}K0T_)_%10dVb0 zc7zVhjVqI^afyDG(hmjz-dn7ftHR{~_+_}0{rW6$(<5SUBx9XM?_3}V=o=0obyB2} z^6{80KWeFR~Gx1JXWmpk6b{R6TXG z+WjXO0XXQH6@1D%=j{pGi8OCZOmb86CZ@xs2ML8u>*LbaN2EddQ6|8{gTT6o`$Fu5 zEe85^B<%pIZxR1a@jr+DnTbL01`Spo(XA=C4GGHIqhc01x2IWlMyKH3^*t!RGy;%% z5MhL7^T->id_a_~$Qe_?6Yeo#8XO^3|MTklV?fVLz_lYm*R$@)Mj&$gm!N;D5*~5i z=P99orteE_%OcUz;M|Gq5p9`*q{6NLIxO~KE7(^iVB)ZLLKv*IBUGVZ@_e*y*4{uh zdBbEg;#%Ahnp*&RX~)&`RQV?jG6ysg;2`+$vE2FLkN}eg`8n~L1GLf*3`{}UHX*iY zp`T7&b+MQ)3C%>>M3wY!Qc=+0(mGKzi1qJHLEu+{zO^wcRA|q7g`UaK=X})k@~P1e z;(#>FG4!V(<1?pCKB?yxp0?Uz=W>GcFc1hiepM!Mj#v>9_5Uoq>-aGfo82$=qLdxim&<8^w3hOn*I~Pb;WCQVty3KjE zoY=}8mjuAVj2v*q^uh&_JVQU~J_kQDL0A!2H`4Kz2Dfm3@AE891q)y&`XIQaLFW;H zuG)!)BlNvLF*Ql|hm!_zlHK48>1n}t{#<9jBKoDPr2AL<8mH>Q2YscXF_@VH<1T-mGh^9t%rji}IP*`^yz*oF*6#Zl)pcCl#z*7>t^WTg>RR32`BhWw}g#G8u z3&l@)*u7D*2`>3TGmO?ic3{ZDCI3|Miw5Z+{v7&e^0z_X(SVh=)t4TTJj%6Mg>mEo z8DEYfX=T(n_w9pol@;(Ma~!c{toV<_=5)dA?Tl#`ZlQFc;2T$u0K!8i*cYfohcA^h<21I zSEGnlXu=h@N8F*#1@fA&dlzUxEr4zC;H~8uqF94Hy<(L#qM$+~K#d217&-j?&$lZY zk6;9@8XE{xm(7fWzhZ)}$uv&d3qk*i39QRlfFjZj=&Q_UUVj|d^6t!FNrPimuO5*E z$HlP89qQno)0p~~Q9jWL1l0QWaLt#ZyfhcW9RDCfKW}OjeV#vy`VdJTv!7%E5bNxW zH%Y+&{^P5o|C8ciekQ==x!n$5xFEiPdxmvDZKF-vdOMXGb3WY$<@^o};ElU_zRuc| zrXV=|J1;&M{F2QL>ZjsB=K>X`;H!0k215I6d|3MG|8S8s`UQPlpd94?6czwiP5Cf* z{CD-OivNq?ZXcS61OSKCjEf-}4VN1;GrkK}f2a6=1N6`I?U-Y&3(DWpKwc1<^*JzL zKnp8^DUcgMsd16AxJeBM;6g!Mfc{I#AZ-e^z_=JEAX`hj64pNE!mZI^cysid1u*aY zggywVaG+j5_lLnTAVzY5%oLa%0NnuH)%{Z?U3tNDl8MMCbE9kn6wQiL)mb0Ke;CWE z3a6x^L2RiEVTemJCR=9;3J%JEF?Hxi;s4}giSO10`npERUeJKo=-v#P`GUuUZ}uA* z{g-5h{k#kC`}R5p%v6tHG+7c^+@friGB41>5QN6vJ~zJ$)|KcH?n zOs}=mtN#NyzS7rXlHy0D`$HT4dH@j*)WdyR!hP50*@u2axn`OMbr4+pMV3MTaG<0? z85kS@xk(3|^%sHC1K=X1^{eLt0x=xKrF(+`d__PnErWmHLx9KPJ-A(qHDlxr|IQa! zUkpdFpp!8@0FcL@!(SUx<11z?arBTZv=CcHZ23a6t zG3;Y!HT?b0d-EHhM`0g8ZSn-_&8i@Nv9Sz_O zj#Pj3!Bk;lkgc#?-Vrein$^51<`U>F2fpgFp&6(j#DQd1?HY`sKMa zw{Ttm21L24tal6lM~bChpZ7qK8iZ!i^TAl{>!5kc#EZ3?*2pqBYo;S4GGz2S2hMh1 zvpkuA40`c}QW3Fz*ocx6yjT`R2YsoGEsOe?5g~^RcM|LMb?D1!K@uwN{GvnyR55^$ z-q1jHah3AoOAFG^B7oEzT`nh04Q9bEb-B=N=&6Z5AmZ(7?&@^cG56v90(ut zdHze7P7i|-y&y(;u32o52;?dBM`{*2U&1|4L+Zcvf~kJa{H{T$Xn-mPsA+Jk>~qSH z%E&XkBBH;+G?wV^nq8AgX2+)#XPf75lF9WVS8;?85jd!igBq89&OtB9$rAu?6WL`X zL97pBcWWHss0!<+pudfKOW!KXiV58-qDlkQ>7E8>-2u!nps@Q~Qz_KK^|uU1sEs;T z-zMd=3nV?u9D>dR)pTjlGD*(-a`;HmQ55P`lU<+#0mlU&QZb;MZ;WzRjC;yw+RHXQ zfPRJY0tqGbd)!;Gc^M91F&8dP<)EzmqJ0|lM9Q-_FglR?g(gpGlVgOKSIOHwqk|+zSVMmdUxz`;R zNN`*NJ;cE?e|5)4>Iw+axow1F^%nX)zE{wHiu+jn_He2*0%(|!cPO@0_^w^1<(twn zbr&n?3_gNz84!#b)1~6iEw#by4e1)f`i(Fwm>~JmAb>7BH|(Hg#0DJ=hF?unF9|UU z;VSeIfa?t!^$+^20s!k)GI2X~$|V`3lK5a>4Ff$+aWZsoUL_82-0kn9%1BX`eFn<76~6i0Q~nO>5J*lQKX6 zF8e|q2TCIl@}No@-Sak?IM1yl+i~_ew?>{7Is{F^glXfz!Uf_Zzv)l{J!ck6Xvx2X z{+74%G43r@KZ^nv_tk!n3H9y&n3O6~pL$)Ti7-JSYvR%oLPYSs3l#Q-*p=sCYk3C! zv_Rl;Fig`+7?91iDU?kY$WIM-X0B$*7c%bo(FVI+P106jnSFh&@;}4971MJ7B#1v{ zf=Q$LqReFwWK62x2TWEW0s3&58r+d6k<~xwqb?BB24Og#&rhM>*Eu<0`H22Qq#1ur zY|{lotj`u;q5C($?=vhweCO@_B<|CWe5-g6MERZx>>BByvn^R|oJjsu)-Dj5vz%%pV8j^df@TTV-v;r!D!;@1f&)#ldKE&NCOc4oyMu4# z8np&tISZY+sDH~CE_(}L9?QxFvcK^vIzdE=^ZA9T;sAy|sy`;^2k5Koi3z3pFD-yn zB3>@_)vSGwfUr$K$P^K9)hx6`lKJ9Ps)^45<2^o7D#wB}YGF%mfV!B)wE>9t;;q}0P5(n{su+knR1 zuT}md+&|L&)KBpo0GSN?4$>_XD4D!Qs!!ConyDfoAO`gh2fbfLWU#y+;q~?za0D2D8oWEvi3$BcwltSNlFZ4uCTy^#8$lo%*i~mGC}23Cuhv=PRWLB3DPQtqkmw2t zzQ+AS&~uHuKpFZAG88`2EfzMJaGMx^BC>QG*#aOUJKpZ}y@~#h?hPB9->&}8B#zLz zk8BwZBrk|);DC;r{^$`OMvs|YvrwXTjr-TievbyV3v^ra#w?^R^OUye5G67w<657O zG1J`$GOP<#JVM|$G>t!k{xb!*AHbOZFJl1wzod@f0%aO-gY_N=D;~Q;9FSxIey;4F z=>9V;T_98*KeVPL!$!ViLYu_>5ZM{5`4}@76DUL$GVsQ*kMv}ge>4q`3dZ#Q*GjWujXdix43@{`f}H1W#a;&F$H(@*uMoNm+!hl)!Ih%Oj-+osqE!O5FGkr z_LkinzpVtxBl{4lo}^ME^i+fY2ZeRPNRvOjCu0U;q~l3Jbuu zK`Y$5%IC8)IrM3uYXWgT^;Dnae-Y?8ZMZ;X&0)|B={fwoiV8Y@^nGrLh<`Ff$@~WKAA53Hfj1WUC!))deu&MmpO>9z&`_c&X z@Ljp}zd-Tx`S3RA!GAXSu#bLd%i^wRr_W|1RdVNF!R~AsB48x#PJU6iJ&I@__O#63 zkV@J6Thk2m!#QwJ4ZA(zllG&t1}onuJ(J$L-+x;yYhvZ7D$kT$AZqj}AGXO*!~M>4 z5@qZVNAiCV)qkSFl_7g#ofKUmN#jo^3xWaCAN0)ueud7wc7c9FplKx{H)Ql8-RbG%c^1Cm1Od|CFHmj+W7K$_U~SG(Go>{Mb14H=Zr%k0sG# z2a8L59`Twm$4wj3;A_n=M=a7>Qcu-< zM*N+EzpY=nK%Pp6C)GdT!}FvakSsc1$eUHiLAVE##QG5{@ed|_U3Bi(anMmVt~z#} z&nGv9&Y7%=2)o?2=_(iXbZY4WVHf(LUih#vncGxICR$H(QS`25QvCT@0g{O*#?40N z?@Wa9%fq25oA)mq2#)?8vb%=^x#hj6dIA(aQg_wsdH}Txgjd!8&7w)TQnU7wx>Sp1 z?FCsqRc5omHbLB;@h?z-JoziJJ7nJ=<6EW7hoh>=yH<=x6+0=bm6d{j#DV9hDi^5h zWexrE)DqMSmfa_D#1lMgFNlhU^6qYrXY)S&#fhWd>vbSvg7JZmN8$r_m-T%f@m(t1 z%b)geZX4XWK)A!{TZ3@h@Fp!SKnMMS6fk7;cSDple_?q3{o?`KOHm+_*rNURVa~ z8HaaiX*b8R*!7biEpH9?Hzx)?=yg%H1KcdZ{a6-%Y?43N?D;c(cFz!kG44pfbJli+&_E4*IU&GBjJ-ja>Opa3SCMxo(3}4G{m~m*5`N zQpFL9XWplulIh!iwr_*}+>k$z_J*!Koc*r;3B2#5^8QQ)J!;%5Nq#o{9wGOmBo}D! zvOkhWk*m%wdf5Cf%U`zeA4uZrm_D0xaW%yZ|DrrL zlfKs(q3WgI?#=$e^uLh+|36*nZxG|3p2N}w`aS9E`6Zveg82W_uSx&lR{jMR;EzuL ze|{VOO!|B7Y5uwNzl;EU@2{G}83ooq%=qu8^*w;~J%+sj-d_0U(qC{FXnT~GHy`Lk z@$J6FjgkLJG}wMBq75J2aBH5={Q1;x%Wtul?|7jA{r$@`|5mz1_FZwuBicRtdHRv}&EjtES%7zgZC>@G zQq$KJm#;S-T+FWr|IbUK$mds{7R93R;=&(H@7^!oc(wVJ3-q%J{s*OI-{0$to=3pC zydoc7Ap1ut(F3>sUGh{pmc{jmKb9Ur|G8iE>KkECFZl&_vjAlOt))WK$A6CE-;j!S z;Gb~%*C~7X<-b2m7bIoBv=R>j48Vn1*2`!C#! z1sKkO!*HMY@GqrGoexyM6<}xOmyu$#$N!u0^K^^;edEwuhHV$<*J=9Z-oi&oe(MG? zPnlzC`b>uIqQ5_tPK}&6v+Ey*k;W^7P}cP6UYP3^JJ8>=4BNj8wBPKhvVSPJ_Cx}D z{cWZr=zA_o#%_aNb^n4>WJnz`Pv?^Em-^J~(LXZ!8R0CXd<*@2vwPXBw*a32eR1|D zOy?2v{E>G0+hzv$_)CBo>Jo4Vx#Ty55OKro~QY9Ck zrF}K?+9uo)#Q0&&mlGZQ(wRpHHJ3=|r zjk`=Wq|4eDHeuT!G#20m*1J>vete`n?x1+|G;n?2alq6+f)V&9{34cWz;M*}d@OB( zIzl}G)J@t>Tkd*CiSDu;R#1aG7wC~{@9IZ7Q|hGUL@eDu0iYVY`x%g3{!0j|Kjc5m z#aaKgG+<cjibZM%T2fL=XKX&-PrPS02tv!9TQ~g|DBA zM;QP}1HMoqV6qG&G?_E3Zj%0WsZ-CECwxP6fB&DzgYY?Q~m#M(66ag;x9TUWh%6Oa*9ri;hxXD3nc$!m))U}e`@M4 zNRDYZYB}%P-C7QBbDeE2$)>0n#M3ytg!(&4+pzZHH^y_td|?cm>r`<%x7STW?LtK-{B^O0RTL;|I}$zR(sD;)bm_u(k$W5IthWf)7*`2=D`l&ovF`k>tqP?#V!@ z`*Roo(0gx*8w~v;a=~RS(gpYOpPL% zZ5J|q;u?j1clZE&cQbxa`GfiIEh4D9W)>VMVb_*{44*<(+zp{zU*#@}Y2neu3iO0)6+NTAI{huMB*{+4wJj zI0%*ogJ}@q{?d{CH7Q~;)bbnu!MV>turxp*09%7FSY|@3=fttxLT-T!a$=434O5US zzq{%FBwE<&IpxASVt_5WD3L=T0appYcQ+` zYOtROwa$b+rzj8u(MOV*^fmV`&>LkR^EZA@wxsLD4+N!gD6C7kdxNO>F=O9gNzCn; zrr>OI(O?cb_T1NBnu7K)bdvw$u;${dv^Iu#SO2lMd>2UX2;p89F0`fNUNd1QkIF1S z8*okS0zCjdS`pN7ZIw|=`W90);!)fP3IPDAJ%^zIQKWT7YBT>O`HUg?s;*;8| zCPF`WLD5Iz@V8L^jqZPEy3dqh8P1I>QwP3F^gktoTY%#3`yax+8}~S%BbXF_@PZ7E zy%7)^_)-32t~EBlf6{=rXZ#6ANSvSGxPt&4wZ46>hJ=1I3&8?VS0j$%978DbEG&`0 zic@rHpEj9*n_@|Z@B2p>3ySaKp7LD;Tu`D)wVG$>4{c<>_H)cGf)~V;n9U9gf6Zi@ z1gGrEcZAw-+8m8hmO@Ez_3f_TDxX(9&BwSW9(1>|HI|d-oU>5AI^jOdZdrZKR z7*I0Iw?X%neJt+dexdTSOtT{4RH|@9?3G4VI%OOjODt7x#H zN%&1>)BV?}gg^oJkwBl%x+bC4S_gr zv4cD5AeS=Mw*~>cC82Wb0a@=L-F+kMDegIctMZ|8EH0rrmqKohfVe&LY^VN#KnAA4 z=>i?ReA=oI4KoK2$NnTSTt+yuZf4C5ME|Ekyc&Vt0_bq?_B=z>rJ3wCTd%nObo_Ck zHt27efVP>;Z_5RGiThZ|na(D30xeTUS7!uD&j(Z^ZRA47b`s^K^*I_g4#2I`M=7CS zV&4m{@m8kZ-buP&IACxuEkNP@k2tq&3*aIoVFp+xth+&|J9@>0IqJ#nA)Lw#+(-Wrg`pR9ei1iWyGAL$V!|vI&jFs|eoN)&Qx)`!%USvLFv>1a>G=Tj zw@)6rKqfJ>+KkQvr29ws76EH+omQ7dDwS%2)OHR4O7t&WASVn0d90`z)SJaZ>AY5! zM0VWmR-3z#DO22)Ugj4}Xr2Se#LH7_+|RsAsCMHsSuVzi z_?iZGD${@)uxnJ>m&#njMg*YG0d{bYEtQYBdG)CZ`YPPq1BbS=h=^uXUJdM9zjXGI z3pC;oXVocvyG?Ypk+Q$zBk8U&Zw!%La>_I$Dzl;!}UVYwnv&A6oj zomCZ48Uz#3!XO|Nm(Kwn;hyui&=;MthW-riWVrkz5my7kslC(Yh$qh{tNa}oXynbH zYl|?T+#$Xury+k;fPz2vQi9HY;sUiah(m<2;>0|gm9({uw}4hO;IavD#P%gsN`sK| zplh*s8p|9-5Pj+n`4=FCp}(i9A;AJpJ%booCOF=Qu-kf2Hfx^}M_ zu_mhjMF4hx9iGMs%c<$Rj$e5LDO}JmEo6%8O}D#w?RKw`P|7Z z^r={bfG03v$TxYyakzJcB1-ijK3tVZe1WoujrKI{-n8=j#RnQ=neXja6eFEci{qE zm0#g3cb**~CWE7&XWK2ONe9$XyrDsb_0}(n`IT#wp99=Lf6(6^`pSwY(7)9#MIz8k zjoOFdi1*4f^ED~)U&{eR^p807h@r6W3oE?zG&IgSELKF30|2oG3m3>gUj;+Zfj)5{ zZ&RFlol4^wiW@Wt^=rSVGYB?ws{i)&$@x-5{aY$Q->##Pk7IoTbW9G^F3?x+0#RmEWblp1t{0k2T(64d2pcp=sT9wE z6k9fxe~+owNmneibua=IzkiQ zNW^d#=*y#pK^aEB4M*ErGhr)5*=Fa*OjgpynHQ$hIl#rp*PeTp&bZSluy) zR=4sd09g4&F~52aP(zg%es7yxutc7aA5 z(WNnmxN(H4`D?yY7L*xKOpE?5&yKIefqW#IjWq5x$+$IZ4#>h;{nw^4*52M2UA#4Y z4F{@>`T9A)+S@@|zh?ycGn1{S?Cz-Yihwd1;u-qGZJgf%vdYE9${mqgaOanB{z#f06mDZb5DR?ynWpRou1P1#w zh?SlJ`9<*h%L25k%FxeeUqD~lNIufQI&Y;tTz=tSzZ`|)2j2;mM*-voW&h{OOiHLQ zCYHXgmhzX%3C} z<>1w8q2-FAlbJa%redz0mW1pKdW<3z9!Q-akRfan<|Xx>#O zjBvz<0}A)(DS5qZGtuBW>+>kT5%vl65kV}?Mru&zlj2vm%0*G_g&`hOzLYU~O-!gWM;~)L{a2>>MWDt44C|INBpFy{xU4!CXpHM62nIbO4F-WekO481 z_B3VF)>N2Qcc|RTGB~aR1Mgvv2H`A#fo~S~Bj}fM8Qh?f-HHjRl8yekJ)O9HViARB zI`Y$i^gsX+$@Uram`nlyz8wT+!kFZV=r8u8TmlPzSS5P_u;5E9&#pOH^91j5vX&4T+R&r zv^8V+6!I2$-hfWN5XkchHX-%o5KqCecM{tGFKJM}3-kc`Q#8`@f#TyVm+Jq<6l{7#4{4BYgQlh4L!b63w7i2mw_MO*jMOKp{GoY= z^+f-bd13@c%G(iU1Z>dA0e-v7DVo8;Y0SgS_`*38p}#=?zWVoi#39oDYCa{xY5{#w z|AVeVEDq``yE>gmgq{Y{7;+m_p`Z3cSdr^rDt_ybrpvKX;99`ipdCAQW_kgUhV?%r&Ise zeh;TX-~hbFeIC9GggCFpp?G&l%_Ookm7$o!x1Ld48l@a+nW z1C{IuF$YMJgXJk|9uC}RE|B%MfIeFu$`t5NYhf1%CG_v2Vb52l;3JO+lSlxDJP2Dt zU-qWZQiw>E;>YMOIFL6()WBTzK{ZF^3t3paa)Cx41{m(=c*DdkUQo>Q#0vp(6^-f+ zkcdij8jb_{;q~`kP-Il-d`IA%NPl|}9F?($1ijny+7txYu4ync-?GAe=tE?bM0U@F zWu+EvtI8?Gb@@EmP+B}7Mt~dNxj^Rm$ZgQe3bP{whyEq~dX_PfFf$D>MUh>XY!DVM zkm5e}R*%d37*nNQG2k3Pe@6&XnK@u>_D#VYQ6^W?fSv^NQU$n47C))@nK?`JhnzpxRMsT~JMwE|$zH)&EwX~s)6<)Ix@&!e9 zX%-xi`lSmrXb3DRaSc6pcGn{UaHQ`81W1)wM?sR2&n^+K=&xu{qTjVw#Ys(L>hGl^ zO8n z4j>e9=K`%BkRhB}nF8C^;nVY7;M<@p=;xXS?xCMMeUDe@4?D_p2%9qVSqi56OA8Ry zzg?@QAadZ6>_mLH?ejwf7@7PmnTUST>#Y%pdEUeV0BD75Rjlj_4wUMjIDoO@14e{z zj*K@=0fI;L?daLWI?1Rg-Z4Q;IajX85YZnLo9s;9q@kXVAFcABj42iNf|eXOr7Usq zF$pkiHaxR34b2ZII$80%Oe*vtjecusj`HSK9r9V4f-xy!hzGkvs4W1Sf@=fd#exi+ z4D^P3lAcP5<-yHQ{udnJmMCuxk&ryUctBQ~0_&?W1!v2x z2Vt@k^~P}HN9Zq4Ezxfm$~2%^uL_vXN6ZYF0^`P@Vz?hVi`VXQK#ch0YaSi>Gv~ir zJA6%fT_cODw2C9Pj79G=XW~u;8qkyr*+vvT;{g1^8}(m>0fQz`fF6Mr9S!0SV+r~n zDt;go-=SZ18(KaD8t~-HJrbbP(Y1aw1@6o+4Tke!Onrapr)d^0ahu8)CZHV9Y z69n$!|H$Zzbcu=()@gmN!EhPpd!XJAEA>kE7w!=1;;kvDz7zaN@k=qDQuGTA;;BdI zx89r&&CapmzOV~w&puClOlHp%55uv*oxPwD;BL)el)y6rj7GnR^nx_R`I&hb6Oj18 zLK7M+h`>(BhWD==sES4*u!V^(6L`c0C1MdvQ;@zByjJ{iGxY3>0AP)N*@wgjra_oI zUnsB}Duv+lOfrD{B6EKHE)aSDYz;ur7v1l0xlL)|wa}7Y@C>O2m6Gv+$$%)vUWTkq z1Z|9X_BKxi{D1?@E*^C04uw-F&UU6?`Hiq0FQ_3#Mm*;+}$d;dq=$VFlf$3>~`C$UK21OysD;GR^5&_H1j6FV)4 zPP{Li6u+(fF$X#qNJ3$Es)`X2L9eEOzY|=ef2Wv;@gwM8xIKXqOoPQ14nm$mKjS{y z0Vjz#LZ~f3Z3hrRe@6h$56+Mncj6)yixC3_6KdTNXFVtnf||n}4$6J!XTbsCL#g}q zfiXf6pbO`YyD32bAgslA+z%l}iFnSh;Ewui>Gq5{T&dOSjht)d^&{h+_@LXsj?kd{ z{g$bq&m6ch0#W^sG9xREd|(=|@C(G38Z8UWCrx=03yk@VJ5-WaoHGm<+tf!ircKXh6g9qIefE9DH&QRP2BHdACwmzBO(r31zPkSKw+Uf6VZqbiWP*R2Le7 zix>+haHuGDbbpr)_Wt3%K1{X4F&ey}=Nzylv-jl+Wz-x1mXtu4zcsjz0TxA3DmniS z{W2e`A3P{EzWU?~t4=r_AOV?Zu(Y$Yaq02v(Rx1O=IKO=8yvW;L6z4n!@WC%_c4Ij(4Rs-VlhV!^HgC0Xl^>mc87!d z=RLa^(pg(_me{HFBW{eqDvglO8j6cSsjoROi>&xZBbIBbo%1u*En5IU^*t)Z54H?% zIZ))0HVA!jHpXrYkcvNpe(2>&i8xkhKcVjv>m8v$Bd*1u!L&-62sQ-u@E~WQB#c2skUYVgTU= zH~t9yqb_#A05@1!0GcXtWtoOUGuVW?@C)MlTZY|z0{yKQBwQ`rbXv~niX{ii-n!H| zKL{A=EC8Y@$Zn4acGL4&aUi*@5K17>*kb?{`hE&o&Z4e{dtE0UGr$W4&;J%9$^K9ue==qd2dL zbpOHVKSvP@fT$oMdDO9~$iN&>Gy8}GAlyfq)gb}g9hxu0RT?E11NuuPw9SWcS1G6s zir=<8IzNW^u+_sef!OP0`vlru;zr2_SDc{BLB)JyF^yTwkcy=dkh7dAdRr!)9$Nrh zc|*K7LdQH?JX@^Xp}>Ju`G5n6y1$A6LJ{7B{wl<8LO+30DxCvVUs`}cI2bY|K5Ly& z<6TUcEz>7Y?KDo~Fo z(?IgMxS2*@?Bd!gzcq5?u)9DeJqiOxMFY+h&%7!Nz(>T&XB{fOs;vw*cky;Ky%9Kr1B<&S9}#(!>BJ+ytPu&7LpBm#-8vp&5w%NG3M+ zi4jQ#(MHx4d}W>h)Pone8zm0zgx`dbmeN<5I^d2d6`Op%YG{S_oEKHO(LVEoBz#<9 zK+vWLmwToP1`vC8Xv$NU7f-}W4%lpC8%(y}!~mtM)3w<_|Bz#@r1n|yqtCPffNg(9 zf8xC2$;5=1xPG3KqS^$i3`2BjR9UI)i|cxFyo2W>PPtFmj6fd-l$qKTh&x2k)XGO3 zK&V9N8dY&{`cnLFLI2fhMTr~|V3J`_y=2Zd!2-;)W8!~M4`FT@B_ri0cHNzc^#XnF z!6|f$`#g($$+BiN^**(cj(}{goWRTzm1Q_}_&7)IaZN4vV=lXC(HOg^Y=Rjsyn6 z8g-?XGK?~~tNJ7NRyzGPc-sb}so&}_qQ7`Lo6(W6n+&e;5D9lb+X2|d1fcahV z3o(5J{2}_KPw~RFGBIR83DZG;#8cKA566owXX2@WRH68D`%1wVc3^A3;>3uBRkuf+ zK=k`@L{K7TP#b|Y4crlm+T8{n8IF+y*hKse2jG3`t)cKQl6KIaf>&?3Q6qYHP=~GS zFGWUenxwFFO#G|*H_l|1@)~7`oa|Jk;6nslT7cU$J>FvqrWl&;k5!d z?AG2tDrL{^5Zj{HPsHS2r%rN$o`b7aZXFnF+j=nr=3-s8R?> zmvm{#gsmPPrTyMa1xisMvOxd1E<9%Cm>D+$ci#Uk`b(dOY(~r^wge6!5WC}m&Aj+= z+;`{~xD;@g;^!RTQ7SDyG9TtB;Z&Y*SWi`~(!rv`Tdm)vQD&EEBkC31pM8B>1>ZU0 zAoBis@DucxH%1|@QDnk94p{M9L0lC}N|YCht5bBCMhQphClw`wi0=#2(%~%GC#RP& z@jB)9`;>S(R!$pH?@05h!j@T>ulIm(W}GSShn9{|urTp>wIpyOn}wmKrsDvl5;5kP z#p<8reTIJPq+`X^Svs{70PuY71Awf~(w8`32t84fLa`T%#Y=*>L6+2W+YWXjA_-&qZc1^z&Nes!H85A}7yW^?5SH6TVx}Cy4V*7^4r=f9yfHq>GXs!Zoqx zcYgZ$1WkOBffiV(|2uKr8Ub#GM)!jid1Vlgn^kQVc0hGJ%^y{puk+83p&ub>u|xkh z2V_tlcdh<;HeBx)4L_x8=Q(fyGeS*?(eW9?j~@CLM64Ot#F+R%TLM{^e<#e2-9bOJQZCOSfi7n_B|hT7tp5@K z+39EKf9L|a+&b@QS;WM;V>j+4tN(bSX+~!=(&Q39u|{OBfA{HnlS%+f^?6h@$l6^- z+%*E5=#RK(gAhjFcY$Jq8xDwG7&u@t02;NrKtK9Z4f<=hCo!Q70OmXc2qPu3`Zp$o z40Xb*4!COW{A!UeBY=(!-`HRa@c^=M$m3Q3y{=8BAgh1(b_nj-DMB&=N7Tor#UF$L zbgD!-<2auMP4b=t(g}bo26%`5(VW^3d=uRD5}Ri~AD)sG2ehk@;i5oa29gdk4J0Js z_B-G%Q*&W(4~FI~IXbyAu2*SLN`}sMM!=f2Dme}GAPi`j(1yaVIPir>=ocCFPEnF! zXj2|Nd&{?(S*w4?PmBSy$PQ!o_nxq)4n9Ui1+k2vsUg*Eyi zXCpMP^BwM~@j3vIo(m4p#N>umE)M7%Xs-TOVaC0cqjx+KCX`?w8=vl~nOH}9WzH~e zZpDa_!tH6CA1sVO z_dtN>)8n46x@Cf%E#01N4z%wA-J?IDxqxrgM*C+zST|{gN)Bn zfF&fT4Veflxlp!gSYQMkx8TlX=>cj+R!c}HM zO!>}_3}-!OGQM3u$6C g2S#;D+S3yLANdRd#@MguhX4Qo07*qoM6N<$f+W?cJOBUy diff --git a/FreshViewer/LiquidGlass/Assets/DisplacementMaps/standard_displacement.jpeg b/FreshViewer/LiquidGlass/Assets/DisplacementMaps/standard_displacement.jpeg deleted file mode 100644 index cd37aa4002f56e2b1657a86cc8291c8e1942728a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4451 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 0.0001 ? centered / dist : float2(0.0, 0.0); - float2 radialOffset = dir * wave; - - // карта смещения - half2 disp = read_displacement(uv) * half(strength * 0.03); - - return uv + radialOffset + float2(disp); -} - -half4 apply_chromatic_aberration(float2 uv, float strength) -{ - float baseStrength = strength * 0.0025; - - float2 rUV = distort_uv(uv + float2(baseStrength * chromaticAberrationScales.r, 0.0), strength); - float2 gUV = distort_uv(uv, strength); - float2 bUV = distort_uv(uv - float2(baseStrength * chromaticAberrationScales.b, 0.0), strength); - - half4 r = sample_background(rUV); - half4 g = sample_background(gUV); - half4 b = sample_background(bUV); - - return half4(r.r, g.g, b.b, g.a); -} - -half4 apply_interaction_lighting(float2 uv, half4 color) -{ - if (isHovered > 0.5) - { - float2 center = float2(0.5, 0.0); - float dist = length(uv - center); - float mask = smoothstep_local(0.6, 0.0, dist); - color.rgb += half3(0.18, 0.2, 0.25) * half(mask); - } - - if (isActive > 0.5) - { - float2 center = float2(0.5, 0.1); - float dist = length(uv - center); - float mask = smoothstep_local(0.8, 0.0, dist); - color.rgb += half3(0.12, 0.16, 0.25) * half(mask); - } - - return color; -} - -// Имитация дополнительного размытия за счёт усреднения нескольких сэмплов -half4 apply_soft_blur(float2 uv, float blurStrength) -{ - if (blurStrength < 0.01) - { - return sample_background(uv); - } - - float radius = blurStrength * 0.012; - float2 offsets[4] = float2[4]( - float2( radius, radius), - float2(-radius, radius), - float2( radius, -radius), - float2(-radius, -radius) - ); - - half4 acc = half4(0.0); - for (int i = 0; i < 4; ++i) - { - acc += sample_background(uv + offsets[i]); - } - - return acc * 0.25; -} - -// ---- Основная функция ------------------------------------------------------- - -half4 main(float2 coord) -{ - float2 uv = coord / resolution; - - float displacementStrength = clamp(displacementScale / 100.0, 0.0, 1.5); - float aberrationStrength = clamp(aberrationIntensity, 0.0, 10.0); - - half4 baseColor = apply_soft_blur(uv, blurAmount); - half4 liquidColor = apply_chromatic_aberration(distort_uv(uv, displacementStrength), aberrationStrength); - - // Смешиваем размытие и жидкое искажение - half4 color = mix(baseColor, liquidColor, half(0.65)); - - color = adjust_saturation(color, saturation); - color = apply_interaction_lighting(uv, color); - - if (overLight > 0.5) - { - color.rgb *= half3(0.85, 0.85, 0.9); - } - - color.a = 1.0; - return color; -} diff --git a/FreshViewer/LiquidGlass/Assets/Shaders/LiquidGlassShader_Fixed.sksl b/FreshViewer/LiquidGlass/Assets/Shaders/LiquidGlassShader_Fixed.sksl deleted file mode 100644 index e255ee7..0000000 --- a/FreshViewer/LiquidGlass/Assets/Shaders/LiquidGlassShader_Fixed.sksl +++ /dev/null @@ -1,181 +0,0 @@ -// Liquid Glass SKSL Shader - 修正版本 -// 正确分离 Displacement Scale 和 Chromatic Aberration 效果 - -// Uniform 变量定义 -uniform float2 resolution; // 分辨率 -uniform float displacementScale; // 位移缩放强度 (用于位移贴图) -uniform float blurAmount; // 模糊量 -uniform float saturation; // 饱和度 (0-2 范围,1为正常) -uniform float aberrationIntensity; // 色差强度 (用于聚焦扭曲) -uniform float cornerRadius; // 圆角半径 -uniform float2 mouseOffset; // 鼠标相对偏移 (百分比) -uniform float2 globalMouse; // 全局鼠标位置 -uniform float isHovered; // 是否悬停 (0.0 或 1.0) -uniform float isActive; // 是否激活 (0.0 或 1.0) -uniform float overLight; // 是否在亮色背景上 (0.0 或 1.0) -uniform float edgeMaskOffset; // 边缘遮罩偏移 -uniform float3 chromaticAberrationScales; // 色差缩放系数 [R, G, B] -uniform float hasDisplacementMap; // 是否有位移贴图 (0.0 或 1.0) - -// 着色器输入 -uniform shader backgroundTexture; // 背景纹理 -uniform shader displacementTexture; // 位移贴图纹理 - -// 有符号距离场 - 圆角矩形 -float roundedRectSDF(float2 p, float2 b, float r) { - float2 q = abs(p) - b + r; - return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r; -} - -// 饱和度调整函数 -half4 adjustSaturation(half4 color, half saturationLevel) { - // RGB to grayscale weights (Rec. 709) - half3 gray = half3(0.299, 0.587, 0.114); - half luminance = dot(color.rgb, gray); - return half4(mix(half3(luminance), color.rgb, saturationLevel), color.a); -} - -// 位移贴图应用函数 (Displacement Scale效果) -float2 applyDisplacementMap(float2 uv) { - // 如果有位移贴图,使用位移贴图数据来控制边缘色散 - if (hasDisplacementMap > 0.5) { - // 从位移贴图采样 - half4 displacementSample = displacementTexture.eval(uv * resolution); - - // 将RGB值从[0,1]范围转换为[-1,1]范围的位移向量 - // 注意:0.5表示无位移,<0.5向负方向,>0.5向正方向 - float2 displacement = float2( - (displacementSample.r - 0.5) * 2.0, - (displacementSample.g - 0.5) * 2.0 - ); - - // 应用位移缩放 - 使用displacementScale参数 - float displacementStrength = displacementScale / 1000.0; - displacement *= displacementStrength; - - // 限制位移范围以防止过度扭曲 - displacement = clamp(displacement, float2(-0.2, -0.2), float2(0.2, 0.2)); - - // 将位移应用到原始UV坐标 - float2 distortedUV = uv + displacement; - - // 确保结果在有效范围内 - return clamp(distortedUV, float2(0.0, 0.0), float2(1.0, 1.0)); - } - else { - // 没有位移贴图时直接返回原始UV - return uv; - } -} - -// 色差聚焦扭曲效果 (Chromatic Aberration效果) -float2 applyChromaticAberrationDistortion(float2 uv, float intensityMultiplier) { - // 将坐标中心化,使 (0,0) 位于控件中心 - float2 centeredUV = uv - 0.5; - - // 扭曲矩形的半尺寸 - 根据TypeScript版本调整 - float2 rectHalfSize = float2(0.3, 0.2); - float cornerRadiusNormalized = 0.6; - - // 计算当前像素到圆角矩形边缘的距离 - float distanceToEdge = roundedRectSDF(centeredUV, rectHalfSize, cornerRadiusNormalized); - - // 使用色差强度来控制扭曲程度 - float aberrationOffset = (aberrationIntensity * intensityMultiplier) / 100.0; - - // 计算聚焦效果 - float displacement = smoothstep(0.8, 0.0, distanceToEdge - aberrationOffset); - float scaled = smoothstep(0.0, 1.0, displacement); - - // 应用聚焦变换 - float2 distortedUV = centeredUV * scaled + 0.5; - - return distortedUV; -} - -// 主色差效果函数 -half4 applyChromaticAberration(float2 uv) { - if (aberrationIntensity < 0.001) { - // 没有色差效果,只应用位移贴图 - float2 distortedUV = applyDisplacementMap(uv); - return backgroundTexture.eval(distortedUV * resolution); - } - - // 为不同颜色通道计算不同的聚焦强度 - float2 redUV = applyChromaticAberrationDistortion(uv, 1.2); // 红色最强聚焦 - float2 greenUV = applyChromaticAberrationDistortion(uv, 1.0); // 绿色标准聚焦 - float2 blueUV = applyChromaticAberrationDistortion(uv, 0.8); // 蓝色最弱聚焦 - - // 对每个颜色通道应用位移贴图 - redUV = applyDisplacementMap(redUV); - greenUV = applyDisplacementMap(greenUV); - blueUV = applyDisplacementMap(blueUV); - - // 采样各个颜色通道 - half4 redSample = backgroundTexture.eval(redUV * resolution); - half4 greenSample = backgroundTexture.eval(greenUV * resolution); - half4 blueSample = backgroundTexture.eval(blueUV * resolution); - - // 组合颜色通道,使用绿色通道的alpha作为基准 - return half4(redSample.r, greenSample.g, blueSample.b, greenSample.a); -} - -// 交互效果 (悬停和激活状态) -half4 applyInteractionEffects(half2 uv, half4 baseColor) { - if (isHovered > 0.5) { - // 悬停效果 - 径向渐变从顶部 - half2 hoverCenter = half2(0.5, 0.0); - half hoverDist = length(uv - hoverCenter); - half hoverMask = smoothstep(0.5, 0.0, hoverDist); - - half3 hoverColor = half3(1.0, 1.0, 1.0) * 0.3 * hoverMask; - baseColor.rgb = baseColor.rgb + hoverColor * 0.5; - } - - if (isActive > 0.5) { - // 激活效果 - 更强的径向渐变 - half2 activeCenter = half2(0.5, 0.0); - half activeDist = length(uv - activeCenter); - half activeMask = smoothstep(1.0, 0.0, activeDist); - - half3 activeColor = half3(1.0, 1.0, 1.0) * 0.6 * activeMask; - baseColor.rgb = baseColor.rgb + activeColor * 0.7; - } - - return baseColor; -} - -// 主着色器函数 -half4 main(float2 coord) { - half2 uv = coord / resolution; - - // 应用色差效果(包含聚焦扭曲和位移贴图) - half4 color = applyChromaticAberration(uv); - - // 应用饱和度调整 - color = adjustSaturation(color, saturation); - - // 根据overLight状态调整颜色 - if (overLight > 0.5) { - color.rgb *= 0.7; // 在亮色背景上减弱效果 - } - - // 应用交互效果 - color = applyInteractionEffects(uv, color); - - // 创建边界遮罩 - 基于距离中心的简单渐变 - float2 centeredUV = uv - 0.5; - float distanceFromCenter = length(centeredUV); - - // 创建平滑的径向遮罩,在边缘处渐变到透明 - float edgeMask = 1.0 - smoothstep(0.45, 0.5, distanceFromCenter); - - // 同时创建矩形遮罩来防止四角的液态玻璃效果 - float2 rectMask = step(abs(centeredUV), float2(0.48, 0.48)); - float combinedMask = edgeMask * rectMask.x * rectMask.y; - - // 应用组合遮罩 - color.a = color.a * combinedMask; - - return color; -} diff --git a/FreshViewer/LiquidGlass/Assets/Shaders/LiquidGlassShader_New.sksl b/FreshViewer/LiquidGlass/Assets/Shaders/LiquidGlassShader_New.sksl deleted file mode 100644 index 6a37fd8..0000000 --- a/FreshViewer/LiquidGlass/Assets/Shaders/LiquidGlassShader_New.sksl +++ /dev/null @@ -1,136 +0,0 @@ -// Liquid Glass SKSL Shader - 基于旧版本的简化实现 -// 结合旧版本的位移逻辑和新的饱和度、色差效果 - -// Uniform 变量定义 -uniform float2 resolution; // 分辨率 -uniform float displacementScale; // 位移缩放强度 -uniform float blurAmount; // 模糊量 -uniform float saturation; // 饱和度 (0-2 范围,1为正常) -uniform float aberrationIntensity; // 色差强度 -uniform float cornerRadius; // 圆角半径 -uniform float2 mouseOffset; // 鼠标相对偏移 (百分比) -uniform float2 globalMouse; // 全局鼠标位置 -uniform float isHovered; // 是否悬停 (0.0 或 1.0) -uniform float isActive; // 是否激活 (0.0 或 1.0) -uniform float overLight; // 是否在亮色背景上 (0.0 或 1.0) -uniform float edgeMaskOffset; // 边缘遮罩偏移 -uniform float3 chromaticAberrationScales; // 色差缩放系数 [R, G, B] -uniform float hasDisplacementMap; // 是否有位移贴图 (0.0 或 1.0) - -// 着色器输入 -uniform shader backgroundTexture; // 背景纹理 -uniform shader displacementTexture; // 位移贴图纹理 - -// 有符号距离场 - 圆角矩形 (来自旧版本) -float roundedRectSDF(float2 p, float2 b, float r) { - float2 q = abs(p) - b + r; - return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r; -} - -// 饱和度调整函数 -half4 adjustSaturation(half4 color, half saturationLevel) { - // RGB to grayscale weights (Rec. 709) - half3 gray = half3(0.299, 0.587, 0.114); - half luminance = dot(color.rgb, gray); - return half4(mix(half3(luminance), color.rgb, saturationLevel), color.a); -} - -// 基础液态玻璃变形 (基于旧版本逻辑) -float2 applyLiquidGlassDistortion(float2 uv) { - // 将坐标中心化,使 (0,0) 位于控件中心 - float2 centeredUV = uv - 0.5; - - // 扭曲矩形的半尺寸 - float2 rectHalfSize = float2(0.3, 0.2); - - // 扭曲形状的圆角半径 - float cornerRadiusNormalized = 0.6; - - // 计算当前像素到圆角矩形边缘的距离 - float distanceToEdge = roundedRectSDF(centeredUV, rectHalfSize, cornerRadiusNormalized); - - // 使用 smoothstep 基于距离创建位移因子 - // displacementScale uniform 控制扭曲强度 - float displacement = smoothstep(0.8, 0.0, distanceToEdge - (displacementScale / 1000.0)); - float scaled = smoothstep(0.0, 1.0, displacement); - - // 通过应用位移计算新的扭曲纹理坐标 - float2 distortedUV = centeredUV * scaled; - - // 将坐标移回 [0, 1] 范围进行采样 - return distortedUV + 0.5; -} - -// 色差效果 (简化版本) -half4 applyChromaticAberration(float2 uv) { - if (aberrationIntensity < 0.1) { - // 没有色差效果,直接采样 - float2 distortedUV = applyLiquidGlassDistortion(uv); - return backgroundTexture.eval(distortedUV * resolution); - } - - // 计算色差偏移 - float aberrationOffset = aberrationIntensity * 0.001; - - // 为每个颜色通道应用不同的位移 - float2 redUV = applyLiquidGlassDistortion(uv + float2(aberrationOffset * chromaticAberrationScales.r, 0.0)); - float2 greenUV = applyLiquidGlassDistortion(uv + float2(aberrationOffset * chromaticAberrationScales.g, 0.0)); - float2 blueUV = applyLiquidGlassDistortion(uv + float2(aberrationOffset * chromaticAberrationScales.b, 0.0)); - - // 采样各个颜色通道 - half4 redSample = backgroundTexture.eval(redUV * resolution); - half4 greenSample = backgroundTexture.eval(greenUV * resolution); - half4 blueSample = backgroundTexture.eval(blueUV * resolution); - - // 组合颜色通道 - return half4(redSample.r, greenSample.g, blueSample.b, redSample.a); -} - -// 交互效果 (悬停和激活状态) -half4 applyInteractionEffects(half2 uv, half4 baseColor) { - if (isHovered > 0.5) { - // 悬停效果 - 径向渐变从顶部 - half2 hoverCenter = half2(0.5, 0.0); - half hoverDist = length(uv - hoverCenter); - half hoverMask = smoothstep(0.5, 0.0, hoverDist); - - half3 hoverColor = half3(1.0, 1.0, 1.0) * 0.3 * hoverMask; - baseColor.rgb = baseColor.rgb + hoverColor * 0.5; - } - - if (isActive > 0.5) { - // 激活效果 - 更强的径向渐变 - half2 activeCenter = half2(0.5, 0.0); - half activeDist = length(uv - activeCenter); - half activeMask = smoothstep(1.0, 0.0, activeDist); - - half3 activeColor = half3(1.0, 1.0, 1.0) * 0.6 * activeMask; - baseColor.rgb = baseColor.rgb + activeColor * 0.7; - } - - return baseColor; -} - -// 主着色器函数 - 简化版本 -half4 main(float2 coord) { - half2 uv = coord / resolution; - - // 应用色差效果(同时包含液态玻璃变形) - half4 color = applyChromaticAberration(uv); - - // 应用饱和度调整 - color = adjustSaturation(color, saturation); - - // 根据overLight状态调整颜色 - if (overLight > 0.5) { - color.rgb *= 0.7; // 在亮色背景上减弱效果 - } - - // 应用交互效果 - color = applyInteractionEffects(uv, color); - - // 确保alpha通道正确 - color.a = 1.0; - - return color; -} diff --git a/FreshViewer/LiquidGlass/DisplacementMapManager.cs b/FreshViewer/LiquidGlass/DisplacementMapManager.cs deleted file mode 100644 index 3697335..0000000 --- a/FreshViewer/LiquidGlass/DisplacementMapManager.cs +++ /dev/null @@ -1,298 +0,0 @@ -using Avalonia; -using Avalonia.Platform; -using Avalonia.Rendering.SceneGraph; -using Avalonia.Skia; -using SkiaSharp; -using System; -using System.IO; -using System.Collections.Generic; -using System.Runtime.InteropServices; -using System.Diagnostics; - -namespace FreshViewer.UI.LiquidGlass -{ - /// - /// 位移贴图管理器 - 负责加载和管理不同模式的位移贴图 - /// - public static class DisplacementMapManager - { - private static readonly Dictionary _preloadedMaps = new(); - private static readonly Dictionary _shaderGeneratedMaps = new(); - private static readonly object _lockObject = new object(); // 添加线程安全锁 - private static bool _mapsLoaded = false; - - /// - /// 预加载所有位移贴图 - /// - public static void LoadDisplacementMaps() - { - lock (_lockObject) - { - if (_mapsLoaded) return; - _mapsLoaded = true; - - try - { - // 加载标准位移贴图 - _preloadedMaps[LiquidGlassMode.Standard] = - LoadMapFromAssets("DisplacementMaps/standard_displacement.jpeg"); - - // 加载极坐标位移贴图 - _preloadedMaps[LiquidGlassMode.Polar] = - LoadMapFromAssets("DisplacementMaps/polar_displacement.jpeg"); - - // 加载突出边缘位移贴图 - _preloadedMaps[LiquidGlassMode.Prominent] = - LoadMapFromAssets("DisplacementMaps/prominent_displacement.jpeg"); - - // Shader模式的贴图将动态生成 - _preloadedMaps[LiquidGlassMode.Shader] = null; - } - catch (Exception ex) - { - DebugLog($"[DisplacementMapManager] Error loading displacement maps: {ex.Message}"); - } - } - } - - /// - /// 从资源文件加载位移贴图 - /// - private static SKBitmap? LoadMapFromAssets(string resourcePath) - { - try - { - var assetUri = new Uri($"avares://FreshViewer/LiquidGlass/Assets/{resourcePath}"); - using var stream = AssetLoader.Open(assetUri); - return SKBitmap.Decode(stream); - } - catch (Exception ex) - { - DebugLog($"[DisplacementMapManager] Failed to load {resourcePath}: {ex.Message}"); - return null; - } - } - - /// - /// 获取指定模式的位移贴图 - /// - public static SKBitmap? GetDisplacementMap(LiquidGlassMode mode, int width = 0, int height = 0) - { - lock (_lockObject) - { - LoadDisplacementMaps(); - - if (mode == LiquidGlassMode.Shader) - { - // 为Shader模式生成动态位移贴图 - var key = $"shader_{width}x{height}"; - if (!_shaderGeneratedMaps.ContainsKey(key) && width > 0 && height > 0) - { - DebugLog($"[DisplacementMapManager] Generating shader displacement map: {width}x{height}"); - try - { - var bitmap = GenerateShaderDisplacementMap(width, height); - _shaderGeneratedMaps[key] = bitmap; - } - catch (Exception ex) - { - DebugLog($"[DisplacementMapManager] Error generating shader map: {ex.Message}"); - _shaderGeneratedMaps[key] = null; - } - } - - var result = _shaderGeneratedMaps.TryGetValue(key, out var shaderBitmap) ? shaderBitmap : null; - - // 验证位图是否有效 - if (result != null && (result.IsEmpty || result.IsNull)) - { - DebugLog("[DisplacementMapManager] Shader bitmap is invalid, removing from cache"); - _shaderGeneratedMaps.Remove(key); - result = null; - } - - DebugLog($"[DisplacementMapManager] Shader mode map: {(result != null ? "Found" : "Not found")}"); - return result; - } - - var preloadedResult = - _preloadedMaps.TryGetValue(mode, out var preloadedBitmap) ? preloadedBitmap : null; - - // 验证预加载位图是否有效 - if (preloadedResult != null && (preloadedResult.IsEmpty || preloadedResult.IsNull)) - { - DebugLog($"[DisplacementMapManager] Preloaded bitmap for {mode} is invalid"); - preloadedResult = null; - } - - DebugLog( - $"[DisplacementMapManager] {mode} mode map: {(preloadedResult != null ? "Found" : "Not found")}"); - return preloadedResult; - } - } - - /// - /// 生成Shader模式的位移贴图(对应TS版本的ShaderDisplacementGenerator) - /// - private static SKBitmap GenerateShaderDisplacementMap(int width, int height) - { - var bitmap = new SKBitmap(width, height, SKColorType.Rgba8888, SKAlphaType.Premul); - var canvas = new SKCanvas(bitmap); - - try - { - // 实现液态玻璃Shader算法 - var pixels = new uint[width * height]; - var maxScale = 0f; - var rawValues = new List<(float dx, float dy)>(); - - // 第一遍:计算所有位移值 - for (int y = 0; y < height; y++) - { - for (int x = 0; x < width; x++) - { - var uv = new LiquidVector2(x / (float)width, y / (float)height); - var pos = LiquidGlassShader(uv); - - var dx = pos.X * width - x; - var dy = pos.Y * height - y; - - maxScale = Math.Max(maxScale, Math.Max(Math.Abs(dx), Math.Abs(dy))); - rawValues.Add((dx, dy)); - } - } - - // 确保最小缩放值防止过度归一化 - if (maxScale > 0) - maxScale = Math.Max(maxScale, 1); - else - maxScale = 1; - - // 第二遍:转换为图像数据 - int rawIndex = 0; - for (int y = 0; y < height; y++) - { - for (int x = 0; x < width; x++) - { - var (dx, dy) = rawValues[rawIndex++]; - - // 边缘平滑化 - var edgeDistance = Math.Min(Math.Min(x, y), Math.Min(width - x - 1, height - y - 1)); - var edgeFactor = Math.Min(1f, edgeDistance / 2f); - - var smoothedDx = dx * edgeFactor; - var smoothedDy = dy * edgeFactor; - - var r = smoothedDx / maxScale + 0.5f; - var g = smoothedDy / maxScale + 0.5f; - - var red = (byte)Math.Max(0, Math.Min(255, r * 255)); - var green = (byte)Math.Max(0, Math.Min(255, g * 255)); - var blue = (byte)Math.Max(0, Math.Min(255, g * 255)); // 蓝色通道复制绿色以兼容SVG - var alpha = (byte)255; - - pixels[y * width + x] = (uint)((alpha << 24) | (blue << 16) | (green << 8) | red); - } - } - - // 将像素数据写入bitmap - var handle = GCHandle.Alloc(pixels, GCHandleType.Pinned); - try - { - bitmap.SetPixels(handle.AddrOfPinnedObject()); - } - finally - { - handle.Free(); - } - } - finally - { - canvas.Dispose(); - } - - return bitmap; - } - - /// - /// 液态玻璃Shader函数 (对应TS版本的liquidGlass shader) - /// - private static LiquidVector2 LiquidGlassShader(LiquidVector2 uv) - { - var ix = uv.X - 0.5f; - var iy = uv.Y - 0.5f; - var distanceToEdge = RoundedRectSDF(ix, iy, 0.3f, 0.2f, 0.6f); - var displacement = SmoothStep(0.8f, 0f, distanceToEdge - 0.15f); - var scaled = SmoothStep(0f, 1f, displacement); - return new LiquidVector2(ix * scaled + 0.5f, iy * scaled + 0.5f); - } - - /// - /// 有符号距离场 - 圆角矩形 - /// - private static float RoundedRectSDF(float x, float y, float width, float height, float radius) - { - var qx = Math.Abs(x) - width + radius; - var qy = Math.Abs(y) - height + radius; - return Math.Min(Math.Max(qx, qy), 0) + Length(Math.Max(qx, 0), Math.Max(qy, 0)) - radius; - } - - /// - /// 向量长度计算 - /// - private static float Length(float x, float y) - { - return (float)Math.Sqrt(x * x + y * y); - } - - /// - /// 平滑步进函数 (Hermite插值) - /// - private static float SmoothStep(float a, float b, float t) - { - t = Math.Max(0, Math.Min(1, (t - a) / (b - a))); - return t * t * (3 - 2 * t); - } - - /// - /// 清理资源 - /// - public static void Cleanup() - { - foreach (var bitmap in _preloadedMaps.Values) - { - bitmap?.Dispose(); - } - - _preloadedMaps.Clear(); - - foreach (var bitmap in _shaderGeneratedMaps.Values) - { - bitmap?.Dispose(); - } - - _shaderGeneratedMaps.Clear(); - - _mapsLoaded = false; - } - - [Conditional("DEBUG")] - private static void DebugLog(string message) - => Console.WriteLine(message); - } - - /// - /// LiquidVector2结构体 - 避免与系统Vector2冲突 - /// - public struct LiquidVector2 - { - public float X { get; set; } - public float Y { get; set; } - - public LiquidVector2(float x, float y) - { - X = x; - Y = y; - } - } -} diff --git a/FreshViewer/LiquidGlass/DraggableLiquidGlassCard.cs b/FreshViewer/LiquidGlass/DraggableLiquidGlassCard.cs deleted file mode 100644 index f4da6a0..0000000 --- a/FreshViewer/LiquidGlass/DraggableLiquidGlassCard.cs +++ /dev/null @@ -1,326 +0,0 @@ -using Avalonia; -using Avalonia.Controls; -using Avalonia.Input; -using Avalonia.Media; -using System; -using System.Diagnostics; - -namespace FreshViewer.UI.LiquidGlass -{ - /// - /// 可拖拽的液态玻璃卡片 - 悬浮在所有内容之上,支持鼠标拖拽移动 - /// - public class DraggableLiquidGlassCard : Control - { - #region Avalonia Properties - - /// - /// 位移缩放强度 - /// - public static readonly StyledProperty DisplacementScaleProperty = - AvaloniaProperty.Register(nameof(DisplacementScale), 20.0); - - /// - /// 模糊量 - /// - public static readonly StyledProperty BlurAmountProperty = - AvaloniaProperty.Register(nameof(BlurAmount), 0.15); - - /// - /// 饱和度 - /// - public static readonly StyledProperty SaturationProperty = - AvaloniaProperty.Register(nameof(Saturation), 120.0); - - /// - /// 色差强度 - /// - public static readonly StyledProperty AberrationIntensityProperty = - AvaloniaProperty.Register(nameof(AberrationIntensity), 7.0); - - /// - /// 圆角半径 - /// - public static readonly StyledProperty CornerRadiusProperty = - AvaloniaProperty.Register(nameof(CornerRadius), 12.0); - - /// - /// 液态玻璃效果模式 - /// - public static readonly StyledProperty ModeProperty = - AvaloniaProperty.Register(nameof(Mode), - LiquidGlassMode.Standard); - - /// - /// 是否在亮色背景上 - /// - public static readonly StyledProperty OverLightProperty = - AvaloniaProperty.Register(nameof(OverLight), false); - - /// - /// X位置 - /// - public static readonly StyledProperty XProperty = - AvaloniaProperty.Register(nameof(X), 100.0); - - /// - /// Y位置 - /// - public static readonly StyledProperty YProperty = - AvaloniaProperty.Register(nameof(Y), 100.0); - - #endregion - - #region Properties - - public double DisplacementScale - { - get => GetValue(DisplacementScaleProperty); - set => SetValue(DisplacementScaleProperty, value); - } - - public double BlurAmount - { - get => GetValue(BlurAmountProperty); - set => SetValue(BlurAmountProperty, value); - } - - public double Saturation - { - get => GetValue(SaturationProperty); - set => SetValue(SaturationProperty, value); - } - - public double AberrationIntensity - { - get => GetValue(AberrationIntensityProperty); - set => SetValue(AberrationIntensityProperty, value); - } - - public double CornerRadius - { - get => GetValue(CornerRadiusProperty); - set => SetValue(CornerRadiusProperty, value); - } - - public LiquidGlassMode Mode - { - get => GetValue(ModeProperty); - set => SetValue(ModeProperty, value); - } - - public bool OverLight - { - get => GetValue(OverLightProperty); - set => SetValue(OverLightProperty, value); - } - - public double X - { - get => GetValue(XProperty); - set => SetValue(XProperty, value); - } - - public double Y - { - get => GetValue(YProperty); - set => SetValue(YProperty, value); - } - - #endregion - - #region Drag State - - private bool _isDragging = false; - private Point _dragStartPoint; - private double _dragStartX; - private double _dragStartY; - - #endregion - - static DraggableLiquidGlassCard() - { - // 当任何属性变化时,触发重新渲染 - AffectsRender( - DisplacementScaleProperty, - BlurAmountProperty, - SaturationProperty, - AberrationIntensityProperty, - CornerRadiusProperty, - ModeProperty, - OverLightProperty - ); - - // 位置变化时触发重新布局 - AffectsArrange(XProperty, YProperty); - } - - public DraggableLiquidGlassCard() - { - // 监听所有属性变化并立即重新渲染 - PropertyChanged += OnPropertyChanged; - - // 监听DataContext变化,确保绑定生效后立即重新渲染 - PropertyChanged += (_, args) => - { - if (args.Property == DataContextProperty) - { - DebugLog("[DraggableLiquidGlassCard] DataContext changed - forcing re-render"); - // 延迟一下确保绑定完全生效 - Avalonia.Threading.Dispatcher.UIThread.Post(() => InvalidateVisual(), - Avalonia.Threading.DispatcherPriority.Background); - } - }; - - // 确保控件加载完成后立即重新渲染,以应用正确的初始参数 - Loaded += (_, _) => - { - DebugLog("[DraggableLiquidGlassCard] Loaded event - forcing re-render with current values"); - InvalidateVisual(); - }; - - // 在属性系统完全初始化后再次渲染 - AttachedToVisualTree += (_, _) => - { - DebugLog("[DraggableLiquidGlassCard] AttachedToVisualTree - forcing re-render"); - InvalidateVisual(); - }; - - // 设置默认大小 - Width = 200; - Height = 150; - - // 设置鼠标光标为手型,表示可拖拽 - Cursor = new Cursor(StandardCursorType.Hand); - } - - private void OnPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) - { - // 强制立即重新渲染 - if (e.Property == DisplacementScaleProperty || - e.Property == BlurAmountProperty || - e.Property == SaturationProperty || - e.Property == AberrationIntensityProperty || - e.Property == CornerRadiusProperty || - e.Property == ModeProperty || - e.Property == OverLightProperty) - { - DebugLog( - $"[DraggableLiquidGlassCard] Property {e.Property.Name} changed from {e.OldValue} to {e.NewValue}"); - InvalidateVisual(); - } - } - - #region Mouse Events for Dragging - - protected override void OnPointerPressed(PointerPressedEventArgs e) - { - base.OnPointerPressed(e); - - if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) - { - _isDragging = true; - _dragStartPoint = e.GetPosition(Parent as Visual); - _dragStartX = X; - _dragStartY = Y; - - // 捕获指针,确保即使鼠标移出控件范围也能继续拖拽 - e.Pointer.Capture(this); - - // 改变光标为拖拽状态 - Cursor = new Cursor(StandardCursorType.SizeAll); - - DebugLog($"[DraggableLiquidGlassCard] Drag started at ({_dragStartX}, {_dragStartY})"); - } - } - - protected override void OnPointerMoved(PointerEventArgs e) - { - base.OnPointerMoved(e); - - if (_isDragging) - { - var currentPoint = e.GetPosition(Parent as Visual); - var deltaX = currentPoint.X - _dragStartPoint.X; - var deltaY = currentPoint.Y - _dragStartPoint.Y; - - X = _dragStartX + deltaX; - Y = _dragStartY + deltaY; - - DebugLog($"[DraggableLiquidGlassCard] Dragging to ({X}, {Y})"); - } - } - - protected override void OnPointerReleased(PointerReleasedEventArgs e) - { - base.OnPointerReleased(e); - - if (_isDragging) - { - _isDragging = false; - e.Pointer.Capture(null); - - // 恢复光标为手型 - Cursor = new Cursor(StandardCursorType.Hand); - - DebugLog($"[DraggableLiquidGlassCard] Drag ended at ({X}, {Y})"); - } - } - - protected override void OnPointerCaptureLost(PointerCaptureLostEventArgs e) - { - base.OnPointerCaptureLost(e); - - if (_isDragging) - { - _isDragging = false; - Cursor = new Cursor(StandardCursorType.Hand); - DebugLog("[DraggableLiquidGlassCard] Drag cancelled"); - } - } - - #endregion - - protected override Size ArrangeOverride(Size finalSize) - { - // 使用X和Y属性来定位控件 - return base.ArrangeOverride(finalSize); - } - - [Conditional("DEBUG")] - private static void DebugLog(string message) - => Console.WriteLine(message); - - public override void Render(DrawingContext context) - { - var bounds = new Rect(0, 0, Bounds.Width, Bounds.Height); - - // 调试:输出当前参数值 - DebugLog( - $"[DraggableLiquidGlassCard] Rendering at ({X}, {Y}) with DisplacementScale={DisplacementScale}, Saturation={Saturation}"); - - // 创建液态玻璃效果参数 - 悬浮卡片模式 - var parameters = new LiquidGlassParameters - { - DisplacementScale = DisplacementScale, - BlurAmount = BlurAmount, - Saturation = Saturation, - AberrationIntensity = AberrationIntensity, - Elasticity = 0.0, // 悬浮卡片不需要弹性效果 - CornerRadius = CornerRadius, - Mode = Mode, - IsHovered = _isDragging, // 拖拽时显示悬停效果 - IsActive = false, - OverLight = OverLight, - MouseOffsetX = 0.0, // 静态位置 - MouseOffsetY = 0.0, // 静态位置 - GlobalMouseX = 0.0, - GlobalMouseY = 0.0, - ActivationZone = 0.0 // 无激活区域 - }; - - // 静态渲染,无变换 - context.Custom(new LiquidGlassDrawOperation(bounds, parameters)); - } - } -} diff --git a/FreshViewer/LiquidGlass/LiquidGlassButton.cs b/FreshViewer/LiquidGlass/LiquidGlassButton.cs deleted file mode 100644 index 16f6523..0000000 --- a/FreshViewer/LiquidGlass/LiquidGlassButton.cs +++ /dev/null @@ -1,448 +0,0 @@ -using Avalonia; -using Avalonia.Controls; -using Avalonia.Media; -using System; -using System.Diagnostics; - -namespace FreshViewer.UI.LiquidGlass -{ - /// - /// 液态玻璃按钮控件 - 完整的鼠标交互响应 - /// - public class LiquidGlassButton : Control - { - #region Avalonia Properties - - /// - /// 位移缩放强度 - /// - public static readonly StyledProperty DisplacementScaleProperty = - AvaloniaProperty.Register(nameof(DisplacementScale), 20.0); - - /// - /// 模糊量 - /// - public static readonly StyledProperty BlurAmountProperty = - AvaloniaProperty.Register(nameof(BlurAmount), 0.15); - - /// - /// 饱和度 - /// - public static readonly StyledProperty SaturationProperty = - AvaloniaProperty.Register(nameof(Saturation), 120.0); - - /// - /// 色差强度 - /// - public static readonly StyledProperty AberrationIntensityProperty = - AvaloniaProperty.Register(nameof(AberrationIntensity), 7.0); - - /// - /// 弹性系数 - /// - public static readonly StyledProperty ElasticityProperty = - AvaloniaProperty.Register(nameof(Elasticity), 0.15); - - /// - /// 圆角半径 - /// - public static readonly StyledProperty CornerRadiusProperty = - AvaloniaProperty.Register(nameof(CornerRadius), 999.0); - - /// - /// 液态玻璃效果模式 - /// - public static readonly StyledProperty ModeProperty = - AvaloniaProperty.Register(nameof(Mode), LiquidGlassMode.Standard); - - /// - /// 是否处于悬停状态 - /// - public static readonly StyledProperty IsHoveredProperty = - AvaloniaProperty.Register(nameof(IsHovered), false); - - /// - /// 是否处于激活状态 - /// - public static readonly StyledProperty IsActiveProperty = - AvaloniaProperty.Register(nameof(IsActive), false); - - /// - /// 是否在亮色背景上 - /// - public static readonly StyledProperty OverLightProperty = - AvaloniaProperty.Register(nameof(OverLight), false); - - /// - /// 鼠标相对偏移 X (百分比) - /// - public static readonly StyledProperty MouseOffsetXProperty = - AvaloniaProperty.Register(nameof(MouseOffsetX), 0.0); - - /// - /// 鼠标相对偏移 Y (百分比) - /// - public static readonly StyledProperty MouseOffsetYProperty = - AvaloniaProperty.Register(nameof(MouseOffsetY), 0.0); - - /// - /// 全局鼠标位置 X - /// - public static readonly StyledProperty GlobalMouseXProperty = - AvaloniaProperty.Register(nameof(GlobalMouseX), 0.0); - - /// - /// 全局鼠标位置 Y - /// - public static readonly StyledProperty GlobalMouseYProperty = - AvaloniaProperty.Register(nameof(GlobalMouseY), 0.0); - - /// - /// 激活区域距离 (像素) - /// - public static readonly StyledProperty ActivationZoneProperty = - AvaloniaProperty.Register(nameof(ActivationZone), 200.0); - - #endregion - - #region Properties - - public double DisplacementScale - { - get => GetValue(DisplacementScaleProperty); - set => SetValue(DisplacementScaleProperty, value); - } - - public double BlurAmount - { - get => GetValue(BlurAmountProperty); - set => SetValue(BlurAmountProperty, value); - } - - public double Saturation - { - get => GetValue(SaturationProperty); - set => SetValue(SaturationProperty, value); - } - - public double AberrationIntensity - { - get => GetValue(AberrationIntensityProperty); - set => SetValue(AberrationIntensityProperty, value); - } - - public double Elasticity - { - get => GetValue(ElasticityProperty); - set => SetValue(ElasticityProperty, value); - } - - public double CornerRadius - { - get => GetValue(CornerRadiusProperty); - set => SetValue(CornerRadiusProperty, value); - } - - public LiquidGlassMode Mode - { - get => GetValue(ModeProperty); - set => SetValue(ModeProperty, value); - } - - public bool IsHovered - { - get => GetValue(IsHoveredProperty); - set => SetValue(IsHoveredProperty, value); - } - - public bool IsActive - { - get => GetValue(IsActiveProperty); - set => SetValue(IsActiveProperty, value); - } - - public bool OverLight - { - get => GetValue(OverLightProperty); - set => SetValue(OverLightProperty, value); - } - - public double MouseOffsetX - { - get => GetValue(MouseOffsetXProperty); - set => SetValue(MouseOffsetXProperty, value); - } - - public double MouseOffsetY - { - get => GetValue(MouseOffsetYProperty); - set => SetValue(MouseOffsetYProperty, value); - } - - public double GlobalMouseX - { - get => GetValue(GlobalMouseXProperty); - set => SetValue(GlobalMouseXProperty, value); - } - - public double GlobalMouseY - { - get => GetValue(GlobalMouseYProperty); - set => SetValue(GlobalMouseYProperty, value); - } - - public double ActivationZone - { - get => GetValue(ActivationZoneProperty); - set => SetValue(ActivationZoneProperty, value); - } - - #endregion - - static LiquidGlassButton() - { - // 当任何属性变化时,触发重新渲染 - AffectsRender( - DisplacementScaleProperty, - BlurAmountProperty, - SaturationProperty, - AberrationIntensityProperty, - ElasticityProperty, - CornerRadiusProperty, - ModeProperty, - IsHoveredProperty, - IsActiveProperty, - OverLightProperty, - MouseOffsetXProperty, - MouseOffsetYProperty, - GlobalMouseXProperty, - GlobalMouseYProperty, - ActivationZoneProperty - ); - } - - public LiquidGlassButton() - { - // 监听所有属性变化并立即重新渲染 - PropertyChanged += OnPropertyChanged; - - // 监听DataContext变化,确保绑定生效后立即重新渲染 - PropertyChanged += (_, args) => - { - if (args.Property == DataContextProperty) - { - DebugLog("[LiquidGlassButton] DataContext changed - forcing re-render"); - // 延迟一下确保绑定完全生效 - Avalonia.Threading.Dispatcher.UIThread.Post(() => InvalidateVisual(), - Avalonia.Threading.DispatcherPriority.Background); - } - }; - - // 确保控件加载完成后立即重新渲染,以应用正确的初始参数 - Loaded += (_, _) => - { - DebugLog("[LiquidGlassButton] Loaded event - forcing re-render with current values"); - InvalidateVisual(); - }; - - // 在属性系统完全初始化后再次渲染 - AttachedToVisualTree += (_, _) => - { - DebugLog("[LiquidGlassButton] AttachedToVisualTree - forcing re-render"); - InvalidateVisual(); - }; - } - - private void OnPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) - { - // 强制立即重新渲染 - if (e.Property == DisplacementScaleProperty || - e.Property == BlurAmountProperty || - e.Property == SaturationProperty || - e.Property == AberrationIntensityProperty || - e.Property == ElasticityProperty || - e.Property == CornerRadiusProperty || - e.Property == ModeProperty || - e.Property == IsHoveredProperty || - e.Property == IsActiveProperty || - e.Property == OverLightProperty || - e.Property == MouseOffsetXProperty || - e.Property == MouseOffsetYProperty || - e.Property == GlobalMouseXProperty || - e.Property == GlobalMouseYProperty || - e.Property == ActivationZoneProperty) - { - DebugLog($"[LiquidGlassButton] Property {e.Property.Name} changed from {e.OldValue} to {e.NewValue}"); - InvalidateVisual(); - } - } - - #region Mouse Event Handlers - - protected override void OnPointerEntered(Avalonia.Input.PointerEventArgs e) - { - base.OnPointerEntered(e); - IsHovered = true; - UpdateMousePosition(e.GetPosition(this)); - } - - protected override void OnPointerExited(Avalonia.Input.PointerEventArgs e) - { - base.OnPointerExited(e); - IsHovered = false; - // 鼠标离开时重置位置 - MouseOffsetX = 0.0; - MouseOffsetY = 0.0; - GlobalMouseX = 0.0; - GlobalMouseY = 0.0; - } - - protected override void OnPointerMoved(Avalonia.Input.PointerEventArgs e) - { - base.OnPointerMoved(e); - UpdateMousePosition(e.GetPosition(this)); - } - - protected override void OnPointerPressed(Avalonia.Input.PointerPressedEventArgs e) - { - base.OnPointerPressed(e); - IsActive = true; - } - - protected override void OnPointerReleased(Avalonia.Input.PointerReleasedEventArgs e) - { - base.OnPointerReleased(e); - IsActive = false; - } - - #endregion - - #region Helper Methods - - /// - /// 更新鼠标位置并计算相对偏移 - /// - private void UpdateMousePosition(Point position) - { - if (Bounds.Width == 0 || Bounds.Height == 0) return; - - var centerX = Bounds.Width / 2; - var centerY = Bounds.Height / 2; - - // 计算相对偏移 (百分比) - MouseOffsetX = ((position.X - centerX) / Bounds.Width) * 100; - MouseOffsetY = ((position.Y - centerY) / Bounds.Height) * 100; - - // 设置全局鼠标位置(相对于控件) - GlobalMouseX = position.X; - GlobalMouseY = position.Y; - } - - /// - /// 计算淡入因子(基于鼠标距离元素边缘的距离) - /// - private double CalculateFadeInFactor() - { - if (GlobalMouseX == 0 && GlobalMouseY == 0) return 0; - - var centerX = Bounds.Width / 2; - var centerY = Bounds.Height / 2; - var pillWidth = Bounds.Width; - var pillHeight = Bounds.Height; - - var edgeDistanceX = Math.Max(0, Math.Abs(GlobalMouseX - centerX) - pillWidth / 2); - var edgeDistanceY = Math.Max(0, Math.Abs(GlobalMouseY - centerY) - pillHeight / 2); - var edgeDistance = Math.Sqrt(edgeDistanceX * edgeDistanceX + edgeDistanceY * edgeDistanceY); - - return edgeDistance > ActivationZone ? 0 : 1 - edgeDistance / ActivationZone; - } - - /// - /// 计算方向性缩放变换 - /// - private (double scaleX, double scaleY) CalculateDirectionalScale() - { - if (GlobalMouseX == 0 && GlobalMouseY == 0) return (1.0, 1.0); - - var centerX = Bounds.Width / 2; - var centerY = Bounds.Height / 2; - var deltaX = GlobalMouseX - centerX; - var deltaY = GlobalMouseY - centerY; - - var centerDistance = Math.Sqrt(deltaX * deltaX + deltaY * deltaY); - if (centerDistance == 0) return (1.0, 1.0); - - var normalizedX = deltaX / centerDistance; - var normalizedY = deltaY / centerDistance; - var fadeInFactor = CalculateFadeInFactor(); - var stretchIntensity = Math.Min(centerDistance / 300, 1) * Elasticity * fadeInFactor; - - // X轴缩放:左右移动时水平拉伸,上下移动时压缩 - var scaleX = 1 + Math.Abs(normalizedX) * stretchIntensity * 0.3 - - Math.Abs(normalizedY) * stretchIntensity * 0.15; - - // Y轴缩放:上下移动时垂直拉伸,左右移动时压缩 - var scaleY = 1 + Math.Abs(normalizedY) * stretchIntensity * 0.3 - - Math.Abs(normalizedX) * stretchIntensity * 0.15; - - return (Math.Max(0.8, scaleX), Math.Max(0.8, scaleY)); - } - - /// - /// 计算弹性位移 - /// - private (double x, double y) CalculateElasticTranslation() - { - var fadeInFactor = CalculateFadeInFactor(); - var centerX = Bounds.Width / 2; - var centerY = Bounds.Height / 2; - - return ( - (GlobalMouseX - centerX) * Elasticity * 0.1 * fadeInFactor, - (GlobalMouseY - centerY) * Elasticity * 0.1 * fadeInFactor - ); - } - - #endregion - - public override void Render(DrawingContext context) - { - var bounds = new Rect(0, 0, Bounds.Width, Bounds.Height); - - // 创建液态玻璃效果参数 - var parameters = new LiquidGlassParameters - { - DisplacementScale = DisplacementScale, - BlurAmount = BlurAmount, - Saturation = Saturation, - AberrationIntensity = AberrationIntensity, - Elasticity = Elasticity, - CornerRadius = CornerRadius, - Mode = Mode, - IsHovered = IsHovered, - IsActive = IsActive, - OverLight = OverLight, - MouseOffsetX = MouseOffsetX, - MouseOffsetY = MouseOffsetY, - GlobalMouseX = GlobalMouseX, - GlobalMouseY = GlobalMouseY, - ActivationZone = ActivationZone - }; - - // 计算变换 - var (scaleX, scaleY) = CalculateDirectionalScale(); - var (translateX, translateY) = CalculateElasticTranslation(); - - // 应用变换 - using (context.PushTransform(Matrix.CreateScale(scaleX, scaleY) * - Matrix.CreateTranslation(translateX, translateY))) - { - context.Custom(new LiquidGlassDrawOperation(bounds, parameters)); - } - } - - [Conditional("DEBUG")] - private static void DebugLog(string message) - => Console.WriteLine(message); - } -} diff --git a/FreshViewer/LiquidGlass/LiquidGlassCard.cs b/FreshViewer/LiquidGlass/LiquidGlassCard.cs deleted file mode 100644 index 019af6b..0000000 --- a/FreshViewer/LiquidGlass/LiquidGlassCard.cs +++ /dev/null @@ -1,386 +0,0 @@ -using Avalonia; -using Avalonia.Controls; -using Avalonia.Media; -using System; - -namespace FreshViewer.UI.LiquidGlass -{ - /// - /// 液态玻璃卡片控件 - 静态显示,不响应鼠标交互 - /// - public class LiquidGlassCard : Control - { - #region Avalonia Properties - - /// - /// 位移缩放强度 - /// - public static readonly StyledProperty DisplacementScaleProperty = - AvaloniaProperty.Register(nameof(DisplacementScale), 20.0); - - /// - /// 模糊量 - /// - public static readonly StyledProperty BlurAmountProperty = - AvaloniaProperty.Register(nameof(BlurAmount), 0.15); - - /// - /// 饱和度 - /// - public static readonly StyledProperty SaturationProperty = - AvaloniaProperty.Register(nameof(Saturation), 120.0); - - /// - /// 色差强度 - /// - public static readonly StyledProperty AberrationIntensityProperty = - AvaloniaProperty.Register(nameof(AberrationIntensity), 7.0); - - /// - /// 圆角半径 - /// - public static readonly StyledProperty CornerRadiusProperty = - AvaloniaProperty.Register(nameof(CornerRadius), 12.0); - - /// - /// 液态玻璃效果模式 - /// - public static readonly StyledProperty ModeProperty = - AvaloniaProperty.Register(nameof(Mode), LiquidGlassMode.Standard); - - /// - /// 是否在亮色背景上 - /// - public static readonly StyledProperty OverLightProperty = - AvaloniaProperty.Register(nameof(OverLight), false); - - #endregion - - #region Properties - - public double DisplacementScale - { - get => GetValue(DisplacementScaleProperty); - set => SetValue(DisplacementScaleProperty, value); - } - - public double BlurAmount - { - get => GetValue(BlurAmountProperty); - set => SetValue(BlurAmountProperty, value); - } - - public double Saturation - { - get => GetValue(SaturationProperty); - set => SetValue(SaturationProperty, value); - } - - public double AberrationIntensity - { - get => GetValue(AberrationIntensityProperty); - set => SetValue(AberrationIntensityProperty, value); - } - - public double CornerRadius - { - get => GetValue(CornerRadiusProperty); - set => SetValue(CornerRadiusProperty, value); - } - - public LiquidGlassMode Mode - { - get => GetValue(ModeProperty); - set => SetValue(ModeProperty, value); - } - - public bool OverLight - { - get => GetValue(OverLightProperty); - set => SetValue(OverLightProperty, value); - } - - #endregion - - #region Border Gloss Effect - - private Point _lastMousePosition = new Point(0, 0); - private bool _isMouseTracking = false; - - protected override void OnPointerEntered(Avalonia.Input.PointerEventArgs e) - { - base.OnPointerEntered(e); - _isMouseTracking = true; - _lastMousePosition = e.GetPosition(this); - InvalidateVisual(); - } - - protected override void OnPointerExited(Avalonia.Input.PointerEventArgs e) - { - base.OnPointerExited(e); - _isMouseTracking = false; - InvalidateVisual(); - } - - protected override void OnPointerMoved(Avalonia.Input.PointerEventArgs e) - { - base.OnPointerMoved(e); - if (_isMouseTracking) - { - _lastMousePosition = e.GetPosition(this); - InvalidateVisual(); - } - } - - #endregion - - static LiquidGlassCard() - { - // 当任何属性变化时,触发重新渲染 - AffectsRender( - DisplacementScaleProperty, - BlurAmountProperty, - SaturationProperty, - AberrationIntensityProperty, - CornerRadiusProperty, - ModeProperty, - OverLightProperty - ); - } - - public LiquidGlassCard() - { - // 监听所有属性变化并立即重新渲染 - PropertyChanged += OnPropertyChanged!; - - // 监听DataContext变化,确保绑定生效后立即重新渲染 - PropertyChanged += (sender, args) => - { - if (args.Property == DataContextProperty) - { -#if DEBUG - Console.WriteLine($"[LiquidGlassCard] DataContext changed - forcing re-render"); -#endif - // 延迟一下确保绑定完全生效 - Avalonia.Threading.Dispatcher.UIThread.Post(() => InvalidateVisual(), - Avalonia.Threading.DispatcherPriority.Background); - } - }; - - // 确保控件加载完成后立即重新渲染,以应用正确的初始参数 - Loaded += (_, _) => - { -#if DEBUG - Console.WriteLine($"[LiquidGlassCard] Loaded event - forcing re-render with current values"); -#endif - InvalidateVisual(); - }; - - // 在属性系统完全初始化后再次渲染 - AttachedToVisualTree += (_, _) => - { -#if DEBUG - Console.WriteLine($"[LiquidGlassCard] AttachedToVisualTree - forcing re-render"); -#endif - InvalidateVisual(); - }; - } - - private void OnPropertyChanged(object sender, AvaloniaPropertyChangedEventArgs e) - { - // 强制立即重新渲染 - if (e.Property == DisplacementScaleProperty || - e.Property == BlurAmountProperty || - e.Property == SaturationProperty || - e.Property == AberrationIntensityProperty || - e.Property == CornerRadiusProperty || - e.Property == ModeProperty || - e.Property == OverLightProperty) - { -#if DEBUG - Console.WriteLine( - $"[LiquidGlassCard] Property {e.Property.Name} changed from {e.OldValue} to {e.NewValue}"); -#endif - InvalidateVisual(); - } - } - - public override void Render(DrawingContext context) - { - var bounds = new Rect(0, 0, Bounds.Width, Bounds.Height); - -#if DEBUG - Console.WriteLine( - $"[LiquidGlassCard] Rendering with DisplacementScale={DisplacementScale}, Saturation={Saturation}"); -#endif - - if (!LiquidGlassPlatform.SupportsAdvancedEffects) - { - DrawFallbackCard(context, bounds); - return; - } - - // 创建液态玻璃效果参数 - 卡片模式不使用鼠标交互 - var parameters = new LiquidGlassParameters - { - DisplacementScale = DisplacementScale, - BlurAmount = BlurAmount, - Saturation = Saturation, - AberrationIntensity = AberrationIntensity, - Elasticity = 0.0, // 卡片不需要弹性效果 - CornerRadius = CornerRadius, - Mode = Mode, - IsHovered = false, // 卡片不响应悬停 - IsActive = false, // 卡片不响应激活 - OverLight = OverLight, - MouseOffsetX = 0.0, // 静态位置 - MouseOffsetY = 0.0, // 静态位置 - GlobalMouseX = 0.0, - GlobalMouseY = 0.0, - ActivationZone = 0.0 // 无激活区域 - }; - - // 渲染主要的液态玻璃效果 - context.Custom(new LiquidGlassDrawOperation(bounds, parameters)); - - // 绘制边框光泽效果 - if (_isMouseTracking) - { - DrawBorderGloss(context, bounds); - } - } - - private void DrawFallbackCard(DrawingContext context, Rect bounds) - { - if (bounds.Width <= 0 || bounds.Height <= 0) - { - return; - } - - var cornerRadius = new CornerRadius(Math.Min(CornerRadius, Math.Min(bounds.Width, bounds.Height) / 2)); - var roundedRect = new RoundedRect(bounds, cornerRadius); - - var background = new LinearGradientBrush - { - StartPoint = new RelativePoint(0.05, 0.0, RelativeUnit.Relative), - EndPoint = new RelativePoint(0.95, 1.0, RelativeUnit.Relative), - GradientStops = new GradientStops - { - new GradientStop(Color.FromArgb(0xFA, 0xFF, 0xFF, 0xFF), 0), - new GradientStop(Color.FromArgb(0xE6, 0xF1, 0xF8, 0xFF), 0.55), - new GradientStop(Color.FromArgb(0xD0, 0xE2, 0xF1, 0xFF), 1) - } - }; - - context.DrawRectangle(background, null, roundedRect); - - var highlightRect = new Rect(bounds.X + 2, bounds.Y + 2, bounds.Width - 4, bounds.Height * 0.45); - var highlight = new LinearGradientBrush - { - StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), - EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative), - GradientStops = new GradientStops - { - new GradientStop(Color.FromArgb(0xC0, 0xFF, 0xFF, 0xFF), 0), - new GradientStop(Color.FromArgb(0x2A, 0xFF, 0xFF, 0xFF), 1) - } - }; - - context.DrawRectangle(highlight, null, - new RoundedRect(highlightRect, new CornerRadius(cornerRadius.TopLeft, cornerRadius.TopRight, 0, 0))); - - var glowRect = new Rect(bounds.X + 6, bounds.Bottom - Math.Min(bounds.Height * 0.55, 90), bounds.Width - 12, - Math.Min(bounds.Height * 0.55, 90)); - var glow = new RadialGradientBrush - { - Center = new RelativePoint(0.5, 1.1, RelativeUnit.Relative), - GradientOrigin = new RelativePoint(0.5, 1.1, RelativeUnit.Relative), - RadiusX = new RelativeScalar(1.0, RelativeUnit.Relative), - RadiusY = new RelativeScalar(1.0, RelativeUnit.Relative), - GradientStops = new GradientStops - { - new GradientStop(Color.FromArgb(0x50, 0xD6, 0xF0, 0xFF), 0), - new GradientStop(Color.FromArgb(0x00, 0xD6, 0xF0, 0xFF), 1) - } - }; - - context.DrawRectangle(glow, null, - new RoundedRect(glowRect, - new CornerRadius(cornerRadius.BottomLeft, cornerRadius.BottomRight, cornerRadius.BottomRight, - cornerRadius.BottomLeft))); - - var borderPen = new Pen(new SolidColorBrush(Color.FromArgb(0x6A, 0xFC, 0xFF, 0xFF)), 1.0); - context.DrawRectangle(null, borderPen, roundedRect); - } - - private void DrawBorderGloss(DrawingContext context, Rect bounds) - { - if (bounds.Width <= 0 || bounds.Height <= 0) return; - - // 计算鼠标相对于控件中心的角度 - var centerX = bounds.Width / 2; - var centerY = bounds.Height / 2; - var deltaX = _lastMousePosition.X - centerX; - var deltaY = _lastMousePosition.Y - centerY; - var angle = Math.Atan2(deltaY, deltaX); - - // 计算光泽应该出现的边框位置 - var glossLength = Math.Min(bounds.Width, bounds.Height) * 0.3; // 光泽长度 - var glossWidth = 3.0; // 光泽宽度 - - // 根据鼠标位置决定光泽在哪条边上 - Point glossStart, glossEnd; - if (Math.Abs(deltaX) > Math.Abs(deltaY)) - { - // 光泽在左右边框 - if (deltaX > 0) // 鼠标在右侧,光泽在右边框 - { - var y = Math.Max(glossLength / 2, Math.Min(bounds.Height - glossLength / 2, _lastMousePosition.Y)); - glossStart = new Point(bounds.Width - glossWidth / 2, y - glossLength / 2); - glossEnd = new Point(bounds.Width - glossWidth / 2, y + glossLength / 2); - } - else // 鼠标在左侧,光泽在左边框 - { - var y = Math.Max(glossLength / 2, Math.Min(bounds.Height - glossLength / 2, _lastMousePosition.Y)); - glossStart = new Point(glossWidth / 2, y - glossLength / 2); - glossEnd = new Point(glossWidth / 2, y + glossLength / 2); - } - } - else - { - // 光泽在上下边框 - if (deltaY > 0) // 鼠标在下方,光泽在下边框 - { - var x = Math.Max(glossLength / 2, Math.Min(bounds.Width - glossLength / 2, _lastMousePosition.X)); - glossStart = new Point(x - glossLength / 2, bounds.Height - glossWidth / 2); - glossEnd = new Point(x + glossLength / 2, bounds.Height - glossWidth / 2); - } - else // 鼠标在上方,光泽在上边框 - { - var x = Math.Max(glossLength / 2, Math.Min(bounds.Width - glossLength / 2, _lastMousePosition.X)); - glossStart = new Point(x - glossLength / 2, glossWidth / 2); - glossEnd = new Point(x + glossLength / 2, glossWidth / 2); - } - } - - // 创建光泽渐变 - var glossBrush = new LinearGradientBrush - { - StartPoint = new RelativePoint(glossStart.X / bounds.Width, glossStart.Y / bounds.Height, - RelativeUnit.Relative), - EndPoint = new RelativePoint(glossEnd.X / bounds.Width, glossEnd.Y / bounds.Height, - RelativeUnit.Relative), - GradientStops = new GradientStops - { - new GradientStop(Colors.Transparent, 0.0), - new GradientStop(Color.FromArgb(100, 255, 255, 255), 0.5), - new GradientStop(Colors.Transparent, 1.0) - } - }; - - // 绘制光泽线条 - var pen = new Pen(glossBrush, glossWidth); - context.DrawLine(pen, glossStart, glossEnd); - } - } -} diff --git a/FreshViewer/LiquidGlass/LiquidGlassControl.cs b/FreshViewer/LiquidGlass/LiquidGlassControl.cs deleted file mode 100644 index b4cefa2..0000000 --- a/FreshViewer/LiquidGlass/LiquidGlassControl.cs +++ /dev/null @@ -1,411 +0,0 @@ -using Avalonia; -using Avalonia.Controls; -using Avalonia.Media; -using Avalonia.Platform; -using Avalonia.Rendering.SceneGraph; -using Avalonia.Skia; -using SkiaSharp; -using System; -using System.IO; -using System.Numerics; - -namespace FreshViewer.UI.LiquidGlass -{ - /// - /// 液态玻璃效果模式枚举 - /// - public enum LiquidGlassMode - { - Standard, // 标准径向位移模式 - Polar, // 极坐标位移模式 - Prominent, // 突出边缘位移模式 - Shader // 实时生成的 Shader 位移模式 - } - - /// - /// 液态玻璃控件 - 完全复刻 TypeScript 版本功能 - /// - public class LiquidGlassControl : Control - { - #region Avalonia Properties - - /// - /// 位移缩放强度 (对应 TS 版本的 displacementScale) - /// - public static readonly StyledProperty DisplacementScaleProperty = - AvaloniaProperty.Register(nameof(DisplacementScale), 70.0); - - /// - /// 模糊量 (对应 TS 版本的 blurAmount) - /// - public static readonly StyledProperty BlurAmountProperty = - AvaloniaProperty.Register(nameof(BlurAmount), 0.0625); - - /// - /// 饱和度 (对应 TS 版本的 saturation) - /// - public static readonly StyledProperty SaturationProperty = - AvaloniaProperty.Register(nameof(Saturation), 140.0); - - /// - /// 色差强度 (对应 TS 版本的 aberrationIntensity) - /// - public static readonly StyledProperty AberrationIntensityProperty = - AvaloniaProperty.Register(nameof(AberrationIntensity), 2.0); - - /// - /// 弹性系数 (对应 TS 版本的 elasticity) - /// - public static readonly StyledProperty ElasticityProperty = - AvaloniaProperty.Register(nameof(Elasticity), 0.15); - - /// - /// 圆角半径 (对应 TS 版本的 cornerRadius) - /// - public static readonly StyledProperty CornerRadiusProperty = - AvaloniaProperty.Register(nameof(CornerRadius), 999.0); - - /// - /// 液态玻璃效果模式 - /// - public static readonly StyledProperty ModeProperty = - AvaloniaProperty.Register(nameof(Mode), LiquidGlassMode.Standard); - - /// - /// 是否处于悬停状态 - /// - public static readonly StyledProperty IsHoveredProperty = - AvaloniaProperty.Register(nameof(IsHovered), false); - - /// - /// 是否处于激活状态 - /// - public static readonly StyledProperty IsActiveProperty = - AvaloniaProperty.Register(nameof(IsActive), false); - - /// - /// 是否在亮色背景上 - /// - public static readonly StyledProperty OverLightProperty = - AvaloniaProperty.Register(nameof(OverLight), false); - - /// - /// 鼠标相对偏移 X (百分比) - /// - public static readonly StyledProperty MouseOffsetXProperty = - AvaloniaProperty.Register(nameof(MouseOffsetX), 0.0); - - /// - /// 鼠标相对偏移 Y (百分比) - /// - public static readonly StyledProperty MouseOffsetYProperty = - AvaloniaProperty.Register(nameof(MouseOffsetY), 0.0); - - /// - /// 全局鼠标位置 X - /// - public static readonly StyledProperty GlobalMouseXProperty = - AvaloniaProperty.Register(nameof(GlobalMouseX), 0.0); - - /// - /// 全局鼠标位置 Y - /// - public static readonly StyledProperty GlobalMouseYProperty = - AvaloniaProperty.Register(nameof(GlobalMouseY), 0.0); - - /// - /// 激活区域距离 (像素) - /// - public static readonly StyledProperty ActivationZoneProperty = - AvaloniaProperty.Register(nameof(ActivationZone), 200.0); - - #endregion - - #region Properties - - public double DisplacementScale - { - get => GetValue(DisplacementScaleProperty); - set => SetValue(DisplacementScaleProperty, value); - } - - public double BlurAmount - { - get => GetValue(BlurAmountProperty); - set => SetValue(BlurAmountProperty, value); - } - - public double Saturation - { - get => GetValue(SaturationProperty); - set => SetValue(SaturationProperty, value); - } - - public double AberrationIntensity - { - get => GetValue(AberrationIntensityProperty); - set => SetValue(AberrationIntensityProperty, value); - } - - public double Elasticity - { - get => GetValue(ElasticityProperty); - set => SetValue(ElasticityProperty, value); - } - - public double CornerRadius - { - get => GetValue(CornerRadiusProperty); - set => SetValue(CornerRadiusProperty, value); - } - - public LiquidGlassMode Mode - { - get => GetValue(ModeProperty); - set => SetValue(ModeProperty, value); - } - - public bool IsHovered - { - get => GetValue(IsHoveredProperty); - set => SetValue(IsHoveredProperty, value); - } - - public bool IsActive - { - get => GetValue(IsActiveProperty); - set => SetValue(IsActiveProperty, value); - } - - public bool OverLight - { - get => GetValue(OverLightProperty); - set => SetValue(OverLightProperty, value); - } - - public double MouseOffsetX - { - get => GetValue(MouseOffsetXProperty); - set => SetValue(MouseOffsetXProperty, value); - } - - public double MouseOffsetY - { - get => GetValue(MouseOffsetYProperty); - set => SetValue(MouseOffsetYProperty, value); - } - - public double GlobalMouseX - { - get => GetValue(GlobalMouseXProperty); - set => SetValue(GlobalMouseXProperty, value); - } - - public double GlobalMouseY - { - get => GetValue(GlobalMouseYProperty); - set => SetValue(GlobalMouseYProperty, value); - } - - public double ActivationZone - { - get => GetValue(ActivationZoneProperty); - set => SetValue(ActivationZoneProperty, value); - } - - #endregion - - static LiquidGlassControl() - { - // 当任何属性变化时,触发重新渲染 - AffectsRender( - DisplacementScaleProperty, - BlurAmountProperty, - SaturationProperty, - AberrationIntensityProperty, - ElasticityProperty, - CornerRadiusProperty, - ModeProperty, - IsHoveredProperty, - IsActiveProperty, - OverLightProperty, - MouseOffsetXProperty, - MouseOffsetYProperty, - GlobalMouseXProperty, - GlobalMouseYProperty, - ActivationZoneProperty - ); - } - - protected override void OnPointerEntered(Avalonia.Input.PointerEventArgs e) - { - base.OnPointerEntered(e); - IsHovered = true; - UpdateMousePosition(e.GetPosition(this)); - } - - protected override void OnPointerExited(Avalonia.Input.PointerEventArgs e) - { - base.OnPointerExited(e); - IsHovered = false; - } - - protected override void OnPointerMoved(Avalonia.Input.PointerEventArgs e) - { - base.OnPointerMoved(e); - UpdateMousePosition(e.GetPosition(this)); - } - - protected override void OnPointerPressed(Avalonia.Input.PointerPressedEventArgs e) - { - base.OnPointerPressed(e); - IsActive = true; - } - - protected override void OnPointerReleased(Avalonia.Input.PointerReleasedEventArgs e) - { - base.OnPointerReleased(e); - IsActive = false; - } - - /// - /// 更新鼠标位置并计算相对偏移 - /// - private void UpdateMousePosition(Point position) - { - var centerX = Bounds.Width / 2; - var centerY = Bounds.Height / 2; - - // 计算相对偏移 (百分比) - MouseOffsetX = ((position.X - centerX) / Bounds.Width) * 100; - MouseOffsetY = ((position.Y - centerY) / Bounds.Height) * 100; - - // 设置全局鼠标位置(相对于控件) - GlobalMouseX = position.X; - GlobalMouseY = position.Y; - } - - /// - /// 计算淡入因子(基于鼠标距离元素边缘的距离) - /// - private double CalculateFadeInFactor() - { - if (GlobalMouseX == 0 && GlobalMouseY == 0) return 0; - - var centerX = Bounds.Width / 2; - var centerY = Bounds.Height / 2; - var pillWidth = Bounds.Width; - var pillHeight = Bounds.Height; - - var edgeDistanceX = Math.Max(0, Math.Abs(GlobalMouseX - centerX) - pillWidth / 2); - var edgeDistanceY = Math.Max(0, Math.Abs(GlobalMouseY - centerY) - pillHeight / 2); - var edgeDistance = Math.Sqrt(edgeDistanceX * edgeDistanceX + edgeDistanceY * edgeDistanceY); - - return edgeDistance > ActivationZone ? 0 : 1 - edgeDistance / ActivationZone; - } - - /// - /// 计算方向性缩放变换 - /// - private (double scaleX, double scaleY) CalculateDirectionalScale() - { - if (GlobalMouseX == 0 && GlobalMouseY == 0) return (1.0, 1.0); - - var centerX = Bounds.Width / 2; - var centerY = Bounds.Height / 2; - var deltaX = GlobalMouseX - centerX; - var deltaY = GlobalMouseY - centerY; - - var centerDistance = Math.Sqrt(deltaX * deltaX + deltaY * deltaY); - if (centerDistance == 0) return (1.0, 1.0); - - var normalizedX = deltaX / centerDistance; - var normalizedY = deltaY / centerDistance; - var fadeInFactor = CalculateFadeInFactor(); - var stretchIntensity = Math.Min(centerDistance / 300, 1) * Elasticity * fadeInFactor; - - // X轴缩放:左右移动时水平拉伸,上下移动时压缩 - var scaleX = 1 + Math.Abs(normalizedX) * stretchIntensity * 0.3 - - Math.Abs(normalizedY) * stretchIntensity * 0.15; - - // Y轴缩放:上下移动时垂直拉伸,左右移动时压缩 - var scaleY = 1 + Math.Abs(normalizedY) * stretchIntensity * 0.3 - - Math.Abs(normalizedX) * stretchIntensity * 0.15; - - return (Math.Max(0.8, scaleX), Math.Max(0.8, scaleY)); - } - - /// - /// 计算弹性位移 - /// - private (double x, double y) CalculateElasticTranslation() - { - var fadeInFactor = CalculateFadeInFactor(); - var centerX = Bounds.Width / 2; - var centerY = Bounds.Height / 2; - - return ( - (GlobalMouseX - centerX) * Elasticity * 0.1 * fadeInFactor, - (GlobalMouseY - centerY) * Elasticity * 0.1 * fadeInFactor - ); - } - - public override void Render(DrawingContext context) - { - var bounds = new Rect(0, 0, Bounds.Width, Bounds.Height); - - // 创建液态玻璃效果参数 - var parameters = new LiquidGlassParameters - { - DisplacementScale = DisplacementScale, - BlurAmount = BlurAmount, - Saturation = Saturation, - AberrationIntensity = AberrationIntensity, - Elasticity = Elasticity, - CornerRadius = CornerRadius, - Mode = Mode, - IsHovered = IsHovered, - IsActive = IsActive, - OverLight = OverLight, - MouseOffsetX = MouseOffsetX, - MouseOffsetY = MouseOffsetY, - GlobalMouseX = GlobalMouseX, - GlobalMouseY = GlobalMouseY, - ActivationZone = ActivationZone - }; - - // 计算变换 - var (scaleX, scaleY) = CalculateDirectionalScale(); - var (translateX, translateY) = CalculateElasticTranslation(); - - // 应用变换 - using (context.PushTransform(Matrix.CreateScale(scaleX, scaleY) * - Matrix.CreateTranslation(translateX, translateY))) - { - context.Custom(new LiquidGlassDrawOperation(bounds, parameters)); - } - } - } - - /// - /// 液态玻璃效果参数集合 - /// - public struct LiquidGlassParameters - { - public double DisplacementScale { get; set; } - public double BlurAmount { get; set; } - public double Saturation { get; set; } - public double AberrationIntensity { get; set; } - public double Elasticity { get; set; } - public double CornerRadius { get; set; } - public LiquidGlassMode Mode { get; set; } - public bool IsHovered { get; set; } - public bool IsActive { get; set; } - public bool OverLight { get; set; } - public double MouseOffsetX { get; set; } - public double MouseOffsetY { get; set; } - public double GlobalMouseX { get; set; } - public double GlobalMouseY { get; set; } - public double ActivationZone { get; set; } - } -} diff --git a/FreshViewer/LiquidGlass/LiquidGlassControlOld.cs b/FreshViewer/LiquidGlass/LiquidGlassControlOld.cs deleted file mode 100644 index 237b8a3..0000000 --- a/FreshViewer/LiquidGlass/LiquidGlassControlOld.cs +++ /dev/null @@ -1,245 +0,0 @@ -using Avalonia; -using Avalonia.Controls; -using Avalonia.Media; -using Avalonia.Platform; -using Avalonia.Rendering.SceneGraph; -using Avalonia.Skia; -using SkiaSharp; -using System; -using System.IO; - -namespace FreshViewer.UI.LiquidGlass -{ - public class LiquidGlassControlOld : Control - { - #region Avalonia Properties - - public static readonly StyledProperty RadiusProperty = - AvaloniaProperty.Register(nameof(Radius), 25.0); - - // Radius 属性控制扭曲效果的强度。 - public double Radius - { - get => GetValue(RadiusProperty); - set => SetValue(RadiusProperty, value); - } - - #endregion - - static LiquidGlassControlOld() - { - // 当 Radius 属性变化时,触发重新渲染。 - AffectsRender(RadiusProperty); - } - - /// - /// 重写标准的 Render 方法来执行所有的绘图操作。 - /// - public override void Render(DrawingContext context) - { - // 使用 Custom 方法将我们的 Skia 绘图逻辑插入到渲染管线中。 - // 关键改动:在这里直接传递 Radius 的值,而不是在渲染线程中访问它。 - if (!LiquidGlassPlatform.SupportsAdvancedEffects) - { - DrawFallback(context); - return; - } - - context.Custom(new LiquidGlassDrawOperation(new Rect(0, 0, Bounds.Width, Bounds.Height), Radius)); - - // 因为这个控件没有子元素,所以我们不再调用 base.Render()。 - } - - /// - /// 一个处理 Skia 渲染的自定义绘图操作。 - /// - private class LiquidGlassDrawOperation : ICustomDrawOperation - { - private readonly Rect _bounds; - - // 存储从 UI 线程传递过来的 Radius 值。 - private readonly double _radius; - - private static SKRuntimeEffect? _effect; - private static bool _isShaderLoaded; - - // 构造函数现在接收一个 double 类型的 radius。 - public LiquidGlassDrawOperation(Rect bounds, double radius) - { - _bounds = bounds; - _radius = radius; - } - - public void Dispose() - { - } - - public bool HitTest(Point p) => _bounds.Contains(p); - - public Rect Bounds => _bounds; - - public bool Equals(ICustomDrawOperation? other) => false; - - public void Render(ImmediateDrawingContext context) - { - var leaseFeature = context.TryGetFeature(); - if (leaseFeature is null) return; - - // 确保着色器只被加载一次。 - LoadShader(); - - using var lease = leaseFeature.Lease(); - var canvas = lease.SkCanvas; - - if (_effect is null) - { - DrawErrorHint(canvas); - } - else - { - DrawLiquidGlassEffect(canvas, lease); - } - } - - private void LoadShader() - { - if (_isShaderLoaded) return; - _isShaderLoaded = true; - - try - { - // 确保你的 .csproj 文件中包含了正确的 AvaloniaResource。 - // - var assetUri = new Uri("avares://FreshViewer/LiquidGlass/Assets/Shaders/LiquidGlassShader.sksl"); - using var stream = AssetLoader.Open(assetUri); - using var reader = new StreamReader(stream); - var shaderCode = reader.ReadToEnd(); - - _effect = SKRuntimeEffect.Create(shaderCode, out var errorText); - if (_effect == null) - { - Console.WriteLine($"[SKIA ERROR] Failed to create SKRuntimeEffect: {errorText}"); - } - } - catch (Exception ex) - { - Console.WriteLine($"[AVALONIA ERROR] Exception while loading shader: {ex.Message}"); - } - } - - private void DrawErrorHint(SKCanvas canvas) - { - using var errorPaint = new SKPaint - { - Color = new SKColor(255, 0, 0, 120), // 半透明红色 - Style = SKPaintStyle.Fill - }; - canvas.DrawRect(SKRect.Create(0, 0, (float)_bounds.Width, (float)_bounds.Height), errorPaint); - - using var textPaint = new SKPaint - { - Color = SKColors.White, - TextSize = 14, - IsAntialias = true, - TextAlign = SKTextAlign.Center - }; - canvas.DrawText("Shader Failed to Load!", (float)_bounds.Width / 2, (float)_bounds.Height / 2, - textPaint); - } - - private void DrawLiquidGlassEffect(SKCanvas canvas, ISkiaSharpApiLease lease) - { - if (_effect is null) return; - - // 获取背景的快照 - using var backgroundSnapshot = lease.SkSurface?.Snapshot(); - if (backgroundSnapshot is null) return; - - if (!canvas.TotalMatrix.TryInvert(out var currentInvertedTransform)) - return; - - using var backdropShader = SKShader.CreateImage(backgroundSnapshot, SKShaderTileMode.Clamp, - SKShaderTileMode.Clamp, currentInvertedTransform); - - var pixelSize = new PixelSize((int)_bounds.Width, (int)_bounds.Height); - var uniforms = new SKRuntimeEffectUniforms(_effect); - - // 关键改动:使用从构造函数中存储的 _radius 值。 - uniforms["radius"] = (float)_radius; - uniforms["resolution"] = new[] { (float)pixelSize.Width, (float)pixelSize.Height }; - - var children = new SKRuntimeEffectChildren(_effect) { { "content", backdropShader } }; - using var finalShader = _effect.ToShader(false, uniforms, children); - - using var paint = new SKPaint { Shader = finalShader }; - canvas.DrawRect(SKRect.Create(0, 0, (float)_bounds.Width, (float)_bounds.Height), paint); - - if (children is IDisposable disposableChildren) - { - disposableChildren.Dispose(); - } - - if (uniforms is IDisposable disposableUniforms) - { - disposableUniforms.Dispose(); - } - } - } - - private void DrawFallback(DrawingContext context) - { - var bounds = new Rect(0, 0, Bounds.Width, Bounds.Height); - if (bounds.Width <= 0 || bounds.Height <= 0) - { - return; - } - - var rounded = new RoundedRect(bounds, new CornerRadius(Radius)); - var fill = new LinearGradientBrush - { - StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), - EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative), - GradientStops = new GradientStops - { - new GradientStop(Color.FromArgb(0xF2, 0xFF, 0xFF, 0xFF), 0), - new GradientStop(Color.FromArgb(0xDC, 0xEC, 0xF7, 0xFF), 1) - } - }; - context.DrawRectangle(fill, null, rounded); - - var sheen = new LinearGradientBrush - { - StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), - EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative), - GradientStops = new GradientStops - { - new GradientStop(Color.FromArgb(0xB0, 0xFF, 0xFF, 0xFF), 0), - new GradientStop(Color.FromArgb(0x1B, 0xFF, 0xFF, 0xFF), 1) - } - }; - context.DrawRectangle(sheen, null, - new RoundedRect(new Rect(bounds.X + 4, bounds.Y + 4, bounds.Width - 8, bounds.Height * 0.55), - new CornerRadius(Radius))); - - var glow = new RadialGradientBrush - { - Center = new RelativePoint(0.5, 1.2, RelativeUnit.Relative), - GradientOrigin = new RelativePoint(0.5, 1.2, RelativeUnit.Relative), - RadiusX = new RelativeScalar(1.0, RelativeUnit.Relative), - RadiusY = new RelativeScalar(1.0, RelativeUnit.Relative), - GradientStops = new GradientStops - { - new GradientStop(Color.FromArgb(0x38, 0xD6, 0xF0, 0xFF), 0), - new GradientStop(Color.FromArgb(0x00, 0xD6, 0xF0, 0xFF), 1) - } - }; - context.DrawRectangle(glow, null, - new RoundedRect( - new Rect(bounds.X + 8, bounds.Bottom - Math.Min(bounds.Height * 0.5, 120), bounds.Width - 16, - Math.Min(bounds.Height * 0.5, 120)), new CornerRadius(Radius))); - - var borderPen = new Pen(new SolidColorBrush(Color.FromArgb(0x60, 0xD9, 0xEE, 0xFF)), 1.0); - context.DrawRectangle(null, borderPen, rounded); - } - } -} diff --git a/FreshViewer/LiquidGlass/LiquidGlassDecorator.cs b/FreshViewer/LiquidGlass/LiquidGlassDecorator.cs deleted file mode 100644 index b0ec991..0000000 --- a/FreshViewer/LiquidGlass/LiquidGlassDecorator.cs +++ /dev/null @@ -1,265 +0,0 @@ -using Avalonia; -using Avalonia.Controls; -using Avalonia.Media; -using Avalonia.Media.Imaging; -using Avalonia.Platform; -using Avalonia.Rendering; -using Avalonia.Rendering.SceneGraph; -using Avalonia.Skia; -using Avalonia.VisualTree; -using SkiaSharp; -using System; -using System.IO; - -namespace FreshViewer.UI.LiquidGlass -{ - // 注意:此控件现在应用的是“液态玻璃”扭曲效果,而不是之前的毛玻璃模糊。 - public class LiquidGlassDecorator : Decorator - { - #region Re-entrancy Guard - - // 一个防止 Render 方法被递归调用的标志。 - private bool _isRendering; - - #endregion - - #region Avalonia Properties - - public static readonly StyledProperty RadiusProperty = - AvaloniaProperty.Register(nameof(Radius), 25.0); - - // Radius 属性现在控制扭曲效果。 - public double Radius - { - get => GetValue(RadiusProperty); - set => SetValue(RadiusProperty, value); - } - - #endregion - - static LiquidGlassDecorator() - { - // 当 Radius 属性变化时,触发重绘。 - AffectsRender(RadiusProperty); - } - - /// - /// 重写标准的 Render 方法来执行所有绘制操作。 - /// - public override void Render(DrawingContext context) - { - // 重入守卫:如果我们已经在渲染中,则不开始新的渲染。 - // 这会打破递归循环。 - if (_isRendering) - return; - - try - { - _isRendering = true; - - // ВАЖНО: сначала рисуем эффект стекла по снимку уже отрисованного фона, - // затем поверх — дочерний контент. Иначе непрозрачный шейдер перекрывает UI. - if (LiquidGlassPlatform.SupportsAdvancedEffects) - { - // Поверх детей накладываем жидкостный шейдер. - context.Custom(new LiquidGlassDrawOperation(new Rect(0, 0, Bounds.Width, Bounds.Height), this)); - } - else - { - // Если шейдеры недоступны — рисуем прозрачное стекло поверх. - DrawFallbackOverlay(context); - } - - // Теперь выводим дочерние элементы поверх стекла - base.Render(context); - } - finally - { - _isRendering = false; - } - } - - private void DrawFallbackOverlay(DrawingContext context) - { - var bounds = new Rect(0, 0, Bounds.Width, Bounds.Height); - if (bounds.Width <= 0 || bounds.Height <= 0) - { - return; - } - - var rounded = new RoundedRect(bounds, new CornerRadius(Radius)); - - var backdrop = new LinearGradientBrush - { - StartPoint = new RelativePoint(0.05, 0.0, RelativeUnit.Relative), - EndPoint = new RelativePoint(0.95, 1.0, RelativeUnit.Relative), - GradientStops = new GradientStops - { - new GradientStop(Color.FromArgb(0xF5, 0xFF, 0xFF, 0xFF), 0), - new GradientStop(Color.FromArgb(0xE2, 0xF3, 0xFF, 0xFF), 0.5), - new GradientStop(Color.FromArgb(0xCC, 0xE6, 0xF5, 0xFF), 1) - } - }; - - context.DrawRectangle(backdrop, null, rounded); - - var topHighlight = new LinearGradientBrush - { - StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), - EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative), - GradientStops = new GradientStops - { - new GradientStop(Color.FromArgb(0xBE, 0xFF, 0xFF, 0xFF), 0), - new GradientStop(Color.FromArgb(0x25, 0xFF, 0xFF, 0xFF), 1) - } - }; - - context.DrawRectangle(topHighlight, null, - new RoundedRect(new Rect(bounds.X + 6, bounds.Y + 6, bounds.Width - 12, bounds.Height * 0.5), - new CornerRadius(Radius))); - - var bottomGlowRect = new Rect(bounds.X + 10, bounds.Bottom - Math.Min(bounds.Height * 0.5, 120), - bounds.Width - 20, Math.Min(bounds.Height * 0.5, 120)); - var bottomGlow = new RadialGradientBrush - { - Center = new RelativePoint(0.5, 1.15, RelativeUnit.Relative), - GradientOrigin = new RelativePoint(0.5, 1.15, RelativeUnit.Relative), - RadiusX = new RelativeScalar(1.0, RelativeUnit.Relative), - RadiusY = new RelativeScalar(1.0, RelativeUnit.Relative), - GradientStops = new GradientStops - { - new GradientStop(Color.FromArgb(0x45, 0xD6, 0xF0, 0xFF), 0), - new GradientStop(Color.FromArgb(0x00, 0xD6, 0xF0, 0xFF), 1) - } - }; - - context.DrawRectangle(bottomGlow, null, new RoundedRect(bottomGlowRect, new CornerRadius(Radius))); - - var borderPen = new Pen(new SolidColorBrush(Color.FromArgb(0x5A, 0xC4, 0xD8, 0xFF)), 1.0); - context.DrawRectangle(null, borderPen, rounded); - } - - /// - /// 处理 Skia 渲染的自定义绘制操作。 - /// - private class LiquidGlassDrawOperation : ICustomDrawOperation - { - private readonly Rect _bounds; - private readonly LiquidGlassDecorator _owner; - - private static SKRuntimeEffect? _effect; - private static bool _isShaderLoaded; - - public LiquidGlassDrawOperation(Rect bounds, LiquidGlassDecorator owner) - { - _bounds = bounds; - _owner = owner; - } - - public void Dispose() - { - } - - public bool HitTest(Point p) => _bounds.Contains(p); - - public Rect Bounds => _bounds; - - public bool Equals(ICustomDrawOperation? other) => false; - - public void Render(ImmediateDrawingContext context) - { - var leaseFeature = context.TryGetFeature(); - if (leaseFeature is null) return; - - LoadShader(); - - using var lease = leaseFeature.Lease(); - var canvas = lease.SkCanvas; - - if (_effect is null) - { - DrawErrorHint(canvas); - } - else - { - DrawLiquidGlassEffect(canvas, lease); - } - } - - private void LoadShader() - { - if (_isShaderLoaded) return; - _isShaderLoaded = true; - - try - { - // 更新为加载新的液态玻璃着色器。 - var assetUri = new Uri("avares://FreshViewer/LiquidGlass/Assets/Shaders/LiquidGlassShader.sksl"); - using var stream = AssetLoader.Open(assetUri); - using var reader = new StreamReader(stream); - var shaderCode = reader.ReadToEnd(); - - _effect = SKRuntimeEffect.Create(shaderCode, out var errorText); - if (_effect == null) - { - Console.WriteLine($"创建 SKRuntimeEffect 失败: {errorText}"); - } - } - catch (Exception ex) - { - Console.WriteLine($"加载着色器时发生异常: {ex.Message}"); - } - } - - private void DrawErrorHint(SKCanvas canvas) - { - using var errorPaint = new SKPaint - { - Color = new SKColor(255, 0, 0, 120), // 半透明红色 - Style = SKPaintStyle.Fill - }; - canvas.DrawRect(SKRect.Create(0, 0, (float)_bounds.Width, (float)_bounds.Height), errorPaint); - - using var textPaint = new SKPaint - { - Color = SKColors.White, - TextSize = 14, - IsAntialias = true, - TextAlign = SKTextAlign.Center - }; - canvas.DrawText("着色器加载失败!", (float)_bounds.Width / 2, (float)_bounds.Height / 2, textPaint); - } - - private void DrawLiquidGlassEffect(SKCanvas canvas, ISkiaSharpApiLease lease) - { - if (_effect is null) return; - - var pixelWidth = (int)Math.Ceiling(_bounds.Width); - var pixelHeight = (int)Math.Ceiling(_bounds.Height); - if (pixelWidth <= 0 || pixelHeight <= 0) return; - - using var snapshot = lease.SkSurface?.Snapshot(); - if (snapshot is null) return; - - if (!canvas.TotalMatrix.TryInvert(out var inverse)) - { - return; - } - - using var shaderImage = snapshot.ToShader(SKShaderTileMode.Clamp, SKShaderTileMode.Clamp, inverse); - - var uniforms = new SKRuntimeEffectUniforms(_effect) - { - ["radius"] = (float)_owner.Radius, - ["resolution"] = new[] { (float)pixelWidth, (float)pixelHeight } - }; - - var children = new SKRuntimeEffectChildren(_effect) { { "content", shaderImage } }; - using var finalShader = _effect.ToShader(false, uniforms, children); - - using var paint = new SKPaint { Shader = finalShader }; - canvas.DrawRect(SKRect.Create(0, 0, pixelWidth, pixelHeight), paint); - } - } - } -} diff --git a/FreshViewer/LiquidGlass/LiquidGlassDrawOperation.cs b/FreshViewer/LiquidGlass/LiquidGlassDrawOperation.cs deleted file mode 100644 index 98d4756..0000000 --- a/FreshViewer/LiquidGlass/LiquidGlassDrawOperation.cs +++ /dev/null @@ -1,453 +0,0 @@ -using Avalonia; -using Avalonia.Platform; -using Avalonia.Rendering.SceneGraph; -using Avalonia.Skia; -using SkiaSharp; -using System; -using System.IO; -using Avalonia.Media; -using System.Diagnostics; - -namespace FreshViewer.UI.LiquidGlass -{ - /// - /// 液态玻璃绘制操作 - 处理 Skia 渲染的自定义绘图操作 - /// - internal class LiquidGlassDrawOperation : ICustomDrawOperation - { - private readonly Rect _bounds; - private readonly LiquidGlassParameters _parameters; - - private static SKRuntimeEffect? _liquidGlassEffect; - private static bool _isShaderLoaded; - - public LiquidGlassDrawOperation(Rect bounds, LiquidGlassParameters parameters) - { - _bounds = bounds; - _parameters = parameters; - } - - public void Dispose() - { - } - - public bool HitTest(Point p) => _bounds.Contains(p); - - public Rect Bounds => _bounds; - - public bool Equals(ICustomDrawOperation? other) => false; - - public void Render(ImmediateDrawingContext context) - { - var leaseFeature = context.TryGetFeature(); - if (leaseFeature is null) return; - - // 确保位移贴图已加载 - DisplacementMapManager.LoadDisplacementMaps(); - - // 确保着色器只被加载一次 - LoadShader(); - - using var lease = leaseFeature.Lease(); - var canvas = lease.SkCanvas; - - if (_liquidGlassEffect is null) - { - DrawErrorHint(canvas); - } - else - { - DrawLiquidGlassEffect(canvas, lease); - } - } - - /// - /// 加载液态玻璃着色器 - /// - private void LoadShader() - { - if (_isShaderLoaded) return; - _isShaderLoaded = true; - - try - { - // 加载SKSL着色器代码 - var assetUri = new Uri("avares://FreshViewer/LiquidGlass/Assets/Shaders/LiquidGlassShader.sksl"); - using var stream = AssetLoader.Open(assetUri); - using var reader = new StreamReader(stream); - var shaderCode = reader.ReadToEnd(); - - _liquidGlassEffect = SKRuntimeEffect.Create(shaderCode, out var errorText); - if (_liquidGlassEffect == null) - { - DebugLog($"[SKIA ERROR] Failed to create liquid glass effect: {errorText}"); - } - } - catch (Exception ex) - { - DebugLog($"[AVALONIA ERROR] Exception while loading liquid glass shader: {ex.Message}"); - } - } - - /// - /// 绘制错误提示 - /// - private void DrawErrorHint(SKCanvas canvas) - { - using var errorPaint = new SKPaint - { - Color = new SKColor(255, 0, 0, 120), // 半透明红色 - Style = SKPaintStyle.Fill - }; - canvas.DrawRect(SKRect.Create(0, 0, (float)_bounds.Width, (float)_bounds.Height), errorPaint); - - using var textPaint = new SKPaint - { - Color = SKColors.White, - TextSize = 14, - IsAntialias = true, - TextAlign = SKTextAlign.Center - }; - canvas.DrawText("Liquid Glass Shader Failed to Load!", - (float)_bounds.Width / 2, (float)_bounds.Height / 2, textPaint); - } - - /// - /// 绘制液态玻璃效果 - /// - private void DrawLiquidGlassEffect(SKCanvas canvas, ISkiaSharpApiLease lease) - { - if (_liquidGlassEffect is null) return; - - // 获取背景快照 - using var backgroundSnapshot = lease.SkSurface?.Snapshot(); - if (backgroundSnapshot is null) return; - - if (!canvas.TotalMatrix.TryInvert(out var currentInvertedTransform)) - return; - - // 创建背景着色器 - using var backdropShader = SKShader.CreateImage( - backgroundSnapshot, - SKShaderTileMode.Clamp, - SKShaderTileMode.Clamp, - currentInvertedTransform); - - // 获取位移贴图 - var displacementMap = DisplacementMapManager.GetDisplacementMap( - _parameters.Mode, - (int)_bounds.Width, - (int)_bounds.Height); - - SKShader? displacementShader = null; - if (displacementMap != null && !displacementMap.IsEmpty && !displacementMap.IsNull) - { - try - { - // 额外验证位图数据完整性 - var info = displacementMap.Info; - if (info.Width > 0 && info.Height > 0 && info.BytesSize > 0) - { - displacementShader = SKShader.CreateBitmap( - displacementMap, - SKShaderTileMode.Clamp, - SKShaderTileMode.Clamp); - } - else - { - DebugLog("[LiquidGlassDrawOperation] Displacement bitmap has invalid dimensions or size"); - } - } - catch (Exception ex) - { - DebugLog($"[LiquidGlassDrawOperation] Error creating displacement shader: {ex.Message}"); - displacementShader = null; - } - } - else - { - DebugLog($"[LiquidGlassDrawOperation] No valid displacement map for mode: {_parameters.Mode}"); - } - - // 设置 Uniform 变量 - var uniforms = new SKRuntimeEffectUniforms(_liquidGlassEffect); - - // 基础参数 - 修复参数范围和计算 - uniforms["resolution"] = new[] { (float)_bounds.Width, (float)_bounds.Height }; - - var displacementValue = (float)(_parameters.DisplacementScale * GetModeScale()); - uniforms["displacementScale"] = displacementValue; - DebugLog( - $"[LiquidGlassDrawOperation] DisplacementScale: {_parameters.DisplacementScale} -> {displacementValue}"); - - uniforms["blurAmount"] = (float)_parameters.BlurAmount; // 直接传递,不要额外缩放 - - // 修复饱和度计算 - TypeScript版本使用0-2范围,1为正常 - uniforms["saturation"] = (float)(_parameters.Saturation / 100.0); // 140/100 = 1.4 - - uniforms["aberrationIntensity"] = (float)_parameters.AberrationIntensity; - uniforms["cornerRadius"] = (float)_parameters.CornerRadius; - - // 鼠标交互参数 - 修复坐标传递 - uniforms["mouseOffset"] = new[] - { (float)(_parameters.MouseOffsetX / 100.0), (float)(_parameters.MouseOffsetY / 100.0) }; - uniforms["globalMouse"] = new[] { (float)_parameters.GlobalMouseX, (float)_parameters.GlobalMouseY }; - - // 状态参数 - uniforms["isHovered"] = _parameters.IsHovered ? 1.0f : 0.0f; - uniforms["isActive"] = _parameters.IsActive ? 1.0f : 0.0f; - uniforms["overLight"] = _parameters.OverLight ? 1.0f : 0.0f; - - // 修复边缘遮罩参数计算 - var edgeMaskOffset = (float)Math.Max(0.1, (100.0 - _parameters.AberrationIntensity * 10.0) / 100.0); - uniforms["edgeMaskOffset"] = edgeMaskOffset; - - // 修复色差偏移计算 - 根据TypeScript版本调整 - var baseScale = _parameters.Mode == LiquidGlassMode.Shader ? 1.0f : 1.0f; - var redScale = baseScale; - var greenScale = baseScale - (float)_parameters.AberrationIntensity * 0.002f; - var blueScale = baseScale - (float)_parameters.AberrationIntensity * 0.004f; - - uniforms["chromaticAberrationScales"] = new[] { redScale, greenScale, blueScale }; - - // 设置纹理 - var children = new SKRuntimeEffectChildren(_liquidGlassEffect); - children["backgroundTexture"] = backdropShader; - - if (displacementShader != null) - { - children["displacementTexture"] = displacementShader; - uniforms["hasDisplacementMap"] = 1.0f; - } - else - { - uniforms["hasDisplacementMap"] = 0.0f; - } - - // 创建最终着色器 - try - { - using var finalShader = _liquidGlassEffect.ToShader(false, uniforms, children); - if (finalShader == null) - { - DebugLog("[LiquidGlassDrawOperation] Failed to create final shader"); - return; - } - - using var paint = new SKPaint { Shader = finalShader, IsAntialias = true }; - - // 应用背景模糊效果 - 修复模糊计算 - if (_parameters.BlurAmount > 0.001) // 只有真正需要模糊时才应用 - { - // 使用更线性和可控的模糊半径计算 - var blurRadius = (float)(_parameters.BlurAmount * 20.0); // 调整缩放因子 - - // 根据OverLight状态增加基础模糊(可选) - if (_parameters.OverLight) - { - blurRadius += 2.0f; // 在亮色背景上增加轻微的基础模糊 - } - - // 确保模糊半径在合理范围内 - blurRadius = Math.Max(0.1f, Math.Min(blurRadius, 50.0f)); - - using var blurFilter = SKImageFilter.CreateBlur(blurRadius, blurRadius); - paint.ImageFilter = blurFilter; - } - - // 绘制带圆角的效果 - 关键修复:只在圆角矩形内绘制 - var cornerRadius = - (float)Math.Min(_parameters.CornerRadius, Math.Min(_bounds.Width, _bounds.Height) / 2); - var rect = SKRect.Create(0, 0, (float)_bounds.Width, (float)_bounds.Height); - - // 创建圆角矩形路径 - using var path = new SKPath(); - path.AddRoundRect(rect, cornerRadius, cornerRadius); - - // 裁剪到圆角矩形并绘制 - canvas.Save(); - canvas.ClipPath(path, SKClipOperation.Intersect, true); - canvas.DrawRect(rect, paint); - canvas.Restore(); - } - catch (Exception ex) - { - DebugLog($"[LiquidGlassDrawOperation] Error creating or using shader: {ex.Message}"); - } - finally - { - if (children is IDisposable disposableChildren) - { - disposableChildren.Dispose(); - } - - if (uniforms is IDisposable disposableUniforms) - { - disposableUniforms.Dispose(); - } - } - - // 绘制边框效果 - DrawBorderEffects(canvas); - - // 绘制悬停和激活状态效果 - if (_parameters.IsHovered || _parameters.IsActive) - { - DrawInteractionEffects(canvas); - } - - // 清理位移着色器 - displacementShader?.Dispose(); - } - - /// - /// 获取模式缩放系数 - 修复版本 - /// - private double GetModeScale() - { - // 所有模式都使用正向缩放,通过Shader内部逻辑区分 - return _parameters.Mode switch - { - LiquidGlassMode.Standard => 1.0, - LiquidGlassMode.Polar => 1.2, // Polar模式稍微增强效果 - LiquidGlassMode.Prominent => 1.5, // Prominent模式显著增强效果 - LiquidGlassMode.Shader => 1.0, - _ => 1.0 - }; - } - - /// - /// 绘制边框效果 (对应TS版本的多层边框) - /// - private void DrawBorderEffects(SKCanvas canvas) - { - var cornerRadius = (float)Math.Min(_parameters.CornerRadius, Math.Min(_bounds.Width, _bounds.Height) / 2); - var rect = SKRect.Create(1.5f, 1.5f, (float)_bounds.Width - 3f, (float)_bounds.Height - 3f); - - // 第一层边框 (Screen blend mode) - var angle = 135f + (float)_parameters.MouseOffsetX * 1.2f; - var startOpacity = 0.12f + Math.Abs((float)_parameters.MouseOffsetX) * 0.008f; - var midOpacity = 0.4f + Math.Abs((float)_parameters.MouseOffsetX) * 0.012f; - var startPos = Math.Max(10f, 33f + (float)_parameters.MouseOffsetY * 0.3f) / 100f; - var endPos = Math.Min(90f, 66f + (float)_parameters.MouseOffsetY * 0.4f) / 100f; - - using var borderPaint1 = new SKPaint - { - Style = SKPaintStyle.Stroke, - StrokeWidth = 1f, - IsAntialias = true, - BlendMode = SKBlendMode.Screen - }; - - var colors = new SKColor[] - { - new SKColor(255, 255, 255, 0), - new SKColor(255, 255, 255, (byte)(startOpacity * 255)), - new SKColor(255, 255, 255, (byte)(midOpacity * 255)), - new SKColor(255, 255, 255, 0) - }; - - var positions = new float[] { 0f, startPos, endPos, 1f }; - - using var gradient1 = SKShader.CreateLinearGradient( - new SKPoint(0, 0), - new SKPoint((float)_bounds.Width, (float)_bounds.Height), - colors, - positions, - SKShaderTileMode.Clamp); - - borderPaint1.Shader = gradient1; - canvas.DrawRoundRect(rect, cornerRadius, cornerRadius, borderPaint1); - - // 第二层边框 (Overlay blend mode) - using var borderPaint2 = new SKPaint - { - Style = SKPaintStyle.Stroke, - StrokeWidth = 1f, - IsAntialias = true, - BlendMode = SKBlendMode.Overlay - }; - - var colors2 = new SKColor[] - { - new SKColor(255, 255, 255, 0), - new SKColor(255, 255, 255, (byte)((startOpacity + 0.2f) * 255)), - new SKColor(255, 255, 255, (byte)((midOpacity + 0.2f) * 255)), - new SKColor(255, 255, 255, 0) - }; - - using var gradient2 = SKShader.CreateLinearGradient( - new SKPoint(0, 0), - new SKPoint((float)_bounds.Width, (float)_bounds.Height), - colors2, - positions, - SKShaderTileMode.Clamp); - - borderPaint2.Shader = gradient2; - canvas.DrawRoundRect(rect, cornerRadius, cornerRadius, borderPaint2); - } - - /// - /// 绘制交互效果 (悬停和激活状态) - /// - private void DrawInteractionEffects(SKCanvas canvas) - { - var cornerRadius = (float)Math.Min(_parameters.CornerRadius, Math.Min(_bounds.Width, _bounds.Height) / 2); - var rect = SKRect.Create(0, 0, (float)_bounds.Width, (float)_bounds.Height); - - if (_parameters.IsHovered) - { - // 悬停效果 - using var hoverPaint = new SKPaint - { - Style = SKPaintStyle.Fill, - IsAntialias = true, - BlendMode = SKBlendMode.Overlay - }; - - using var hoverGradient = SKShader.CreateRadialGradient( - new SKPoint((float)_bounds.Width / 2, 0), - (float)_bounds.Width / 2, - new SKColor[] - { - new SKColor(255, 255, 255, 127), // 50% opacity - new SKColor(255, 255, 255, 0) - }, - new float[] { 0f, 0.5f }, - SKShaderTileMode.Clamp); - - hoverPaint.Shader = hoverGradient; - canvas.DrawRoundRect(rect, cornerRadius, cornerRadius, hoverPaint); - } - - if (_parameters.IsActive) - { - // 激活效果 - using var activePaint = new SKPaint - { - Style = SKPaintStyle.Fill, - IsAntialias = true, - BlendMode = SKBlendMode.Overlay - }; - - using var activeGradient = SKShader.CreateRadialGradient( - new SKPoint((float)_bounds.Width / 2, 0), - (float)_bounds.Width, - new SKColor[] - { - new SKColor(255, 255, 255, 204), // 80% opacity - new SKColor(255, 255, 255, 0) - }, - new float[] { 0f, 1f }, - SKShaderTileMode.Clamp); - - activePaint.Shader = activeGradient; - canvas.DrawRoundRect(rect, cornerRadius, cornerRadius, activePaint); - } - } - - [Conditional("DEBUG")] - private static void DebugLog(string message) - => Console.WriteLine(message); - } -} diff --git a/FreshViewer/LiquidGlass/LiquidGlassPlatform.cs b/FreshViewer/LiquidGlass/LiquidGlassPlatform.cs deleted file mode 100644 index 5a494bb..0000000 --- a/FreshViewer/LiquidGlass/LiquidGlassPlatform.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System; - -namespace FreshViewer.UI.LiquidGlass -{ - /// - /// Определяет, доступен ли полный набор эффектов Liquid Glass. - /// Теперь FreshViewer поддерживает только Windows, но оставляем переменную - /// окружения для принудительного включения/отключения эффекта: - /// FRESHVIEWER_FORCE_LIQUID_GLASS = "0"/"false" или "1"/"true". - /// - internal static class LiquidGlassPlatform - { - public static bool SupportsAdvancedEffects { get; } = EvaluateSupport(); - - private static bool EvaluateSupport() - { - var env = Environment.GetEnvironmentVariable("FRESHVIEWER_FORCE_LIQUID_GLASS"); - if (!string.IsNullOrWhiteSpace(env)) - { - if (env == "1" || env.Equals("true", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - if (env == "0" || env.Equals("false", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - } - - if (!OperatingSystem.IsWindows()) - { - return false; - } - - return true; - } - } -} diff --git a/FreshViewer/LiquidGlass/ShaderDebugger.cs b/FreshViewer/LiquidGlass/ShaderDebugger.cs deleted file mode 100644 index 51d4723..0000000 --- a/FreshViewer/LiquidGlass/ShaderDebugger.cs +++ /dev/null @@ -1,125 +0,0 @@ -using Avalonia; -using Avalonia.Platform; -using SkiaSharp; -using System; -using System.IO; - -namespace FreshViewer.UI.LiquidGlass -{ - /// - /// Shader 调试工具 - 检查 Shader 是否正确加载和参数是否正确传递 - /// - public static class ShaderDebugger - { - public static void TestShaderLoading() - { - Console.WriteLine("[ShaderDebugger] 开始测试 Shader 加载..."); - - try - { - // 尝试加载 Shader 文件 - var assetUri = new Uri("avares://FreshViewer/LiquidGlass/Assets/Shaders/LiquidGlassShader.sksl"); - using var stream = AssetLoader.Open(assetUri); - using var reader = new StreamReader(stream); - var shaderCode = reader.ReadToEnd(); - - Console.WriteLine($"[ShaderDebugger] Shader 文件大小: {shaderCode.Length} 字符"); - Console.WriteLine( - $"[ShaderDebugger] Shader 前100字符: {shaderCode.Substring(0, Math.Min(100, shaderCode.Length))}"); - - // 尝试编译 Shader - var effect = SKRuntimeEffect.Create(shaderCode, out var errorText); - if (effect != null) - { - Console.WriteLine("[ShaderDebugger] ✅ Shader 编译成功!"); - - // 检查 Uniform 变量 - var uniformSize = effect.UniformSize; - Console.WriteLine($"[ShaderDebugger] Uniform 大小: {uniformSize} 字节"); - - Console.WriteLine($"[ShaderDebugger] 尝试创建 Uniforms 对象..."); - var uniforms = new SKRuntimeEffectUniforms(effect); - Console.WriteLine($"[ShaderDebugger] ✅ Uniforms 对象创建成功"); - - Console.WriteLine($"[ShaderDebugger] 尝试创建 Children 对象..."); - var children = new SKRuntimeEffectChildren(effect); - Console.WriteLine($"[ShaderDebugger] ✅ Children 对象创建成功"); - - if (uniforms is IDisposable disposableUniforms) - { - disposableUniforms.Dispose(); - } - - if (children is IDisposable disposableChildren) - { - disposableChildren.Dispose(); - } - - effect.Dispose(); - } - else - { - Console.WriteLine($"[ShaderDebugger] ❌ Shader 编译失败: {errorText}"); - } - } - catch (Exception ex) - { - Console.WriteLine($"[ShaderDebugger] ❌ 异常: {ex.Message}"); - Console.WriteLine($"[ShaderDebugger] 堆栈跟踪: {ex.StackTrace}"); - } - } - - public static void TestDisplacementMaps() - { - Console.WriteLine("[ShaderDebugger] 开始测试位移贴图加载..."); - - DisplacementMapManager.LoadDisplacementMaps(); - - var modes = new[] - { LiquidGlassMode.Standard, LiquidGlassMode.Polar, LiquidGlassMode.Prominent, LiquidGlassMode.Shader }; - - foreach (var mode in modes) - { - var map = DisplacementMapManager.GetDisplacementMap(mode, 256, 256); - if (map != null) - { - Console.WriteLine($"[ShaderDebugger] ✅ {mode} 位移贴图加载成功 ({map.Width}x{map.Height})"); - } - else - { - Console.WriteLine($"[ShaderDebugger] ❌ {mode} 位移贴图加载失败"); - } - } - } - - public static void TestParameters() - { - Console.WriteLine("[ShaderDebugger] 开始测试参数传递..."); - - var parameters = new LiquidGlassParameters - { - DisplacementScale = 70.0, - BlurAmount = 0.0625, - Saturation = 140.0, - AberrationIntensity = 2.0, - Elasticity = 0.15, - CornerRadius = 25.0, - Mode = LiquidGlassMode.Standard, - IsHovered = false, - IsActive = false, - OverLight = false, - MouseOffsetX = 0.0, - MouseOffsetY = 0.0, - GlobalMouseX = 0.0, - GlobalMouseY = 0.0, - ActivationZone = 200.0 - }; - - Console.WriteLine($"[ShaderDebugger] DisplacementScale: {parameters.DisplacementScale}"); - Console.WriteLine($"[ShaderDebugger] BlurAmount: {parameters.BlurAmount}"); - Console.WriteLine($"[ShaderDebugger] Saturation: {parameters.Saturation}"); - Console.WriteLine($"[ShaderDebugger] AberrationIntensity: {parameters.AberrationIntensity}"); - Console.WriteLine($"[ShaderDebugger] Mode: {parameters.Mode}"); - } - } -} diff --git a/FreshViewer/Models/ImageMetadata.cs b/FreshViewer/Models/ImageMetadata.cs index 143bf5f..169b947 100644 --- a/FreshViewer/Models/ImageMetadata.cs +++ b/FreshViewer/Models/ImageMetadata.cs @@ -2,6 +2,9 @@ namespace FreshViewer.Models; +/// +/// Represents the metadata extracted from an image, grouped into sections. +/// public sealed class ImageMetadata { public ImageMetadata(IReadOnlyList sections, IReadOnlyDictionary? raw = null) @@ -10,11 +13,20 @@ public ImageMetadata(IReadOnlyList sections, IReadOnlyDictionar 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) @@ -23,11 +35,20 @@ public MetadataSection(string title, IReadOnlyList fields) 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) @@ -36,7 +57,13 @@ public MetadataField(string label, string? value) 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 index b881328..8095230 100644 --- a/FreshViewer/Program.cs +++ b/FreshViewer/Program.cs @@ -1,14 +1,17 @@ using System; -using System.IO; using Avalonia; namespace FreshViewer; +/// +/// Entry point hosting Avalonia's desktop lifetime for FreshViewer. +/// internal static class Program { - private static readonly string StartupLogPath = Path.Combine(AppContext.BaseDirectory, "startup.log"); - [STAThread] + /// + /// Validates platform requirements and starts the Avalonia application. + /// public static void Main(string[] args) { if (!OperatingSystem.IsWindows()) @@ -19,6 +22,9 @@ public static void Main(string[] args) BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); } + /// + /// Creates and configures the Avalonia application builder. + /// public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure() .UseWin32() diff --git a/FreshViewer/Services/ImageLoader.cs b/FreshViewer/Services/ImageLoader.cs index d741820..8b41145 100644 --- a/FreshViewer/Services/ImageLoader.cs +++ b/FreshViewer/Services/ImageLoader.cs @@ -17,6 +17,9 @@ namespace FreshViewer.Services; +/// +/// Loads still images and animations while extracting relevant metadata. +/// public sealed class ImageLoader { private static readonly HashSet AnimatedExtensions = new(StringComparer.OrdinalIgnoreCase) @@ -35,6 +38,11 @@ public sealed class ImageLoader ".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)) @@ -203,6 +211,9 @@ private static LoadedImage ConvertMagickImage(string path, MagickImage magickIma } } +/// +/// Represents a decoded image together with optional animation metadata. +/// public sealed class LoadedImage : IDisposable { public LoadedImage(string path, Bitmap? bitmap, AnimatedImage? animated, ImageMetadata? metadata) @@ -213,19 +224,40 @@ public LoadedImage(string path, Bitmap? bitmap, AnimatedImage? animated, ImageMe 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(); @@ -233,6 +265,9 @@ public void Dispose() } } +/// +/// Describes an animated image composed of individual frames and loop count. +/// public sealed class AnimatedImage : IDisposable { public AnimatedImage(IReadOnlyList frames, int loopCount) @@ -242,12 +277,24 @@ public AnimatedImage(IReadOnlyList frames, int 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) @@ -257,6 +304,9 @@ public void Dispose() } } +/// +/// Represents a single frame in an animated image. +/// public sealed class AnimatedFrame : IDisposable { public AnimatedFrame(Bitmap bitmap, MemoryStream backingStream, TimeSpan duration) @@ -266,12 +316,21 @@ public AnimatedFrame(Bitmap bitmap, MemoryStream backingStream, TimeSpan duratio 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(); diff --git a/FreshViewer/Services/LocalizationService.cs b/FreshViewer/Services/LocalizationService.cs index 7ba0748..0e5f205 100644 --- a/FreshViewer/Services/LocalizationService.cs +++ b/FreshViewer/Services/LocalizationService.cs @@ -8,9 +8,8 @@ namespace FreshViewer.Services; /// -/// Простейшая локализация на основе CultureInfo.CurrentUICulture. -/// Сейчас хранит только выбранную культуру; ключи в XAML пока статические. -/// Расширяется до полноценного IStringLocalizer при необходимости. +/// 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 { @@ -22,6 +21,9 @@ public static class LocalizationService ["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)) @@ -53,7 +55,7 @@ public static void ApplyLanguage(string languageName) 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 diff --git a/FreshViewer/Services/ShortcutManager.cs b/FreshViewer/Services/ShortcutManager.cs index 1279623..b6ffd2a 100644 --- a/FreshViewer/Services/ShortcutManager.cs +++ b/FreshViewer/Services/ShortcutManager.cs @@ -8,19 +8,28 @@ namespace FreshViewer.Services; /// -/// Управляет профилями и ремаппингом горячих клавиш. Позволяет импорт/экспорт JSON. +/// 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(); @@ -29,7 +38,7 @@ public void ResetToProfile(string profileName) { case "Photoshop": ApplyStandardBase(); - // Дополнительно: повороты как в Lightroom/PS: Ctrl+[ и Ctrl+] + // 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; @@ -42,24 +51,24 @@ public void ResetToProfile(string profileName) 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)); } @@ -69,6 +78,11 @@ 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) @@ -87,6 +101,9 @@ public bool TryMatch(KeyEventArgs e, out ShortcutAction action) return false; } + /// + /// Exports the current shortcut mappings to a JSON string. + /// public async Task ExportToJsonAsync() { var export = _actionToCombos.ToDictionary( @@ -97,6 +114,9 @@ public async Task ExportToJsonAsync() 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); @@ -122,6 +142,9 @@ public void ImportFromJson(string json) } } +/// +/// Represents actions invoked by keyboard shortcuts in the viewer. +/// public enum ShortcutAction { Previous, @@ -139,6 +162,9 @@ public enum ShortcutAction CopyFrame } +/// +/// Represents a single key with optional modifier mask. +/// public readonly struct KeyCombo { public KeyCombo(Key key, KeyModifiers modifiers = KeyModifiers.None) @@ -147,9 +173,18 @@ public KeyCombo(Key key, KeyModifiers modifiers = KeyModifiers.None) 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) @@ -157,7 +192,7 @@ public bool Matches(KeyEventArgs e) return false; } - // Нормализуем модификаторы (NumLock/Scroll не влияют) + // Normalize modifiers – lock keys should not affect matching. var mods = e.KeyModifiers & (KeyModifiers.Control | KeyModifiers.Shift | KeyModifiers.Alt | KeyModifiers.Meta); return mods == Modifiers; } @@ -165,6 +200,9 @@ public bool Matches(KeyEventArgs e) 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(); @@ -177,6 +215,9 @@ public static string Format(KeyCombo combo) return string.Join('+', parts); } + /// + /// Parses a human readable shortcut combination back into a . + /// public static KeyCombo? Parse(string? text) { if (string.IsNullOrWhiteSpace(text)) diff --git a/FreshViewer/Services/ThemeManager.cs b/FreshViewer/Services/ThemeManager.cs index f52eca4..7204445 100644 --- a/FreshViewer/Services/ThemeManager.cs +++ b/FreshViewer/Services/ThemeManager.cs @@ -6,10 +6,13 @@ namespace FreshViewer.Services; /// -/// Применение предустановленных тем LiquidGlass на лету. +/// 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; @@ -31,7 +34,7 @@ public static void Apply(string themeName) return; } - // Удаляем предыдущие LiquidGlass словари + // 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 diff --git a/FreshViewer/ViewModels/ImageMetadataViewModels.cs b/FreshViewer/ViewModels/ImageMetadataViewModels.cs index 17d0e68..683d8fd 100644 --- a/FreshViewer/ViewModels/ImageMetadataViewModels.cs +++ b/FreshViewer/ViewModels/ImageMetadataViewModels.cs @@ -4,19 +4,37 @@ 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) diff --git a/FreshViewer/ViewModels/MainWindowViewModel.cs b/FreshViewer/ViewModels/MainWindowViewModel.cs index 0e0accf..e593be4 100644 --- a/FreshViewer/ViewModels/MainWindowViewModel.cs +++ b/FreshViewer/ViewModels/MainWindowViewModel.cs @@ -6,6 +6,9 @@ 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; @@ -31,26 +34,39 @@ public sealed class MainWindowViewModel : INotifyPropertyChanged 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; @@ -63,6 +79,9 @@ public bool IsMetadataVisible } } + /// + /// Gets or sets a value indicating whether the main UI chrome is visible. + /// public bool IsUiVisible { get => _isUiVisible; @@ -88,12 +107,18 @@ public bool IsUiVisible } } + /// + /// 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; @@ -109,6 +134,9 @@ public bool IsInfoPanelVisible } } + /// + /// Gets or sets a value indicating whether the settings panel is open. + /// public bool IsSettingsPanelVisible { get => _isSettingsPanelVisible; @@ -124,12 +152,18 @@ public bool IsSettingsPanelVisible } } + /// + /// 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; @@ -142,48 +176,72 @@ private set } } + /// + /// 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", @@ -191,6 +249,9 @@ public string? ErrorDescription "Frosted Steel" }; + /// + /// Gets the language options exposed in the UI. + /// public IReadOnlyList LanguageOptions { get; } = new[] { "Русский", @@ -199,6 +260,9 @@ public string? ErrorDescription "Deutsch" }; + /// + /// Gets the names of the available shortcut profiles. + /// public IReadOnlyList ShortcutProfiles { get; } = new[] { "Стандартный", @@ -206,36 +270,58 @@ public string? ErrorDescription "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; @@ -253,6 +339,9 @@ public void ApplyMetadata(string? fileName, string? resolution, string statusMes UpdateMetadataCardVisibility(); } + /// + /// Clears all metadata state and hides associated panels. + /// public void ResetMetadata() { FileName = null; @@ -266,6 +355,9 @@ public void ResetMetadata() UpdateMetadataCardVisibility(); } + /// + /// Shows an error overlay with the provided title and description. + /// public void ShowError(string title, string description) { ErrorTitle = title; @@ -273,6 +365,9 @@ public void ShowError(string title, string description) IsErrorVisible = true; } + /// + /// Hides the error overlay and clears the stored messages. + /// public void HideError() { IsErrorVisible = false; diff --git a/FreshViewer/Views/MainWindow.axaml b/FreshViewer/Views/MainWindow.axaml index 55395b5..a364830 100644 --- a/FreshViewer/Views/MainWindow.axaml +++ b/FreshViewer/Views/MainWindow.axaml @@ -4,7 +4,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:controls="clr-namespace:FreshViewer.Controls" xmlns:vm="clr-namespace:FreshViewer.ViewModels" - xmlns:lg="clr-namespace:FreshViewer.UI.LiquidGlass" + xmlns:lg="clr-namespace:FreshViewer.Effects.LiquidGlass" xmlns:easing="clr-namespace:Avalonia.Animation.Easings;assembly=Avalonia.Base" xmlns:conv="clr-namespace:FreshViewer.Converters" @@ -114,7 +114,7 @@ - + - + - + - + - + + + + + + + + + diff --git a/FreshViewer/Views/MainWindow.axaml.cs b/FreshViewer/Views/MainWindow.axaml.cs index 91e6eb2..b0b09ae 100644 --- a/FreshViewer/Views/MainWindow.axaml.cs +++ b/FreshViewer/Views/MainWindow.axaml.cs @@ -23,6 +23,9 @@ 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) @@ -112,7 +115,7 @@ public MainWindow() InitializePanelState(_infoPanel, -80); InitializePanelState(_settingsPanel, 80); - // Применяем стартовые тему и язык + // Apply the persisted theme and language. LocalizationService.ApplyLanguage(_viewModel.SelectedLanguage); ThemeManager.Apply(_viewModel.SelectedTheme); @@ -121,15 +124,18 @@ public MainWindow() private void ConfigureWindowChrome() { - // Упрощённый фон: без Mica/Acrylic/Blur, только обычный непрозрачный градиент. + // Simplified chrome: no Mica/Acrylic effects, just a transparent surface. TransparencyLevelHint = new[] { WindowTransparencyLevel.Transparent }; - Background = Brushes.Transparent; // оставляем прозрачный бэкграунд окна, сам фон рисуем в XAML градиентом + 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 }) @@ -1180,5 +1186,5 @@ private void OnResetShortcutsClicked(object? sender, RoutedEventArgs e) _viewModel.StatusText = "Шорткаты сброшены к профилю"; } - // методы-ссылки не нужны, применяем сразу через PropertyChanged + // Helper methods are unnecessary; property setters trigger PropertyChanged immediately. } diff --git a/README.md b/README.md index 54a76bb..926f326 100644 --- a/README.md +++ b/README.md @@ -51,8 +51,9 @@ The result is a calm, legible interface that stays out of the way while keeping ## Build & run ```bash -dotnet restore FreshViewer.sln -dotnet build FreshViewer.sln -c Release +dotnet restore +dotnet build -warnaserror +dotnet test dotnet run --project FreshViewer/FreshViewer.csproj -- ``` @@ -62,6 +63,12 @@ dotnet publish FreshViewer/FreshViewer.csproj -c Release -r win-x64 \ -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true --self-contained=false ``` +### Liquid Glass effect +- Requires Windows 10 1809+ with GPU-backed composition. The shader is disabled automatically on unsupported platforms. +- Toggle the feature from **Settings → Liquid Glass effects** or via the `FRESHVIEWER_FORCE_LIQUID_GLASS` environment variable (`true`/`false`). +- A small preview card inside the settings panel helps verify the shader versus the fallback gradient. +- When the GPU path is unavailable the app renders a static translucent fallback so the UI remains legible. + ## Settings - Themes: switch between pre‑tuned Liquid Glass palettes - Language: ru / en / uk / de From 47e7b6acd8583bf5de2354c4b7eaba455fc7eeb6 Mon Sep 17 00:00:00 2001 From: amti_yo Date: Mon, 3 Nov 2025 22:24:03 +0200 Subject: [PATCH 07/10] Resolve conflicts and prepare merge --- BlurViewer.ico | Bin 15406 -> 0 bytes CHANGELOG.md | 13 ------------ CONTRIBUTING.md | 20 ------------------ .../PR-HOTFIX-Background-Simplification.md | 4 ---- 4 files changed, 37 deletions(-) delete mode 100644 BlurViewer.ico delete mode 100644 CHANGELOG.md delete mode 100644 CONTRIBUTING.md delete mode 100644 FreshViewer/PR-HOTFIX-Background-Simplification.md diff --git a/BlurViewer.ico b/BlurViewer.ico deleted file mode 100644 index 159c2fdcc564c19cc2e96ab6352a4a4039a5f5ba..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15406 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 diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index b13b41c..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,13 +0,0 @@ -# Changelog - -## [Unreleased] -### Added -- Integrated a shader-driven Liquid Glass decorator with a gradient fallback and sample preview. -- Added a lightweight unit test project that covers key `MainWindowViewModel` behaviors. - -### Changed -- Cleaned and documented core services, view models, and controls with English XML comments. -- Updated window initialization to apply theme and language consistently. - -### Fixed -- Ensured Liquid Glass toggles respect platform support and user settings without crashing. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 293fa3f..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,20 +0,0 @@ -## Contributing - -Спасибо за помощь проекту! Чтобы упростить ревью и сборку, придерживайтесь коротких правил ниже. - -### Быстрый старт -1. Форкните репозиторий и создайте ветку: `feature/<кратко-о-змениях>`. -2. Восстановите пакеты: `dotnet restore FreshViewer.sln`. -3. Запустите приложение: `dotnet run --project FreshViewer/FreshViewer.csproj`. - -### Стиль и качество -- Цель — чистый, читаемый C# с явными именами и проверкой ошибок. -- Избегайте неиспользуемых `using`, не оставляйте закомментированный код. -- Перед PR: `dotnet build -c Release` должен проходить без ошибок. - -### Публикация PR -- Короткое описание задачи и скрин/видео для UI изменений. -- Крупные правки делите на логические коммиты. -- Приветствуются самостоятельные правки документации. - -Спасибо! diff --git a/FreshViewer/PR-HOTFIX-Background-Simplification.md b/FreshViewer/PR-HOTFIX-Background-Simplification.md deleted file mode 100644 index d16ae4b..0000000 --- a/FreshViewer/PR-HOTFIX-Background-Simplification.md +++ /dev/null @@ -1,4 +0,0 @@ -Hotfix: Background simplification (no Mica/Acrylic/Blur). - -- Reverted background to simple gradient -- Kept LiquidGlass effects and UI From c5c4ea603605684a20fafcd80be52a8c7af42b26 Mon Sep 17 00:00:00 2001 From: amti_yo Date: Mon, 3 Nov 2025 22:29:48 +0200 Subject: [PATCH 08/10] Keep legacy Python under legacy/blurviewer; keep new C# in FreshViewer --- README.md | 84 -- legacy/blurviewer/BlurViewer.ico | Bin 0 -> 15406 bytes legacy/blurviewer/BlurViewer.py | 1184 ++++++++++++++++++++++++++ legacy/blurviewer/CONTRIBUTING.md | 123 +++ LICENSE => legacy/blurviewer/LICENSE | 42 +- legacy/blurviewer/Makefile | 45 + legacy/blurviewer/README.de.md | 210 +++++ legacy/blurviewer/README.md | 210 +++++ legacy/blurviewer/README.ru.md | 210 +++++ legacy/blurviewer/pyproject.toml | 162 ++++ legacy/blurviewer/requirements.txt | 14 + 11 files changed, 2179 insertions(+), 105 deletions(-) delete mode 100644 README.md create mode 100644 legacy/blurviewer/BlurViewer.ico create mode 100644 legacy/blurviewer/BlurViewer.py create mode 100644 legacy/blurviewer/CONTRIBUTING.md rename LICENSE => legacy/blurviewer/LICENSE (98%) create mode 100644 legacy/blurviewer/Makefile create mode 100644 legacy/blurviewer/README.de.md create mode 100644 legacy/blurviewer/README.md create mode 100644 legacy/blurviewer/README.ru.md create mode 100644 legacy/blurviewer/pyproject.toml create mode 100644 legacy/blurviewer/requirements.txt diff --git a/README.md b/README.md deleted file mode 100644 index 926f326..0000000 --- a/README.md +++ /dev/null @@ -1,84 +0,0 @@ -

FreshViewer

- -

- -![Release Download](https://img.shields.io/github/downloads/amtiYo/BlurViewer/total?style=flat-square) -[![Release Version](https://img.shields.io/github/v/release/amtiYo/BlurViewer?style=flat-square)](https://github.com/amtiYo/BlurViewer/releases/latest) -[![GitHub license](https://img.shields.io/github/license/amtiYo/BlurViewer?style=flat-square)](LICENSE) -[![GitHub Star](https://img.shields.io/github/stars/amtiYo/BlurViewer?style=flat-square)](https://github.com/amtiYo/BlurViewer/stargazers) -[![GitHub Fork](https://img.shields.io/github/forks/amtiYo/BlurViewer?style=flat-square)](https://github.com/amtiYo/BlurViewer/network/members) -![GitHub Repo size](https://img.shields.io/github/repo-size/amtiYo/BlurViewer?style=flat-square&color=3cb371) -

- -A modern, distraction‑free image viewer for Windows built with .NET 8 and Avalonia. FreshViewer features a crisp Liquid Glass interface, smooth navigation, rich format support, and a handy info overlay — all optimized for everyday use. - -## Highlights -- Liquid Glass UI: translucent cards, soft shadows, and subtle motion for a premium feel -- Smooth navigation: kinetic panning, focus‑aware zoom, rotate, and fit‑to‑view -- Info at a glance: compact summary card + detailed metadata panel (EXIF/XMP) -- Powerful formats: stills, animations, modern codecs, and DSLR RAW families -- Personalization: themes, language (ru/en/uk/de), and keyboard‑shortcut profiles - -## Liquid Glass design -FreshViewer embraces a lightweight “Liquid Glass” aesthetic: -- Top app bar with rounded glass buttons (Back, Next, Fit, Rotate, Open, Info, Settings, Fullscreen) -- Left summary card (file name, resolution, position in folder) -- Slide‑in information panel (I) with fluid enter/exit animation -- Compact status pill at the bottom with action hints - -The result is a calm, legible interface that stays out of the way while keeping essential controls at your fingertips. - -## Supported formats -- Common: PNG, JPEG, BMP, TIFF, ICO, SVG -- Modern: WEBP, HEIC/HEIF, AVIF, JXL -- Pro: PSD, HDR, EXR -- DSLR RAW: CR2/CR3, NEF, ARW, DNG, RAF, ORF, RW2, PEF, SRW, MRW, X3F, DCR, KDC, ERF, MEF, MOS, PTX, R3D, FFF, IIQ -- Animation: GIF/APNG (with loop handling) - -## Keyboard & mouse (default) -- Navigate: A/← and D/→ -- Fit to view: Space / F -- Rotate: R / L -- Zoom: mouse wheel, + / − -- Info panel: I -- Settings: P -- Fullscreen: F11 -- Copy current frame: Ctrl+C - -## Requirements -- Windows 10 1809 or newer (x64) -- .NET 8 SDK - -## Build & run -```bash -dotnet restore -dotnet build -warnaserror -dotnet test -dotnet run --project FreshViewer/FreshViewer.csproj -- -``` - -Publish (Windows x64, single file): -```bash -dotnet publish FreshViewer/FreshViewer.csproj -c Release -r win-x64 \ - -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true --self-contained=false -``` - -### Liquid Glass effect -- Requires Windows 10 1809+ with GPU-backed composition. The shader is disabled automatically on unsupported platforms. -- Toggle the feature from **Settings → Liquid Glass effects** or via the `FRESHVIEWER_FORCE_LIQUID_GLASS` environment variable (`true`/`false`). -- A small preview card inside the settings panel helps verify the shader versus the fallback gradient. -- When the GPU path is unavailable the app renders a static translucent fallback so the UI remains legible. - -## Settings -- Themes: switch between pre‑tuned Liquid Glass palettes -- Language: ru / en / uk / de -- Shortcuts: select a profile (Standard, Photoshop, Lightroom) or export/import your own mapping (JSON) - -## Contributing -Contributions are welcome. Please see [CONTRIBUTING.md](./CONTRIBUTING.md) for a short guide. - -## License -MIT — see [LICENSE](./LICENSE). - -## Credits -Avalonia, SkiaSharp, ImageSharp, Magick.NET, and MetadataExtractor. diff --git a/legacy/blurviewer/BlurViewer.ico b/legacy/blurviewer/BlurViewer.ico new file mode 100644 index 0000000000000000000000000000000000000000..159c2fdcc564c19cc2e96ab6352a4a4039a5f5ba GIT binary patch literal 15406 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 literal 0 HcmV?d00001 diff --git a/legacy/blurviewer/BlurViewer.py b/legacy/blurviewer/BlurViewer.py new file mode 100644 index 0000000..a8c7f72 --- /dev/null +++ b/legacy/blurviewer/BlurViewer.py @@ -0,0 +1,1184 @@ +""" +BlurViewer v0.8.1 +Professional image viewer with advanced format support and smooth animations +""" + +import sys +import os +from pathlib import Path +from typing import Optional +import math + +from PySide6.QtCore import Qt, QTimer, QPointF, QRectF, QThread, Signal, QEasingCurve +from PySide6.QtGui import (QPixmap, QImageReader, QPainter, QWheelEvent, QMouseEvent, + QColor, QImage, QGuiApplication, QMovie) +from PySide6.QtWidgets import QApplication, QWidget, QFileDialog + + +class ImageLoader(QThread): + """Background thread for loading heavy image formats""" + + imageLoaded = Signal(str, QPixmap) + animatedImageLoaded = Signal(str) + loadFailed = Signal(str, str) + + _plugins_registered = False + + def __init__(self, path): + super().__init__() + self.path = path + + def run(self): + try: + # Quick format validation for common misnamed files + if not os.path.exists(self.path): + self.loadFailed.emit(self.path, "File does not exist") + return + + # Check file header for common format issues + try: + with open(self.path, 'rb') as f: + header = f.read(16) + + # Check for video files with wrong extensions + if self.path.lower().endswith(('.gif', '.png', '.jpg')) and header[4:8] == b'ftyp': + self.loadFailed.emit(self.path, "This is a video file (MP4), not an image. Use a video player instead.") + return + except Exception: + pass + + # Check if it's an animated format first + if self.isInterruptionRequested(): + return + + if Path(self.path).suffix.lower() in BlurViewer.ANIMATED_EXTENSIONS: + movie = self._try_load_animated(self.path) + if movie and movie.isValid(): + # Try to start movie to verify it works + try: + movie.jumpToFrame(0) + first_frame = movie.currentPixmap() + if not first_frame.isNull(): + if self.isInterruptionRequested(): + return + self.animatedImageLoaded.emit(self.path) + return + except Exception: + pass + + # Load as static image + pixmap = self._load_image_comprehensive(self.path) + if pixmap and not pixmap.isNull(): + if self.isInterruptionRequested(): + return + self.imageLoaded.emit(self.path, pixmap) + else: + self.loadFailed.emit(self.path, "Failed to load image") + except Exception as e: + self.loadFailed.emit(self.path, str(e)) + + def _try_load_animated(self, path: str) -> QMovie: + """Try to load animated image formats using QMovie""" + try: + normalized_path = os.path.normpath(path) + movie = QMovie(normalized_path) + if movie.isValid(): + return movie + except Exception: + pass + return None + + def _register_all_plugins(self): + """Register all available image format plugins""" + if ImageLoader._plugins_registered: + return + + try: + import pillow_heif # type: ignore + + pillow_heif.register_heif_opener() + except Exception: + pass + + try: + import pillow_avif # type: ignore + + if hasattr(pillow_avif, "register_avif_opener"): + pillow_avif.register_avif_opener() + except Exception: + pass + + ImageLoader._plugins_registered = True + + def _load_image_comprehensive(self, path: str) -> QPixmap: + """Comprehensive image loader supporting all formats""" + self._register_all_plugins() + + if self.isInterruptionRequested(): + return None + + # Try Qt native formats first + normalized_path = os.path.normpath(path) + + reader = QImageReader(normalized_path) + reader.setAutoTransform(True) + if reader.canRead(): + qimg = reader.read() + if qimg and not qimg.isNull(): + if qimg.format() != QImage.Format_RGBA8888: + qimg = qimg.convertToFormat(QImage.Format_RGBA8888) + return QPixmap.fromImage(qimg) + + # Try with Pillow for other formats + try: + from PIL import Image + + with open(normalized_path, 'rb') as f: + if self.isInterruptionRequested(): + return None + + im = Image.open(f) + im.load() + + if im.mode != 'RGBA': + im = im.convert('RGBA') + + data = im.tobytes('raw', 'RGBA') + bytes_per_line = im.width * 4 + qimg = QImage(data, im.width, im.height, bytes_per_line, QImage.Format_RGBA8888) + if not qimg.isNull(): + return QPixmap.fromImage(qimg.copy()) + except Exception: + pass + + return None + + +class BlurViewer(QWidget): + # Animation constants - optimized values + LERP_FACTOR = 0.18 # Slightly faster interpolation + PAN_FRICTION = 0.85 # Slightly less friction for smoother panning + NAVIGATION_SPEED = 0.045 # Slower for smoother transitions + ZOOM_FACTOR = 1.2 + ZOOM_STEP = 0.15 + + # Scale limits + MIN_SCALE = 0.1 + MAX_SCALE = 20.0 + MIN_REFRESH_INTERVAL = 8 # 125 FPS max + + # Opacity values + WINDOWED_BG_OPACITY = 200.0 + FULLSCREEN_BG_OPACITY = 250.0 + + # Supported file extensions + ANIMATED_EXTENSIONS = {'.gif', '.mng'} + RAW_EXTENSIONS = {'.cr2', '.cr3', '.nef', '.arw', '.dng', '.raf', '.orf', + '.rw2', '.pef', '.srw', '.x3f', '.mrw', '.dcr', '.kdc', + '.erf', '.mef', '.mos', '.ptx', '.r3d', '.fff', '.iiq'} + + def __init__(self, image_path: Optional[str] = None): + super().__init__() + + # Window setup + self.setWindowFlags(Qt.FramelessWindowHint | Qt.Window) + self.setAttribute(Qt.WA_TranslucentBackground) + self.setFocusPolicy(Qt.StrongFocus) + self.setAcceptDrops(True) + + # Cache screen geometry to avoid repeated calls + self._screen_geom = None + self._screen_center = None + self._current_pixmap_cache = None # Cache for current pixmap + + # Image state + self.pixmap: Optional[QPixmap] = None + self.image_path = None + self.movie: Optional[QMovie] = None + self.rotation = 0.0 + + # Directory navigation + self.current_directory = None + self.image_files = [] + self.current_index = -1 + + # Transform state + self.target_scale = 1.0 + self.current_scale = 1.0 + self.target_offset = QPointF(0, 0) + self.current_offset = QPointF(0, 0) + self.fit_scale = 1.0 + + # Animation parameters + self.lerp_factor = self.LERP_FACTOR + self.pan_friction = self.PAN_FRICTION + + # Navigation animation + self.navigation_animation = False + self.navigation_direction = 0 + self.navigation_progress = 0.0 + self.old_pixmap = None + self.new_pixmap = None + + # Interaction state + self.is_panning = False + self.last_mouse_pos = QPointF(0, 0) + self.pan_velocity = QPointF(0, 0) + + # Opening animation + self.opening_animation = True + self.opening_scale = 0.8 + self.opening_opacity = 0.0 + + # Closing animation + self.closing_animation = False + self.closing_scale = 1.0 + self.closing_opacity = 1.0 + + # Background fade + self.background_opacity = 0.0 + self.target_background_opacity = self.WINDOWED_BG_OPACITY + + # Fullscreen state + self.is_fullscreen = False + self.saved_scale = 1.0 + self.saved_offset = QPointF(0, 0) + + # Performance optimization + self.update_pending = False + self.loading_thread: Optional[ImageLoader] = None + self._needs_cache_update = True # Flag for pixmap cache + self._active_request_path: Optional[str] = None + + # Main animation timer with adaptive FPS + self.timer = QTimer(self) + refresh_interval = self._get_monitor_refresh_interval() + self.timer.setInterval(refresh_interval) + self.timer.timeout.connect(self.animate) + self.timer.start() + + # Load image + if image_path: + self.load_image(image_path) + else: + self.open_dialog_and_load() + + def _get_monitor_refresh_interval(self) -> int: + """Get monitor refresh interval in milliseconds""" + try: + screen = QApplication.primaryScreen() + if screen: + refresh_rate = screen.refreshRate() + if refresh_rate > 0: + interval = int(1000.0 / refresh_rate) + return max(interval, self.MIN_REFRESH_INTERVAL) + except Exception: + pass + + return 16 # Fallback to 60 FPS + + def _get_current_pixmap(self) -> QPixmap: + """Get current pixmap with caching""" + if self._needs_cache_update: + if self.pixmap: + self._current_pixmap_cache = self.pixmap + elif self.movie: + self._current_pixmap_cache = self.movie.currentPixmap() + else: + self._current_pixmap_cache = QPixmap() + self._needs_cache_update = False + + return self._current_pixmap_cache or QPixmap() + + def _invalidate_pixmap_cache(self): + """Invalidate the pixmap cache""" + self._needs_cache_update = True + + def _get_screen_info(self): + """Get cached screen geometry and center""" + if self._screen_geom is None: + self._screen_geom = QApplication.primaryScreen().availableGeometry() + self._screen_center = QPointF(self._screen_geom.center()) + return self._screen_geom, self._screen_center + + def _clear_screen_cache(self): + """Clear cached screen info""" + self._screen_geom = None + self._screen_center = None + + def _stop_loading_thread(self): + """Stop the active loading thread if it is running""" + if self.loading_thread: + self.loading_thread.requestInterruption() + self.loading_thread.wait() + self.loading_thread.deleteLater() + self.loading_thread = None + self._active_request_path = None + + def _on_loader_finished(self): + """Cleanup when the loading thread finishes""" + thread = self.sender() + if thread is self.loading_thread: + self.loading_thread = None + if thread: + thread.deleteLater() + + def _start_loading_thread(self, path: str, static_slot, animated_slot): + """Helper to start a loading thread for the given path""" + normalized_path = os.path.normpath(path) + self._stop_loading_thread() + self._active_request_path = normalized_path + + thread = ImageLoader(normalized_path) + thread.imageLoaded.connect(static_slot) + thread.animatedImageLoaded.connect(animated_slot) + thread.loadFailed.connect(self._on_load_failed) + thread.finished.connect(self._on_loader_finished) + + self.loading_thread = thread + thread.start() + + def get_image_files_in_directory(self, directory_path: str): + """Get list of supported image files in directory""" + if not directory_path: + return [] + + directory = Path(directory_path) + if not directory.is_dir(): + return [] + + # Supported extensions + supported_exts = { + '.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' + } | self.RAW_EXTENSIONS + + image_files = [] + try: + for file_path in directory.iterdir(): + if file_path.is_file() and file_path.suffix.lower() in supported_exts: + image_files.append(str(file_path)) + except (OSError, PermissionError): + return [] + + return sorted(image_files, key=lambda x: Path(x).name.lower()) + + def setup_directory_navigation(self, image_path: str): + """Setup directory navigation for the current image""" + if not image_path: + return + + path_obj = Path(image_path) + self.current_directory = str(path_obj.parent) + self.image_files = self.get_image_files_in_directory(self.current_directory) + + try: + self.current_index = self.image_files.index(str(path_obj)) + except ValueError: + self.current_index = -1 + + def navigate_to_image(self, direction: int): + """Navigate to next/previous image in directory""" + if not self.image_files or self.current_index == -1 or self.navigation_animation: + return + + new_index = (self.current_index + direction) % len(self.image_files) + if new_index == self.current_index: + return + + # Setup slide animation only in windowed mode + if not self.is_fullscreen: + self.old_pixmap = self._get_current_pixmap() + self.navigation_direction = direction + self.navigation_progress = 0.0 + self.navigation_animation = True + + self.current_index = new_index + new_path = self.image_files[self.current_index] + + # Load new image in background + self._start_loading_thread(new_path, self._on_navigation_image_loaded, self._on_navigation_animated_loaded) + + def _on_navigation_image_loaded(self, path: str, pixmap: QPixmap): + """Handle successful navigation image loading""" + normalized_path = os.path.normpath(path) + if self._active_request_path and normalized_path != self._active_request_path: + return + + self._active_request_path = None + self._invalidate_pixmap_cache() + + if self.is_fullscreen: + # Stop any movie + if self.movie: + self.movie.stop() + self.movie.deleteLater() + self.movie = None + + self.pixmap = pixmap + self.image_path = normalized_path + self.rotation = 0.0 + self._fit_to_fullscreen_instant() + else: + # Use slide animation in windowed mode + self.new_pixmap = pixmap + self.image_path = normalized_path + self.rotation = 0.0 + + def _on_navigation_animated_loaded(self, path: str): + """Handle successful navigation animated image loading""" + normalized_path = os.path.normpath(path) + if self._active_request_path and normalized_path != self._active_request_path: + return + + self._active_request_path = None + self._invalidate_pixmap_cache() + + if self.is_fullscreen: + # Stop any existing movie + if self.movie: + self.movie.stop() + self.movie.deleteLater() + self.movie = None + + # Create new movie + self.movie = QMovie(normalized_path) + + if self.movie and self.movie.isValid(): + self.pixmap = None + self.image_path = normalized_path + self.rotation = 0.0 + + self.movie.frameChanged.connect(self._on_movie_frame_changed) + self.movie.jumpToFrame(0) + first_frame = self.movie.currentPixmap() + if not first_frame.isNull(): + self.pixmap = first_frame + self._fit_to_fullscreen_instant() + self.pixmap = None + self.movie.start() + else: + # Use slide animation in windowed mode + temp_movie = QMovie(normalized_path) + + if temp_movie and temp_movie.isValid(): + temp_movie.jumpToFrame(0) + first_frame = temp_movie.currentPixmap() + if not first_frame.isNull(): + self.new_pixmap = first_frame + self.image_path = normalized_path + self.rotation = 0.0 + temp_movie.deleteLater() + + def _on_movie_frame_changed(self): + """Handle movie frame change""" + self._invalidate_pixmap_cache() + self.schedule_update() + + def close_application(self): + """Start closing animation and exit""" + if not self.closing_animation: + self.closing_animation = True + self.target_background_opacity = 0.0 + QTimer.singleShot(300, QApplication.instance().quit) + + def get_supported_formats(self): + """Get comprehensive list of supported formats""" + base_formats = [ + "*.png", "*.jpg", "*.jpeg", "*.bmp", "*.gif", "*.mng", "*.webp", "*.tiff", "*.tif", + "*.ico", "*.svg", "*.pbm", "*.pgm", "*.ppm", "*.xbm", "*.xpm" + ] + + raw_formats = ["*" + ext for ext in self.RAW_EXTENSIONS] + modern_formats = ["*.heic", "*.heif", "*.avif", "*.jxl"] + scientific_formats = ["*.fits", "*.hdr", "*.exr", "*.pic", "*.psd"] + + return base_formats + raw_formats + modern_formats + scientific_formats + + def open_dialog_and_load(self): + formats = self.get_supported_formats() + filter_str = f"All Supported Images ({' '.join(formats)})" + + fname, _ = QFileDialog.getOpenFileName( + self, "Open image", str(Path.home()), filter_str + ) + if fname: + self.load_image(fname) + else: + QTimer.singleShot(0, QApplication.instance().quit) + + def load_image(self, path: str): + """Load image with comprehensive format support""" + normalized_path = os.path.normpath(path) + self.image_path = normalized_path + self.setup_directory_navigation(normalized_path) + + # Reset animations + if self.pixmap: + self.opening_animation = True + self.opening_scale = 0.95 + self.opening_opacity = 0.2 + + # Stop any existing movie + if self.movie: + self.movie.stop() + self.movie = None + + self._invalidate_pixmap_cache() + self._stop_loading_thread() + + if not os.path.isfile(normalized_path): + self._on_load_failed(normalized_path, "File does not exist") + return + + # Background loading + self._start_loading_thread(normalized_path, self._on_image_loaded, self._on_animated_image_loaded) + + def _on_image_loaded(self, path: str, pixmap: QPixmap): + """Handle successful image loading""" + normalized_path = os.path.normpath(path) + if self._active_request_path and normalized_path != self._active_request_path: + return + + self._active_request_path = None + # Stop any existing movie + if self.movie: + self.movie.stop() + self.movie = None + + self.pixmap = pixmap + self.image_path = normalized_path + self.rotation = 0.0 + self._invalidate_pixmap_cache() + self._setup_image_display() + + def _on_animated_image_loaded(self, path: str): + """Handle successful animated image loading""" + normalized_path = os.path.normpath(path) + if self._active_request_path and normalized_path != self._active_request_path: + return + + self._active_request_path = None + # Stop any existing movie + if self.movie: + self.movie.stop() + self.movie.deleteLater() + self.movie = None + + # Create new movie + self.movie = QMovie(normalized_path) + + if self.movie.isValid(): + self.pixmap = None + self.image_path = normalized_path + self.rotation = 0.0 + self._invalidate_pixmap_cache() + + # Setup movie for display + self.movie.frameChanged.connect(self._on_movie_frame_changed) + # Get first frame for sizing + self.movie.jumpToFrame(0) + first_frame = self.movie.currentPixmap() + + if not first_frame.isNull(): + self.pixmap = first_frame # Temporary for sizing calculations + self._setup_image_display() + self.pixmap = None # Clear static pixmap, use movie instead + self.movie.start() + else: + self._on_load_failed(normalized_path, "QMovie invalid") + + def _on_load_failed(self, path: str, error: str): + """Handle loading failure""" + normalized_path = os.path.normpath(path) if path else None + if self._active_request_path and normalized_path != self._active_request_path: + return + + self._active_request_path = None + + if self.navigation_animation: + self.navigation_animation = False + self.old_pixmap = None + self.new_pixmap = None + + if not self.pixmap and not self.movie: + if "video file" not in error: + print(f"Failed to load image: {error}") + QTimer.singleShot(3000, QApplication.instance().quit) + + def _setup_image_display(self): + """Setup display parameters after image is loaded""" + current_pixmap = self._get_current_pixmap() + if not current_pixmap or current_pixmap.isNull(): + return + + screen_geom, screen_center = self._get_screen_info() + + # Calculate fit-to-screen scale + if current_pixmap.width() > 0 and current_pixmap.height() > 0: + scale_x = (screen_geom.width() * 0.9) / current_pixmap.width() + scale_y = (screen_geom.height() * 0.9) / current_pixmap.height() + self.fit_scale = min(scale_x, scale_y, 1.0) + else: + self.fit_scale = 1.0 + + # Set initial transform + self.target_scale = self.fit_scale + self.current_scale = self.fit_scale * 0.8 + + # Center the image + self.target_offset = screen_center + self.current_offset = screen_center + + # Setup window + self.resize(screen_geom.width(), screen_geom.height()) + self.move(screen_geom.topLeft()) + + # Reset animation states + self.opening_animation = True + self.opening_scale = 0.8 + self.opening_opacity = 0.0 + self.background_opacity = 0.0 + self.target_background_opacity = self.WINDOWED_BG_OPACITY + self.pan_velocity = QPointF(0, 0) + + self.schedule_update() + + def get_image_bounds(self) -> QRectF: + """Get the bounds of the image in screen coordinates""" + current_pixmap = self._get_current_pixmap() + if not current_pixmap or current_pixmap.isNull(): + return QRectF() + + img_w = current_pixmap.width() * self.current_scale + img_h = current_pixmap.height() * self.current_scale + + return QRectF( + self.current_offset.x() - img_w / 2, + self.current_offset.y() - img_h / 2, + img_w, + img_h + ) + + def point_in_image(self, point: QPointF) -> bool: + """Check if point is inside the image""" + bounds = self.get_image_bounds() + return bounds.contains(point) + + def _calculate_effective_dimensions(self): + """Calculate effective image dimensions considering rotation""" + current_pixmap = self._get_current_pixmap() + if not current_pixmap or current_pixmap.isNull(): + return 1, 1 + + if self.rotation % 180 == 90: # 90° or 270° + return current_pixmap.height(), current_pixmap.width() + else: # 0° or 180° + return current_pixmap.width(), current_pixmap.height() + + def zoom_to(self, new_scale: float, focus_point: QPointF = None): + """Zoom to specific scale with focus point""" + if not self.pixmap and not self.movie: + return + + # Restrict zoom in fullscreen mode + if self.is_fullscreen: + screen_geom = QApplication.primaryScreen().geometry() + effective_width, effective_height = self._calculate_effective_dimensions() + if effective_width > 0 and effective_height > 0: + scale_x = screen_geom.width() / effective_width + scale_y = screen_geom.height() / effective_height + min_fullscreen_scale = min(scale_x, scale_y) + new_scale = max(min_fullscreen_scale, new_scale) + + # Clamp scale + new_scale = max(self.MIN_SCALE, min(self.MAX_SCALE, new_scale)) + + # Determine focus point + if focus_point is None: + focus_point = self.current_offset + elif not self.point_in_image(focus_point): + focus_point = self.current_offset + + # Calculate the point in image space that should stay under the focus + old_scale = self.current_scale + if old_scale > 0: + dx = focus_point.x() - self.current_offset.x() + dy = focus_point.y() - self.current_offset.y() + + scale_ratio = new_scale / old_scale + new_dx = dx * scale_ratio + new_dy = dy * scale_ratio + + self.target_offset = QPointF( + focus_point.x() - new_dx, + focus_point.y() - new_dy + ) + + self.target_scale = new_scale + + def fit_to_screen(self): + """Fit image to screen - FIXED centering bug""" + if not self.pixmap and not self.movie: + return + + screen_geom, screen_center = self._get_screen_info() + + # Recalculate fit scale in case of rotation changes + current_pixmap = self._get_current_pixmap() + if current_pixmap and not current_pixmap.isNull(): + effective_width, effective_height = self._calculate_effective_dimensions() + if effective_width > 0 and effective_height > 0: + scale_x = (screen_geom.width() * 0.9) / effective_width + scale_y = (screen_geom.height() * 0.9) / effective_height + self.fit_scale = min(scale_x, scale_y, 1.0) + + self.target_scale = self.fit_scale + self.target_offset = screen_center + # Reset current offset to ensure smooth centering + self.current_offset = QPointF(self.current_offset) # Create a copy to force update + + def toggle_fullscreen(self): + """Toggle fullscreen mode""" + self.is_fullscreen = not self.is_fullscreen + self._clear_screen_cache() + + if self.is_fullscreen: + self.saved_scale = self.target_scale + self.saved_offset = QPointF(self.target_offset) + self.showFullScreen() + self.target_background_opacity = self.FULLSCREEN_BG_OPACITY + self._fit_to_fullscreen() + else: + self.showNormal() + screen_geom, _ = self._get_screen_info() + self.resize(screen_geom.width(), screen_geom.height()) + self.move(screen_geom.topLeft()) + self.target_scale = self.saved_scale + self.target_offset = self.saved_offset + self.target_background_opacity = self.WINDOWED_BG_OPACITY + + def _fit_to_fullscreen(self): + """Fit image to fullscreen with animation""" + if not self.pixmap and not self.movie: + return + + screen_geom = QApplication.primaryScreen().geometry() + screen_center = QPointF(screen_geom.center()) + + effective_width, effective_height = self._calculate_effective_dimensions() + if effective_width > 0 and effective_height > 0: + scale_x = screen_geom.width() / effective_width + scale_y = screen_geom.height() / effective_height + fit_scale = min(scale_x, scale_y) + else: + fit_scale = 1.0 + + self.target_scale = fit_scale + self.target_offset = screen_center + + def _fit_to_fullscreen_instant(self): + """Fit image to fullscreen instantly without animation""" + if not self.pixmap and not self.movie: + return + + screen_geom = QApplication.primaryScreen().geometry() + screen_center = QPointF(screen_geom.center()) + + effective_width, effective_height = self._calculate_effective_dimensions() + if effective_width > 0 and effective_height > 0: + scale_x = screen_geom.width() / effective_width + scale_y = screen_geom.height() / effective_height + fit_scale = min(scale_x, scale_y) + else: + fit_scale = 1.0 + + self.target_scale = fit_scale + self.current_scale = fit_scale + self.target_offset = screen_center + self.current_offset = screen_center + self.schedule_update() + + def wheelEvent(self, e: QWheelEvent): + """Handle zoom with mouse wheel""" + if not self.pixmap and not self.movie: + return + + delta = e.angleDelta().y() / 120.0 + zoom_factor = 1.0 + (delta * self.ZOOM_STEP) + new_scale = self.target_scale * zoom_factor + self.zoom_to(new_scale, e.position()) + e.accept() + + def mousePressEvent(self, e: QMouseEvent): + """Handle mouse press""" + if e.button() == Qt.LeftButton: + if self.point_in_image(e.position()): + self.is_panning = True + self.last_mouse_pos = e.position() + self.pan_velocity = QPointF(0, 0) + e.accept() + else: + # Exit only in windowed mode + if not self.is_fullscreen: + self.close_application() + elif e.button() == Qt.RightButton: + self.close_application() + + def mouseMoveEvent(self, e: QMouseEvent): + """Handle mouse move""" + if self.is_panning: + delta = e.position() - self.last_mouse_pos + self.current_offset += delta + self.target_offset = QPointF(self.current_offset) + self.pan_velocity = delta * 0.6 + self.last_mouse_pos = e.position() + self.schedule_update() + e.accept() + + def mouseReleaseEvent(self, e: QMouseEvent): + """Handle mouse release""" + if e.button() == Qt.LeftButton: + self.is_panning = False + e.accept() + + def mouseDoubleClickEvent(self, e: QMouseEvent): + """Handle double click - toggle between fit and 100%""" + if not self.pixmap and not self.movie: + return + + if self.is_fullscreen: + screen_geom = QApplication.primaryScreen().geometry() + effective_width, effective_height = self._calculate_effective_dimensions() + if effective_width > 0 and effective_height > 0: + scale_x = screen_geom.width() / effective_width + scale_y = screen_geom.height() / effective_height + fullscreen_fit_scale = min(scale_x, scale_y) + + if abs(self.current_scale - fullscreen_fit_scale) < 0.01: + self.zoom_to(1.0, e.position()) + else: + self._fit_to_fullscreen() + e.accept() + return + + if abs(self.target_scale - self.fit_scale) < 0.01: + self.zoom_to(1.0, e.position()) + else: + self.fit_to_screen() + + e.accept() + + def animate(self): + """Main animation loop - optimized""" + needs_update = False + + # Navigation slide animation with improved easing + if self.navigation_animation: + # Use smoother easing curve + self.navigation_progress = min(1.0, self.navigation_progress + self.NAVIGATION_SPEED) + + if self.navigation_progress >= 1.0: + self.navigation_animation = False + + # Handle animated images + if self.new_pixmap: + # Stop any existing movie + if self.movie: + self.movie.stop() + self.movie.deleteLater() + self.movie = None + + self.pixmap = self.new_pixmap + self.new_pixmap = None + self._invalidate_pixmap_cache() + + self.old_pixmap = None + + # Smooth transition to centered position + _, screen_center = self._get_screen_info() + self.target_offset = screen_center + + # Reset to fit scale for new image + current_pixmap = self._get_current_pixmap() + if current_pixmap and not current_pixmap.isNull(): + screen_geom, _ = self._get_screen_info() + scale_x = (screen_geom.width() * 0.9) / current_pixmap.width() + scale_y = (screen_geom.height() * 0.9) / current_pixmap.height() + self.fit_scale = min(scale_x, scale_y, 1.0) + self.target_scale = self.fit_scale + + needs_update = True + + # Opening animation + if self.opening_animation: + self.opening_scale = min(1.0, self.opening_scale + (1.0 - self.opening_scale) * 0.15) + self.opening_opacity = min(1.0, self.opening_opacity + (1.0 - self.opening_opacity) * 0.2) + + if self.opening_scale > 0.99 and self.opening_opacity > 0.99: + self.opening_scale = 1.0 + self.opening_opacity = 1.0 + self.opening_animation = False + + needs_update = True + + # Closing animation + if self.closing_animation: + self.closing_scale += (0.7 - self.closing_scale) * 0.25 + self.closing_opacity += (0.0 - self.closing_opacity) * 0.25 + needs_update = True + + # Background fade animation + bg_diff = self.target_background_opacity - self.background_opacity + if abs(bg_diff) > 1.0: + self.background_opacity += bg_diff * 0.15 + needs_update = True + + # Pan inertia + if not self.is_panning and (abs(self.pan_velocity.x()) > 0.1 or abs(self.pan_velocity.y()) > 0.1): + self.target_offset += self.pan_velocity + self.pan_velocity *= self.pan_friction + needs_update = True + + # Smooth interpolation to target values + scale_diff = self.target_scale - self.current_scale + offset_diff = self.target_offset - self.current_offset + + if abs(scale_diff) > 0.001: + self.current_scale += scale_diff * self.lerp_factor + needs_update = True + + if abs(offset_diff.x()) > 0.1 or abs(offset_diff.y()) > 0.1: + self.current_offset += offset_diff * self.lerp_factor + needs_update = True + + if needs_update: + self.schedule_update() + + def schedule_update(self): + """Schedule update to avoid excessive redraws""" + if not self.update_pending: + self.update_pending = True + QTimer.singleShot(0, self._do_update) + + def _do_update(self): + """Perform the actual update""" + self.update_pending = False + self.update() + + def dragEnterEvent(self, event): + """Handle drag enter""" + if event.mimeData().hasUrls(): + event.accept() + else: + event.ignore() + + def dropEvent(self, event): + """Handle file drop""" + urls = event.mimeData().urls() + if urls: + path = urls[0].toLocalFile() + if Path(path).is_file(): + self.load_image(path) + + def keyPressEvent(self, e): + """Handle keyboard input""" + if e.key() == Qt.Key_Escape: + self.close_application() + return + + if e.key() == Qt.Key_F11: + self.toggle_fullscreen() + e.accept() + return + + # Directory navigation with A and D keys + key_text = e.text().lower() + if e.key() == Qt.Key_A or key_text == 'a' or key_text == 'ф': + self.navigate_to_image(-1) + e.accept() + return + elif e.key() == Qt.Key_D or key_text == 'd' or key_text == 'в': + self.navigate_to_image(1) + e.accept() + return + + # Zoom with +/- keys + if e.key() == Qt.Key_Plus or e.key() == Qt.Key_Equal: + self._keyboard_zoom(self.ZOOM_FACTOR) + e.accept() + return + elif e.key() == Qt.Key_Minus: + self._keyboard_zoom(1.0 / self.ZOOM_FACTOR) + e.accept() + return + + # Copy to clipboard + if e.modifiers() & Qt.ControlModifier: + if e.key() == Qt.Key_C or key_text == 'c' or key_text == 'с': + current_pixmap = self._get_current_pixmap() + if current_pixmap and not current_pixmap.isNull(): + QGuiApplication.clipboard().setPixmap(current_pixmap) + return + + # Rotate + if e.key() == Qt.Key_R or key_text == 'r' or key_text == 'к': + self.rotation = (self.rotation + 90) % 360 + self._invalidate_pixmap_cache() + if self.is_fullscreen: + self._fit_to_fullscreen_instant() + else: + # Recalculate fit scale after rotation + self.fit_to_screen() + return + + # Fit to screen + if (e.key() == Qt.Key_F or key_text == 'f' or key_text == 'а' or + e.key() == Qt.Key_Space): + if self.is_fullscreen: + self._fit_to_fullscreen() + else: + self.fit_to_screen() + return + + super().keyPressEvent(e) + + def _keyboard_zoom(self, factor: float): + """Handle keyboard zoom with given factor""" + if self.pixmap or self.movie: + _, screen_center = self._get_screen_info() + new_scale = self.target_scale * factor + self.zoom_to(new_scale, screen_center) + + def _ease_in_out_cubic(self, t: float) -> float: + """Smooth easing function for animations""" + if t < 0.5: + return 4 * t * t * t + else: + p = 2 * t - 2 + return 1 + p * p * p / 2 + + def paintEvent(self, event): + """Main paint event - optimized""" + painter = QPainter(self) + painter.setRenderHint(QPainter.SmoothPixmapTransform, True) + painter.setRenderHint(QPainter.Antialiasing, True) + + # Draw dark background with smooth fade + bg_color = QColor(0, 0, 0, int(self.background_opacity)) + painter.fillRect(self.rect(), bg_color) + + # Navigation slide animation + if self.navigation_animation and self.old_pixmap and self.new_pixmap: + self._draw_slide_animation(painter) + elif self.pixmap: + self._draw_single_image(painter, self.pixmap) + elif self.movie and self.movie.state() == QMovie.MovieState.Running: + current_pixmap = self.movie.currentPixmap() + if not current_pixmap.isNull(): + self._draw_single_image(painter, current_pixmap) + + def _draw_slide_animation(self, painter): + """Draw sliding animation between two images - improved smoothness""" + # Use smooth easing curve + t = self._ease_in_out_cubic(self.navigation_progress) + + screen_width = self.width() + # Reduced slide distance for less jarring transition + slide_distance = screen_width * 0.8 + + # Add parallax effect for depth + parallax_factor = 0.3 + + if self.navigation_direction > 0: # Next image + old_x_offset = -slide_distance * t * parallax_factor + new_x_offset = slide_distance * (1 - t) + else: # Previous image + old_x_offset = slide_distance * t * parallax_factor + new_x_offset = -slide_distance * (1 - t) + + # Draw old image with fade and scale + painter.save() + painter.translate(old_x_offset, 0) + old_scale = 1.0 - t * 0.05 # Subtle scale down + painter.scale(old_scale, old_scale) + painter.setOpacity(1.0 - t * 0.5) # Smoother fade + self._draw_single_image(painter, self.old_pixmap) + painter.restore() + + # Draw new image with fade and scale + painter.save() + painter.translate(new_x_offset, 0) + new_scale = 0.95 + t * 0.05 # Scale up to normal + painter.scale(new_scale, new_scale) + painter.setOpacity(0.5 + t * 0.5) # Fade in + self._draw_single_image(painter, self.new_pixmap) + painter.restore() + + def _draw_single_image(self, painter, pixmap): + """Draw a single image with current transforms""" + if not pixmap or pixmap.isNull(): + return + + # Calculate image dimensions + final_scale = self.current_scale + if self.opening_animation: + final_scale *= self.opening_scale + elif self.closing_animation: + final_scale *= self.closing_scale + + img_w = pixmap.width() * final_scale + img_h = pixmap.height() * final_scale + + # Draw image + painter.save() + painter.translate(self.current_offset) + + if self.rotation != 0: + painter.rotate(self.rotation) + + # Set opacity for animations + current_opacity = painter.opacity() + if self.opening_animation: + painter.setOpacity(current_opacity * self.opening_opacity) + elif self.closing_animation: + painter.setOpacity(current_opacity * self.closing_opacity) + + # Draw the pixmap centered + target_rect = QRectF(-img_w / 2, -img_h / 2, img_w, img_h) + source_rect = QRectF(pixmap.rect()) + + painter.drawPixmap(target_rect, pixmap, source_rect) + painter.restore() + + def closeEvent(self, event): + """Clean up on close""" + self._stop_loading_thread() + + # Clean up movie + if self.movie: + self.movie.stop() + self.movie.deleteLater() + self.movie = None + + super().closeEvent(event) + + +def main(): + """Main entry point for the application""" + app = QApplication(sys.argv) + + path = None + if len(sys.argv) >= 2: + path = sys.argv[1] + + viewer = BlurViewer(path) + viewer.show() + + sys.exit(app.exec()) + + +if __name__ == '__main__': + main() diff --git a/legacy/blurviewer/CONTRIBUTING.md b/legacy/blurviewer/CONTRIBUTING.md new file mode 100644 index 0000000..94220e5 --- /dev/null +++ b/legacy/blurviewer/CONTRIBUTING.md @@ -0,0 +1,123 @@ +# Contributing to BlurViewer + +Thank you for your interest in contributing to BlurViewer! This document provides guidelines and information for contributors. + +## 🚀 Quick Start + +1. **Fork** the repository +2. **Clone** your fork: `git clone https://github.com/yourusername/BlurViewer.git` +3. **Create** a feature branch: `git checkout -b feature/amazing-feature` +4. **Make** your changes and test thoroughly +5. **Commit** with clear messages: `git commit -m 'Add amazing feature'` +6. **Push** to your branch: `git push origin feature/amazing-feature` +7. **Create** a Pull Request with detailed description + +## 🛠️ Development Setup + +### Prerequisites +- Python 3.8 or higher +- Git + +### Installation +```bash +# Clone the repository +git clone https://github.com/amtiYo/BlurViewer.git +cd BlurViewer + +# Create virtual environment +python -m venv .venv +source .venv/bin/activate # On Windows: .venv\Scripts\activate + +# Install dependencies +pip install -e . + +# Run the application +python BlurViewer.py +``` + +### Using Makefile (optional) +```bash +make install-package # Install package in development mode +make run # Run the application +make format # Format code +make lint # Run linting +``` + +## 📝 Code Style + +- Follow PEP 8 style guidelines +- Use meaningful variable and function names +- Add comments for complex logic +- Keep functions small and focused +- Write docstrings for public functions + +### Code Formatting +```bash +# Format code with black +black BlurViewer.py + +# Check code style with flake8 +flake8 BlurViewer.py +``` + +## 🧪 Testing + +Before submitting a pull request, please ensure: + +1. **Code runs without errors** - Test the application thoroughly +2. **No new warnings** - Fix any linting issues +3. **Backward compatibility** - Don't break existing functionality +4. **Cross-platform compatibility** - Test on different operating systems if possible + +## 📋 Pull Request Guidelines + +### Before submitting a PR: + +1. **Update documentation** if needed +2. **Add tests** for new features +3. **Update requirements.txt** if adding new dependencies +4. **Test on different image formats** if making changes to image processing +5. **Check performance** - ensure no significant performance regressions + +### PR Description should include: + +- **Summary** of changes +- **Motivation** for the change +- **Testing** performed +- **Screenshots** if UI changes +- **Breaking changes** if any + +## 🐛 Bug Reports + +When reporting bugs, please include: + +- **Operating system** and version +- **Python version** +- **Steps to reproduce** +- **Expected behavior** +- **Actual behavior** +- **Screenshots** if applicable +- **Error messages** if any + +## 💡 Feature Requests + +When suggesting features: + +- **Describe the feature** clearly +- **Explain the use case** +- **Provide examples** if possible +- **Consider implementation complexity** + +## 📄 License + +By contributing to BlurViewer, you agree that your contributions will be licensed under the MIT License. + +## 🤝 Questions? + +If you have questions about contributing, feel free to: + +- Open an issue for discussion +- Ask in the discussions section +- Contact the maintainer directly + +Thank you for contributing to BlurViewer! 🎉 diff --git a/LICENSE b/legacy/blurviewer/LICENSE similarity index 98% rename from LICENSE rename to legacy/blurviewer/LICENSE index 0e27856..c2cbfb6 100644 --- a/LICENSE +++ b/legacy/blurviewer/LICENSE @@ -1,21 +1,21 @@ -MIT License - -Copyright (c) 2025 Артемий (Amti_Yo) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +MIT License + +Copyright (c) 2025 Артемий (Amti_Yo) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/legacy/blurviewer/Makefile b/legacy/blurviewer/Makefile new file mode 100644 index 0000000..7cfb949 --- /dev/null +++ b/legacy/blurviewer/Makefile @@ -0,0 +1,45 @@ +.PHONY: help install run test clean build dist + +help: ## Show this help message + @echo "Available commands:" + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' + +install: ## Install dependencies + pip install -e . + +install-dev: ## Install development dependencies + pip install -e . + pip install pytest black flake8 mypy + +run: ## Run the application + python BlurViewer.py + +test: ## Run tests + pytest + +lint: ## Run linting + black BlurViewer.py + flake8 BlurViewer.py + mypy BlurViewer.py + +format: ## Format code + black BlurViewer.py + +clean: ## Clean build artifacts + rm -rf build/ + rm -rf dist/ + rm -rf *.egg-info/ + find . -type d -name __pycache__ -delete + find . -type f -name "*.pyc" -delete + +build: ## Build the package + python -m build + +dist: clean ## Create distribution + python -m build + +install-package: ## Install the package in development mode + pip install -e . + +uninstall: ## Uninstall the package + pip uninstall blurviewer -y diff --git a/legacy/blurviewer/README.de.md b/legacy/blurviewer/README.de.md new file mode 100644 index 0000000..ae05528 --- /dev/null +++ b/legacy/blurviewer/README.de.md @@ -0,0 +1,210 @@ +

📸 BlurViewer

+
+ +[English](./README.md) | [Русский](./README.ru.md) | Deutsch + +**BlurViewer** - Professioneller Bildbetrachter mit erweiteter Formatunterstützung und flüssigen Animationen. +Blitzschnelle, minimalistische und funktionsreiche Foto-Betrachtungserfahrung für Windows. + +![Release Download](https://img.shields.io/github/downloads/amtiYo/BlurViewer/total?style=flat-square) +[![Release Version](https://img.shields.io/github/v/release/amtiYo/BlurViewer?style=flat-square)](https://github.com/amtiYo/BlurViewer/releases/latest) +[![GitHub license](https://img.shields.io/github/license/amtiYo/BlurViewer?style=flat-square)](LICENSE) +[![GitHub Star](https://img.shields.io/github/stars/amtiYo/BlurViewer?style=flat-square)](https://github.com/amtiYo/BlurViewer/stargazers) +[![GitHub Fork](https://img.shields.io/github/forks/amtiYo/BlurViewer?style=flat-square)](https://github.com/amtiYo/BlurViewer/network/members) +![GitHub Repo size](https://img.shields.io/github/repo-size/amtiYo/BlurViewer?style=flat-square&color=3cb371) + +Ein Bildbetrachter der nächsten Generation mit **universeller Formatunterstützung**, **immersivem Vollbildmodus** und **professioneller Leistung**. Entwickelt für Fotografen, Designer und anspruchsvolle Benutzer. +
+ +## ✨ Hauptfunktionen + +### 🖥️ **Immersive Vollbild-Erfahrung** +- **F11 Echter Vollbildmodus** mit adaptiver Hintergrundverdunkelung +- **Intelligente Zoom-Steuerung** - frei hineinzoomen, begrenztes Herauszoomen für perfekte Bildausschnitte +- **Flüssige Ein-/Ausgangsanimationen** mit Zustandserhaltung +- **Verbesserte Navigation** - nur ESC/Rechtsklick für sicheres Betrachten + +### ⌨️ **Intuitive Bedienung** +- **A/D Navigation** - Mühelos durch Bilder blättern (unterstützt kyrillisches А/В) +- **+/- Zentrum-Zoom** - Präzise Zoom-Kontrolle zum Bildzentrum +- **Sofortige Drehung** - R-Taste mit Auto-Anpassung in allen Modi +- **Intelligentes Schwenken** - Bilder ziehen mit Trägheitsscrollen + +### 🎨 **Erweiterte Animations-System** +- **Kontextabhängige Animationen** - flüssig im Fenstermodus, sofortig im Vollbildmodus +- **Übergangs-Animationen** mit kubischer Glättung zwischen Bildern +- **Professionelle Öffnungs-/Schließeffekte** mit Überblendungsanimationen + +### 📁 **Universelle Formatunterstützung** +- **RAW-Formate**: CR2, CR3, NEF, ARW, DNG, RAF, ORF, RW2, PEF und 20+ weitere +- **Moderne Formate**: HEIC/HEIF, AVIF, WebP, JXL +- **Animierte**: GIF, WebP-Animationen (Anzeige des ersten Frames) +- **Wissenschaftlich**: FITS, HDR, EXR und spezialisierte Formate +- **Standard**: PNG, JPEG, BMP, TIFF, SVG, ICO und mehr + +### ⚡ **Leistungsoptimierungen** +- **15% schnellere** Darstellung dank optimiertem Code +- **Hardware-beschleunigte** flüssige Zoom- und Schwenkfunktionen +- **Hintergrundladen** für sofortiges Bildwechseln +- **Speichereffizient** mit intelligentem Caching + +## 🎮 Vollständige Steuerungsreferenz + +### Grundlegende Steuerung +| Aktion | Tasten | Beschreibung | +|--------|--------|--------------| +| **Vollbild umschalten** | `F11` | Immersiven Vollbildmodus ein-/ausschalten | +| **Bilder navigieren** | `A` / `D` | Vorheriges/Nächstes Bild im Ordner | +| **Hinein-/Herauszoomen** | `+` / `-` | Zentrum-fokussierte Zoom-Steuerung | +| **An Bildschirm anpassen** | `F` / `Leertaste` | Intelligente Anpassung mit Animationen | +| **Bild drehen** | `R` | 90°-Drehung mit Auto-Anpassung | +| **Bild kopieren** | `Strg+C` | In Zwischenablage kopieren | +| **Anwendung beenden** | `Esc` / `Rechtsklick` | Betrachter schließen | + +### Maussteuerung +- **Scrollrad**: Hinein-/Herauszoomen an Cursorposition +- **Linksklick + Ziehen**: Bild schwenken +- **Doppelklick**: Zwischen Bildschirmanpassung und 100%-Skalierung wechseln +- **Drag & Drop**: Neue Bilder sofort öffnen + +## 🚀 Schnellstart + +### Download & Ausführung +1. **Laden** Sie die neueste `BlurViewer.exe` aus den [Releases](https://github.com/amtiYo/BlurViewer/releases/latest) herunter +2. **Keine Installation erforderlich** - führen Sie einfach die ausführbare Datei aus +3. **Ziehen Sie Bilder** in das Fenster oder verwenden Sie Datei → Öffnen + +### Als Standard-Betrachter festlegen +- **Windows 11**: Einstellungen → Apps → Standard-Apps → BlurViewer für Bilddateien wählen +- **Windows 10**: Einstellungen → System → Standard-Apps → Foto-Betrachter → BlurViewer +- **Schnelle Methode**: Rechtsklick auf Bild → Öffnen mit → Andere App auswählen → BlurViewer wählen → Immer diese App verwenden + +### Profi-Tipps +- **Ordner durchsuchen**: Öffnen Sie ein beliebiges Bild, verwenden Sie A/D-Tasten um den ganzen Ordner zu durchsuchen +- **Schneller Vollbildmodus**: F11 für ablenkungsfreie Betrachtung mit perfekter Bildanpassung +- **Schnelle Navigation**: Verwenden Sie +/- für präzisen Zoom, F/Leertaste zum Zurücksetzen +- **Sofortiges Kopieren**: Strg+C um das aktuelle Bild in die Zwischenablage zu kopieren + +## 🖼️ Unterstützte Formate + +
+📷 RAW-Kameraformate (20+) + +- **Canon**: CR2, CR3 +- **Nikon**: NEF +- **Sony**: ARW +- **Adobe**: DNG +- **Fujifilm**: RAF +- **Olympus**: ORF +- **Panasonic**: RW2 +- **Pentax**: PEF, PTX +- **Samsung**: SRW +- **Sigma**: X3F +- **Minolta**: MRW +- **Kodak**: DCR, KDC +- **Epson**: ERF +- **Mamiya**: MEF +- **Leaf**: MOS +- **Phase One**: IIQ +- **Red**: R3D +- **Hasselblad**: 3FR, FFF +
+ +
+🆕 Moderne & Next-Gen-Formate + +- **HEIC/HEIF**: Apple Photos-Format mit vollständigen Metadaten +- **AVIF**: Next-Generation-Format mit überlegener Kompression +- **WebP**: Googles effizientes Web-Format (statisch & animiert) +- **JXL**: JPEG XL für zukunftssichere Archivierung +
+ +
+🎬 Animierte & Standard-Formate + +- **Animiert**: GIF, WebP (Anzeige des ersten Frames) +- **Standard**: PNG, JPEG/JPG, BMP, TIFF/TIF +- **Vektor**: SVG (gerasterte Anzeige) +- **Legacy**: ICO, XBM, XPM, PBM, PGM, PPM +
+ +
+🔬 Wissenschaftliche & Professionelle Formate + +- **Astronomie**: FITS-Dateien +- **HDR**: HDR, EXR (hoher Dynamikbereich) +- **Design**: PSD (Photoshop-Ebenen, teilweise Unterstützung) +- **Medizin**: DICOM (grundlegende Unterstützung) +
+ +## 🔧 Erweiterte Funktionen + +### Intelligenter Vollbildmodus +- **Adaptive Anpassung**: Bilder passen sich automatisch an Bildschirmdimensionen mit Drehungsberücksichtigung an +- **Verbesserter Hintergrund**: 25% dunklerer Hintergrund für bessere Fokussierung +- **Eingeschränkter Ausgang**: Nur ESC/Rechtsklick verhindert versehentliches Schließen +- **Zustandserhaltung**: Kehrt zu exaktem Zoom/Position beim Beenden zurück + +### Intelligente Navigation +- **Auto-Erkennung**: Findet automatisch alle Bilder im selben Ordner +- **Nahtloses Durchsuchen**: A/D-Tasten funktionieren bei jeder Zoom-Stufe +- **Intelligente Zentrierung**: Neue Bilder werden automatisch zentriert unter Beibehaltung des Zooms +- **Drehungsreset**: Jedes neue Bild startet bei 0°-Drehung + +### Leistungs-Engineering +- **Hintergrund-Threading**: Bilder laden ohne UI-Blockierung +- **Intelligentes Caching**: Effiziente Speichernutzung für große Dateien +- **Hardware-Beschleunigung**: GPU-unterstützte Darstellung wo verfügbar +- **Optimierte Animationen**: 60 FPS flüssige Interpolation + +## 🤝 Mitwirken + +Wir begrüßen Beiträge! So fangen Sie an: + +1. **Forken** Sie das Repository +2. **Klonen** Sie Ihren Fork: `git clone https://github.com/yourusername/BlurViewer.git` +3. **Erstellen** Sie einen Feature-Branch: `git checkout -b feature/amazing-feature` +4. **Machen** Sie Ihre Änderungen und testen Sie gründlich +5. **Committen** Sie mit klaren Nachrichten: `git commit -m 'Add amazing feature'` +6. **Pushen** Sie zu Ihrem Branch: `git push origin feature/amazing-feature` +7. **Erstellen** Sie einen Pull Request mit detaillierter Beschreibung + +### Entwicklungsumgebung einrichten +```bash +# Repository klonen und im Entwicklungsmodus installieren +git clone https://github.com/amtiYo/BlurViewer.git +cd BlurViewer +pip install -e . + +# Anwendung ausführen +python BlurViewer.py +``` + +## 📝 Lizenz + +Dieses Projekt ist unter der **MIT-Lizenz** lizenziert - siehe [LICENSE](LICENSE)-Datei für Details. + +## 🔗 Erstellt mit + +- **[PySide6](https://doc.qt.io/qtforpython/)** - Moderne Qt-Bindungen für Python +- **[Pillow](https://pillow.readthedocs.io/)** - Python Imaging Library +- **[rawpy](https://github.com/letmaik/rawpy)** - RAW-Bildverarbeitung +- **[ImageIO](https://imageio.github.io/)** - Wissenschaftliche Bild-E/A +- **[OpenCV](https://opencv.org/)** - Computer Vision Bibliothek + +## 🙏 Danksagungen + +- **libraw**-Team für RAW-Verarbeitungsfähigkeiten +- **Qt Project** für das exzellente GUI-Framework +- **Python-Community** für das großartige Ökosystem +- **Mitwirkende**, die helfen, BlurViewer besser zu machen + +--- + +
+ +**⭐ Bewerten Sie dieses Repository mit einem Stern, wenn BlurViewer Ihr Foto-Betrachtungserlebnis verbessert!** + +Mit ❤️ für Fotografen, Designer und visuelle Enthusiasten weltweit gemacht. + +
diff --git a/legacy/blurviewer/README.md b/legacy/blurviewer/README.md new file mode 100644 index 0000000..58d05ee --- /dev/null +++ b/legacy/blurviewer/README.md @@ -0,0 +1,210 @@ +

📸 BlurViewer

+
+ +English | [Русский](./README.ru.md) | [Deutsch](./README.de.md) + +**BlurViewer** - Professional image viewer with advanced format support and smooth animations. +Lightning-fast, minimalist, and feature-rich photo viewing experience for Windows. + +![Release Download](https://img.shields.io/github/downloads/amtiYo/BlurViewer/total?style=flat-square) +[![Release Version](https://img.shields.io/github/v/release/amtiYo/BlurViewer?style=flat-square)](https://github.com/amtiYo/BlurViewer/releases/latest) +[![GitHub license](https://img.shields.io/github/license/amtiYo/BlurViewer?style=flat-square)](LICENSE) +[![GitHub Star](https://img.shields.io/github/stars/amtiYo/BlurViewer?style=flat-square)](https://github.com/amtiYo/BlurViewer/stargazers) +[![GitHub Fork](https://img.shields.io/github/forks/amtiYo/BlurViewer?style=flat-square)](https://github.com/amtiYo/BlurViewer/network/members) +![GitHub Repo size](https://img.shields.io/github/repo-size/amtiYo/BlurViewer?style=flat-square&color=3cb371) + +A next-generation image viewer with **universal format support**, **immersive fullscreen mode**, and **professional-grade performance**. Built for photographers, designers, and power users. +
+ +## ✨ Key Features + +### 🖥️ **Immersive Fullscreen Experience** +- **F11 True Fullscreen** with adaptive background darkening +- **Smart zoom controls** - zoom in freely, limited zoom out for perfect framing +- **Smooth entry/exit animations** with state preservation +- **Enhanced navigation** - ESC/right-click only for safer viewing + +### ⌨️ **Intuitive Controls** +- **A/D Navigation** - Browse images effortlessly (supports Cyrillic А/В) +- **+/- Center Zoom** - Precise zoom control toward image center +- **Instant Rotation** - R key with auto-fitting in all modes +- **Smart Panning** - Drag images with inertial scrolling + +### 🎨 **Advanced Animation System** +- **Context-aware animations** - smooth in windowed, instant in fullscreen +- **Slide transitions** with cubic easing between images +- **Professional opening/closing** effects with fade animations + +### 📁 **Universal Format Support** +- **RAW Formats**: CR2, CR3, NEF, ARW, DNG, RAF, ORF, RW2, PEF, and 20+ more +- **Modern Formats**: HEIC/HEIF, AVIF, WebP, JXL +- **Animated**: GIF, WebP animations (first frame display) +- **Scientific**: FITS, HDR, EXR, and specialized formats +- **Standard**: PNG, JPEG, BMP, TIFF, SVG, ICO, and more + +### ⚡ **Performance Optimizations** +- **15% faster** rendering with optimized codebase +- **Hardware-accelerated** smooth zoom and pan +- **Background loading** for instant image switching +- **Memory efficient** with smart caching + +## 🎮 Complete Control Reference + +### Essential Controls +| Action | Keys | Description | +|--------|------|-------------| +| **Fullscreen Toggle** | `F11` | Enter/exit immersive fullscreen mode | +| **Navigate Images** | `A` / `D` | Previous/Next image in folder | +| **Zoom In/Out** | `+` / `-` | Center-focused zoom control | +| **Fit to Screen** | `F` / `Space` | Smart fit with animations | +| **Rotate Image** | `R` | 90° rotation with auto-fitting | +| **Copy Image** | `Ctrl+C` | Copy to system clipboard | +| **Exit Application** | `Esc` / `Right Click` | Close viewer | + +### Mouse Controls +- **Scroll Wheel**: Zoom in/out at cursor position +- **Left Click + Drag**: Pan image around +- **Double Click**: Toggle between fit-to-screen and 100% scale +- **Drag & Drop**: Open new images instantly + +## 🚀 Getting Started + +### Download & Run +1. **Download** the latest `BlurViewer.exe` from [Releases](https://github.com/amtiYo/BlurViewer/releases/latest) +2. **No installation required** - just run the executable +3. **Drag images** into the window or use File → Open + +### Set as Default Viewer +- **Windows 11**: Settings → Apps → Default apps → Choose BlurViewer for image files +- **Windows 10**: Settings → System → Default apps → Photo viewer → BlurViewer +- **Quick method**: Right-click any image → Open with → Choose BlurViewer → Always use this app + +### Pro Tips +- **Folder browsing**: Open any image, use A/D keys to browse the entire folder +- **Quick fullscreen**: F11 for distraction-free viewing with perfect image fitting +- **Fast navigation**: Use +/- for precise zoom, F/Space to reset fit +- **Instant copy**: Ctrl+C to copy current image to clipboard + +## 🖼️ Supported Formats + +
+📷 RAW Camera Formats (20+) + +- **Canon**: CR2, CR3 +- **Nikon**: NEF +- **Sony**: ARW +- **Adobe**: DNG +- **Fujifilm**: RAF +- **Olympus**: ORF +- **Panasonic**: RW2 +- **Pentax**: PEF, PTX +- **Samsung**: SRW +- **Sigma**: X3F +- **Minolta**: MRW +- **Kodak**: DCR, KDC +- **Epson**: ERF +- **Mamiya**: MEF +- **Leaf**: MOS +- **Phase One**: IIQ +- **Red**: R3D +- **Hasselblad**: 3FR, FFF +
+ +
+🆕 Modern & Next-Gen Formats + +- **HEIC/HEIF**: Apple Photos format with full metadata +- **AVIF**: Next-generation format with superior compression +- **WebP**: Google's efficient web format (static & animated) +- **JXL**: JPEG XL for future-proof archiving +
+ +
+🎬 Animated & Standard Formats + +- **Animated**: GIF, WebP (first frame display) +- **Standard**: PNG, JPEG/JPG, BMP, TIFF/TIF +- **Vector**: SVG (rasterized display) +- **Legacy**: ICO, XBM, XPM, PBM, PGM, PPM +
+ +
+🔬 Scientific & Professional + +- **Astronomy**: FITS files +- **HDR**: HDR, EXR (high dynamic range) +- **Design**: PSD (Photoshop layers, partial support) +- **Medical**: DICOM (basic support) +
+ +## 🔧 Advanced Features + +### Smart Fullscreen Mode +- **Adaptive fitting**: Images auto-fit to screen dimensions with rotation awareness +- **Enhanced background**: 25% darker backdrop for better focus +- **Restricted exit**: Only ESC/right-click prevents accidental closure +- **State preservation**: Returns to exact zoom/position when exiting + +### Intelligent Navigation +- **Auto-discovery**: Automatically finds all images in the same folder +- **Seamless browsing**: A/D keys work in any zoom level +- **Smart centering**: New images center automatically while preserving zoom +- **Rotation reset**: Each new image starts at 0° rotation + +### Performance Engineering +- **Background threading**: Images load without UI blocking +- **Smart caching**: Efficient memory usage for large files +- **Hardware acceleration**: GPU-assisted rendering where available +- **Optimized animations**: 60 FPS smooth interpolation + +## 🤝 Contributing + +We welcome contributions! Here's how to get started: + +1. **Fork** the repository +2. **Clone** your fork: `git clone https://github.com/yourusername/BlurViewer.git` +3. **Create** a feature branch: `git checkout -b feature/amazing-feature` +4. **Make** your changes and test thoroughly +5. **Commit** with clear messages: `git commit -m 'Add amazing feature'` +6. **Push** to your branch: `git push origin feature/amazing-feature` +7. **Create** a Pull Request with detailed description + +### Development Setup +```bash +# Clone and install in development mode +git clone https://github.com/amtiYo/BlurViewer.git +cd BlurViewer +pip install -e . + +# Run the application +python BlurViewer.py +``` + +## 📝 License + +This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) file for details. + +## 🔗 Built With + +- **[PySide6](https://doc.qt.io/qtforpython/)** - Modern Qt bindings for Python +- **[Pillow](https://pillow.readthedocs.io/)** - Python Imaging Library +- **[rawpy](https://github.com/letmaik/rawpy)** - RAW image processing +- **[ImageIO](https://imageio.github.io/)** - Scientific image I/O +- **[OpenCV](https://opencv.org/)** - Computer vision library + +## 🙏 Acknowledgments + +- **libraw** team for RAW processing capabilities +- **Qt Project** for the excellent GUI framework +- **Python community** for the amazing ecosystem +- **Contributors** who help make BlurViewer better + +--- + +
+ +**⭐ Star this repository if BlurViewer enhances your photo viewing experience!** + +Made with ❤️ for photographers, designers, and visual enthusiasts worldwide. + +
diff --git a/legacy/blurviewer/README.ru.md b/legacy/blurviewer/README.ru.md new file mode 100644 index 0000000..acc177b --- /dev/null +++ b/legacy/blurviewer/README.ru.md @@ -0,0 +1,210 @@ +

📸 BlurViewer

+
+ +[English](./README.md) | Русский | [Deutsch](./README.de.md) + +**BlurViewer** - Профессиональный просмотрщик изображений с расширенной поддержкой форматов и плавными анимациями. +Молниеносно быстрый, минималистичный и многофункциональный просмотрщик фото для Windows. + +![Release Download](https://img.shields.io/github/downloads/amtiYo/BlurViewer/total?style=flat-square) +[![Release Version](https://img.shields.io/github/v/release/amtiYo/BlurViewer?style=flat-square)](https://github.com/amtiYo/BlurViewer/releases/latest) +[![GitHub license](https://img.shields.io/github/license/amtiYo/BlurViewer?style=flat-square)](LICENSE) +[![GitHub Star](https://img.shields.io/github/stars/amtiYo/BlurViewer?style=flat-square)](https://github.com/amtiYo/BlurViewer/stargazers) +[![GitHub Fork](https://img.shields.io/github/forks/amtiYo/BlurViewer?style=flat-square)](https://github.com/amtiYo/BlurViewer/network/members) +![GitHub Repo size](https://img.shields.io/github/repo-size/amtiYo/BlurViewer?style=flat-square&color=3cb371) + +Просмотрщик изображений нового поколения с **универсальной поддержкой форматов**, **погружающим полноэкранным режимом** и **профессиональной производительностью**. Создан для фотографов, дизайнеров и требовательных пользователей. +
+ +## ✨ Ключевые возможности + +### 🖥️ **Погружающий полноэкранный режим** +- **F11 Настоящий полноэкранный режим** с адаптивным затемнением фона +- **Умное управление зумом** - свободное приближение, ограниченное отдаление для идеального кадрирования +- **Плавные анимации входа/выхода** с сохранением состояния +- **Улучшенная навигация** - только ESC/правый клик для безопасного просмотра + +### ⌨️ **Интуитивное управление** +- **Навигация A/Д** - Просматривайте изображения без усилий (поддержка кириллицы А/В) +- **Зум +/-** - Точное управление зумом к центру изображения +- **Мгновенный поворот** - Клавиша R с авто-подгонкой во всех режимах +- **Умное перетаскивание** - Перетаскивание изображений с инерционной прокруткой + +### 🎨 **Продвинутая система анимации** +- **Контекстно-зависимые анимации** - плавные в оконном режиме, мгновенные в полноэкранном +- **Слайд-переходы** с кубическим сглаживанием между изображениями +- **Профессиональные эффекты** открытия/закрытия с плавным появлением + +### 📁 **Универсальная поддержка форматов** +- **RAW форматы**: CR2, CR3, NEF, ARW, DNG, RAF, ORF, RW2, PEF и 20+ других +- **Современные форматы**: HEIC/HEIF, AVIF, WebP, JXL +- **Анимированные**: GIF, WebP анимации (отображение первого кадра) +- **Научные**: FITS, HDR, EXR и специализированные форматы +- **Стандартные**: PNG, JPEG, BMP, TIFF, SVG, ICO и другие + +### ⚡ **Оптимизация производительности** +- **На 15% быстрее** отрисовка благодаря оптимизированному коду +- **Аппаратное ускорение** плавного зума и панорамирования +- **Фоновая загрузка** для мгновенного переключения изображений +- **Эффективное использование памяти** с умным кэшированием + +## 🎮 Полный справочник управления + +### Основные клавиши +| Действие | Клавиши | Описание | +|----------|---------|----------| +| **Переключить полноэкранный режим** | `F11` | Вход/выход из погружающего полноэкранного режима | +| **Навигация по изображениям** | `A` / `Д` | Предыдущее/Следующее изображение в папке | +| **Приближение/Отдаление** | `+` / `-` | Зум с фокусом на центр изображения | +| **Подогнать под экран** | `F` / `Пробел` | Умная подгонка с анимациями | +| **Повернуть изображение** | `R` | Поворот на 90° с авто-подгонкой | +| **Копировать изображение** | `Ctrl+C` | Копировать в буфер обмена | +| **Выйти из приложения** | `Esc` / `Правый клик` | Закрыть просмотрщик | + +### Управление мышью +- **Колесо прокрутки**: Приближение/отдаление в позиции курсора +- **Левый клик + перетаскивание**: Панорамирование изображения +- **Двойной клик**: Переключение между подгонкой под экран и масштабом 100% +- **Перетаскивание файлов**: Мгновенное открытие новых изображений + +## 🚀 Быстрый старт + +### Скачать и запустить +1. **Скачайте** последний `BlurViewer.exe` из раздела [Релизы](https://github.com/amtiYo/BlurViewer/releases/latest) +2. **Установка не требуется** - просто запустите исполняемый файл +3. **Перетащите изображения** в окно или используйте Файл → Открыть + +### Установить как просмотрщик по умолчанию +- **Windows 11**: Параметры → Приложения → Приложения по умолчанию → Выберите BlurViewer для файлов изображений +- **Windows 10**: Параметры → Система → Приложения по умолчанию → Просмотр фотографий → BlurViewer +- **Быстрый способ**: ПКМ по изображению → Открыть с помощью → Выбрать другое приложение → выберите BlurViewer → Всегда использовать это приложение + +### Профессиональные советы +- **Просмотр папки**: Откройте любое изображение, используйте клавиши A/Д для просмотра всей папки +- **Быстрый полноэкранный режим**: F11 для просмотра без отвлечений с идеальной подгонкой изображения +- **Быстрая навигация**: Используйте +/- для точного зума, F/Пробел для сброса подгонки +- **Мгновенное копирование**: Ctrl+C для копирования текущего изображения в буфер обмена + +## 🖼️ Поддерживаемые форматы + +
+📷 RAW форматы камер (20+) + +- **Canon**: CR2, CR3 +- **Nikon**: NEF +- **Sony**: ARW +- **Adobe**: DNG +- **Fujifilm**: RAF +- **Olympus**: ORF +- **Panasonic**: RW2 +- **Pentax**: PEF, PTX +- **Samsung**: SRW +- **Sigma**: X3F +- **Minolta**: MRW +- **Kodak**: DCR, KDC +- **Epson**: ERF +- **Mamiya**: MEF +- **Leaf**: MOS +- **Phase One**: IIQ +- **Red**: R3D +- **Hasselblad**: 3FR, FFF +
+ +
+🆕 Современные форматы нового поколения + +- **HEIC/HEIF**: Формат Apple Photos с полными метаданными +- **AVIF**: Формат нового поколения с превосходным сжатием +- **WebP**: Эффективный веб-формат Google (статичный и анимированный) +- **JXL**: JPEG XL для перспективного архивирования +
+ +
+🎬 Анимированные и стандартные форматы + +- **Анимированные**: GIF, WebP (отображение первого кадра) +- **Стандартные**: PNG, JPEG/JPG, BMP, TIFF/TIF +- **Векторные**: SVG (растеризованное отображение) +- **Устаревшие**: ICO, XBM, XPM, PBM, PGM, PPM +
+ +
+🔬 Научные и профессиональные форматы + +- **Астрономия**: FITS файлы +- **HDR**: HDR, EXR (высокий динамический диапазон) +- **Дизайн**: PSD (слои Photoshop, частичная поддержка) +- **Медицина**: DICOM (базовая поддержка) +
+ +## 🔧 Расширенные возможности + +### Умный полноэкранный режим +- **Адаптивная подгонка**: Изображения автоматически подгоняются под размеры экрана с учетом поворота +- **Улучшенный фон**: На 25% темнее фон для лучшей фокусировки +- **Ограниченный выход**: Только ESC/правый клик предотвращает случайное закрытие +- **Сохранение состояния**: Возвращается к точному зуму/позиции при выходе + +### Интеллектуальная навигация +- **Автообнаружение**: Автоматически находит все изображения в той же папке +- **Беспрепятственный просмотр**: Клавиши A/Д работают при любом уровне зума +- **Умное центрирование**: Новые изображения центрируются автоматически с сохранением зума +- **Сброс поворота**: Каждое новое изображение начинается с поворота 0° + +### Техническая оптимизация +- **Фоновые потоки**: Изображения загружаются без блокировки интерфейса +- **Умное кэширование**: Эффективное использование памяти для больших файлов +- **Аппаратное ускорение**: GPU-ускоренная отрисовка где доступно +- **Оптимизированные анимации**: 60 FPS плавная интерполяция + +## 🤝 Участие в разработке + +Мы приветствуем вклад в развитие! Вот как начать: + +1. **Создайте форк** репозитория +2. **Клонируйте** ваш форк: `git clone https://github.com/yourusername/BlurViewer.git` +3. **Создайте** ветку для функции: `git checkout -b feature/amazing-feature` +4. **Внесите** изменения и тщательно протестируйте +5. **Сделайте коммит** с понятными сообщениями: `git commit -m 'Add amazing feature'` +6. **Отправьте** в вашу ветку: `git push origin feature/amazing-feature` +7. **Создайте** Pull Request с подробным описанием + +### Настройка среды разработки +```bash +# Клонировать и установить в режиме разработки +git clone https://github.com/amtiYo/BlurViewer.git +cd BlurViewer +pip install -e . + +# Запустить приложение +python BlurViewer.py +``` + +## 📝 Лицензия + +Этот проект лицензирован под **лицензией MIT** - см. файл [LICENSE](LICENSE) для подробностей. + +## 🔗 Создано с использованием + +- **[PySide6](https://doc.qt.io/qtforpython/)** - Современные привязки Qt для Python +- **[Pillow](https://pillow.readthedocs.io/)** - Библиотека обработки изображений Python +- **[rawpy](https://github.com/letmaik/rawpy)** - Обработка RAW изображений +- **[ImageIO](https://imageio.github.io/)** - Научный ввод/вывод изображений +- **[OpenCV](https://opencv.org/)** - Библиотека компьютерного зрения + +## 🙏 Благодарности + +- Команде **libraw** за возможности обработки RAW +- **Qt Project** за отличный GUI фреймворк +- **Python сообществу** за потрясающую экосистему +- **Участникам**, которые помогают сделать BlurViewer лучше + +--- + +
+ +**⭐ Поставьте звезду этому репозиторию, если BlurViewer улучшил ваш опыт просмотра фотографий!** + +Сделано с ❤️ для фотографов, дизайнеров и визуальных энтузиастов по всему миру. + +
diff --git a/legacy/blurviewer/pyproject.toml b/legacy/blurviewer/pyproject.toml new file mode 100644 index 0000000..f433b77 --- /dev/null +++ b/legacy/blurviewer/pyproject.toml @@ -0,0 +1,162 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "blurviewer" +version = "0.8.1" +description = "Professional image viewer with advanced format support and smooth animations" +readme = "README.md" +license = {text = "MIT"} +authors = [ + {name = "amtiYo", email = "artemijtkacenko@gmail.com"} +] +maintainers = [ + {name = "amtiYo", email = "artemijtkacenko@gmail.com"} +] +keywords = [ + "image", "viewer", "photo", "gallery", "raw", "heic", "avif", + "desktop", "gui", "qt", "pyside6", "photography", "image-processing" +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Multimedia :: Graphics :: Viewers", + "Topic :: Desktop Environment", + "Topic :: Scientific/Engineering :: Image Processing", + "Topic :: Software Development :: User Interfaces", + "Topic :: Multimedia :: Graphics :: Graphics Conversion", +] +requires-python = ">=3.8" +dependencies = [ + "PySide6>=6.5.0", + "Pillow>=10.0.0", + "pillow-heif>=0.13.0", + "pillow-avif-plugin>=1.3.0", + "rawpy>=0.17.0", + "imageio>=2.31.0", + "opencv-python>=4.8.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "black>=23.0.0", + "flake8>=6.0.0", + "mypy>=1.0.0", + "pre-commit>=3.0.0", + "pytest-qt>=4.0.0", +] +build = [ + "build>=0.10.0", + "twine>=4.0.0", + "pyinstaller>=5.0.0", +] + +[project.urls] +Homepage = "https://github.com/amtiYo/BlurViewer" +Repository = "https://github.com/amtiYo/BlurViewer" +Issues = "https://github.com/amtiYo/BlurViewer/issues" +Documentation = "https://github.com/amtiYo/BlurViewer#readme" +"Bug Tracker" = "https://github.com/amtiYo/BlurViewer/issues" +"Source Code" = "https://github.com/amtiYo/BlurViewer" +"Download" = "https://github.com/amtiYo/BlurViewer/releases" +"Release Notes" = "https://github.com/amtiYo/BlurViewer/releases" + +[project.scripts] +blurviewer = "BlurViewer:main" + +[project.gui-scripts] +blurviewer-gui = "BlurViewer:main" + +[tool.setuptools] +packages = ["."] +include-package-data = true + +[tool.setuptools.package-data] +"*" = ["*.ico", "*.png", "*.gif"] + +[tool.setuptools.exclude-package-data] +"*" = ["*.pyc", "__pycache__", "*.spec", "build", "dist", ".venv", "test_*"] + +# Development tools configuration +[tool.black] +line-length = 88 +target-version = ['py38'] +include = '\.pyi?$' +extend-exclude = ''' +/( + # directories + \.eggs + | \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | build + | dist +)/ +''' + +[tool.flake8] +max-line-length = 88 +extend-ignore = ["E203", "W503", "E501"] +exclude = [ + ".git", + "__pycache__", + "build", + "dist", + ".venv", + "*.egg-info", + "test_*", +] + +[tool.mypy] +python_version = "3.8" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = false # Allow untyped defs for Qt compatibility +disallow_incomplete_defs = false +check_untyped_defs = true +disallow_untyped_decorators = false +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +warn_unreachable = true +strict_equality = true +ignore_missing_imports = true + +# PyInstaller configuration +[tool.pyinstaller] +name = "BlurViewer" +onefile = true +windowed = true +icon = "BlurViewer.ico" +add-data = ["BlurViewer.ico;."] +hidden-import = [ + "PIL._tkinter_finder", + "PIL._imagingtk", + "pillow_heif", + "pillow_avif", + "PySide6.QtCore", + "PySide6.QtGui", + "PySide6.QtWidgets", +] +exclude = [ + "*.pyc", + "__pycache__", + "*.spec", + "build", + "dist", + ".venv", + "test_*" +] \ No newline at end of file diff --git a/legacy/blurviewer/requirements.txt b/legacy/blurviewer/requirements.txt new file mode 100644 index 0000000..96c2ee8 --- /dev/null +++ b/legacy/blurviewer/requirements.txt @@ -0,0 +1,14 @@ +# Core dependencies for BlurViewer +PySide6>=6.5.0 +Pillow>=10.0.0 +pillow-heif>=0.13.0 +pillow-avif-plugin>=1.3.0 +rawpy>=0.17.0 +imageio>=2.31.0 +opencv-python>=4.8.0 + +# For development (optional) +# pytest>=7.0.0 +# black>=23.0.0 +# flake8>=6.0.0 +# mypy>=1.0.0 From 00f2af26cfbeacdedc0de17dd4c4e1e01bf419a4 Mon Sep 17 00:00:00 2001 From: amti_yo Date: Mon, 3 Nov 2025 22:47:42 +0200 Subject: [PATCH 09/10] Trigger PR refresh: mark conflicts as resolved From f346aef626efeb4527060496b6212004fa94b9bb Mon Sep 17 00:00:00 2001 From: amti_yo Date: Wed, 5 Nov 2025 19:46:12 +0200 Subject: [PATCH 10/10] I forgot what I did --- FreshViewer/Program.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/FreshViewer/Program.cs b/FreshViewer/Program.cs index 8095230..844dd2e 100644 --- a/FreshViewer/Program.cs +++ b/FreshViewer/Program.cs @@ -9,9 +9,6 @@ namespace FreshViewer; internal static class Program { [STAThread] - /// - /// Validates platform requirements and starts the Avalonia application. - /// public static void Main(string[] args) { if (!OperatingSystem.IsWindows())