diff --git a/FModel/App.xaml.cs b/FModel/App.xaml.cs
index 4de88637..94bbc15c 100644
--- a/FModel/App.xaml.cs
+++ b/FModel/App.xaml.cs
@@ -146,7 +146,7 @@ public partial class App : Application
Dispatcher.UIThread.UnhandledExceptionFilter += (_, e) =>
{
Log.Error("{Exception}", e.Exception);
- e.Handled = true;
+ e.RequestCatch = true;
ShowErrorDialog(e.Exception);
};
diff --git a/FModel/Extensions/ClipboardExtensions.cs b/FModel/Extensions/ClipboardExtensions.cs
index 571e9156..bd177817 100644
--- a/FModel/Extensions/ClipboardExtensions.cs
+++ b/FModel/Extensions/ClipboardExtensions.cs
@@ -1,198 +1,68 @@
-using SkiaSharp;
using System;
-using System.Drawing;
-using System.Drawing.Imaging;
using System.IO;
-using System.Runtime.CompilerServices;
-using System.Text;
-using System.Windows;
+using Avalonia.Input;
+using Avalonia.Media.Imaging;
+using Avalonia.Threading;
+using FModel.Views;
+using Serilog;
namespace FModel.Extensions;
public static class ClipboardExtensions
{
- public static void SetImage(byte[] pngBytes, string fileName = null)
+ ///
+ /// Copies text to the system clipboard. Fire-and-forget; runs on the UI thread.
+ ///
+ public static void SetText(string text)
{
- Clipboard.Clear();
- var data = new DataObject();
- using var pngMs = new MemoryStream(pngBytes);
- using var image = Image.FromStream(pngMs);
- // As standard bitmap, without transparency support
- data.SetData(DataFormats.Bitmap, image, true);
- // As PNG. Gimp will prefer this over the other two
- data.SetData("PNG", pngMs, false);
- // As DIB. This is (wrongly) accepted as ARGB by many applications
- using var dibMemStream = new MemoryStream(ConvertToDib(image));
- data.SetData(DataFormats.Dib, dibMemStream, false);
- // Optional fileName
- if (!string.IsNullOrEmpty(fileName))
+ _ = Dispatcher.UIThread.InvokeAsync(async () =>
{
- var htmlFragment = GenerateHTMLFragment($"
");
- data.SetData(DataFormats.Html, htmlFragment);
- }
- // The 'copy=true' argument means the MemoryStreams can be safely disposed after the operation
- Clipboard.SetDataObject(data, true);
- }
-
- public static byte[] ConvertToDib(Image image)
- {
- byte[] bm32bData;
- var width = image.Width;
- var height = image.Height;
-
- // Ensure image is 32bppARGB by painting it on a new 32bppARGB image.
- using (var bm32b = new Bitmap(width, height, PixelFormat.Format32bppPArgb))
- {
- using (var gr = Graphics.FromImage(bm32b))
+ try
{
- gr.DrawImage(image, new Rectangle(0, 0, width, height));
+ var clipboard = MainWindow.YesWeCats?.Clipboard;
+ if (clipboard == null)
+ return;
+
+ await clipboard.SetTextAsync(text);
}
-
- // Bitmap format has its lines reversed.
- bm32b.RotateFlip(RotateFlipType.Rotate180FlipX);
- bm32bData = GetRawBytes(bm32b);
- }
-
- // BITMAPINFOHEADER struct for DIB.
- const int hdrSize = 0x28;
- var fullImage = new byte[hdrSize + 12 + bm32bData.Length];
- //Int32 biSize;
- WriteIntToByteArray(fullImage, 0x00, 4, true, hdrSize);
- //Int32 biWidth;
- WriteIntToByteArray(fullImage, 0x04, 4, true, (uint) width);
- //Int32 biHeight;
- WriteIntToByteArray(fullImage, 0x08, 4, true, (uint) height);
- //Int16 biPlanes;
- WriteIntToByteArray(fullImage, 0x0C, 2, true, 1);
- //Int16 biBitCount;
- WriteIntToByteArray(fullImage, 0x0E, 2, true, 32);
- //BITMAPCOMPRESSION biCompression = BITMAPCOMPRESSION.BITFIELDS;
- WriteIntToByteArray(fullImage, 0x10, 4, true, 3);
- //Int32 biSizeImage;
- WriteIntToByteArray(fullImage, 0x14, 4, true, (uint) bm32bData.Length);
- // These are all 0. Since .net clears new arrays, don't bother writing them.
- //Int32 biXPelsPerMeter = 0;
- //Int32 biYPelsPerMeter = 0;
- //Int32 biClrUsed = 0;
- //Int32 biClrImportant = 0;
-
- // The aforementioned "BITFIELDS": colour masks applied to the Int32 pixel value to get the R, G and B values.
- WriteIntToByteArray(fullImage, hdrSize + 0, 4, true, 0x00FF0000);
- WriteIntToByteArray(fullImage, hdrSize + 4, 4, true, 0x0000FF00);
- WriteIntToByteArray(fullImage, hdrSize + 8, 4, true, 0x000000FF);
-
- Unsafe.CopyBlockUnaligned(ref fullImage[hdrSize + 12], ref bm32bData[0], (uint) bm32bData.Length);
- return fullImage;
+ catch (Exception ex)
+ {
+ Log.Error(ex, "Failed to copy text to clipboard");
+ }
+ });
}
- private static byte[] ConvertToDib(byte[] pngBytes = null)
+ ///
+ /// Copies PNG image bytes to the system clipboard. Fire-and-forget; runs on the UI thread.
+ ///
+ public static void SetImage(byte[] pngBytes)
{
- byte[] bm32bData;
- int width, height;
-
- using (var skBmp = SKBitmap.Decode(pngBytes))
+ _ = Dispatcher.UIThread.InvokeAsync(async () =>
{
- width = skBmp.Width;
- height = skBmp.Height;
- using var rotated = new SKBitmap(new SKImageInfo(width, height, skBmp.ColorType));
- using var canvas = new SKCanvas(rotated);
- canvas.Scale(1, -1, 0, height / 2.0f);
- canvas.DrawBitmap(skBmp, SKPoint.Empty);
- canvas.Flush();
- bm32bData = rotated.Bytes;
- }
+ try
+ {
+ var clipboard = MainWindow.YesWeCats?.Clipboard;
+ if (clipboard == null)
+ return;
- // BITMAPINFOHEADER struct for DIB.
- const int hdrSize = 0x28;
- var fullImage = new byte[hdrSize + 12 + bm32bData.Length];
- //Int32 biSize;
- WriteIntToByteArray(fullImage, 0x00, 4, true, hdrSize);
- //Int32 biWidth;
- WriteIntToByteArray(fullImage, 0x04, 4, true, (uint) width);
- //Int32 biHeight;
- WriteIntToByteArray(fullImage, 0x08, 4, true, (uint) height);
- //Int16 biPlanes;
- WriteIntToByteArray(fullImage, 0x0C, 2, true, 1);
- //Int16 biBitCount;
- WriteIntToByteArray(fullImage, 0x0E, 2, true, 32);
- //BITMAPCOMPRESSION biCompression = BITMAPCOMPRESSION.BITFIELDS;
- WriteIntToByteArray(fullImage, 0x10, 4, true, 3);
- //Int32 biSizeImage;
- WriteIntToByteArray(fullImage, 0x14, 4, true, (uint) bm32bData.Length);
- // These are all 0. Since .net clears new arrays, don't bother writing them.
- //Int32 biXPelsPerMeter = 0;
- //Int32 biYPelsPerMeter = 0;
- //Int32 biClrUsed = 0;
- //Int32 biClrImportant = 0;
-
- // The aforementioned "BITFIELDS": colour masks applied to the Int32 pixel value to get the R, G and B values.
- WriteIntToByteArray(fullImage, hdrSize + 0, 4, true, 0x00FF0000);
- WriteIntToByteArray(fullImage, hdrSize + 4, 4, true, 0x0000FF00);
- WriteIntToByteArray(fullImage, hdrSize + 8, 4, true, 0x000000FF);
-
- Unsafe.CopyBlockUnaligned(ref fullImage[hdrSize + 12], ref bm32bData[0], (uint) bm32bData.Length);
- return fullImage;
+ var dataObject = new DataObject();
+ // Keep both MIME and generic bitmap formats for better cross-app paste compatibility.
+ dataObject.Set("image/png", pngBytes);
+ dataObject.Set("PNG", pngBytes);
+ // The MemoryStream can be disposed after decoding — Bitmap copies
+ // the pixel data during construction. The Bitmap itself must stay
+ // alive because on X11/Wayland the clipboard uses deferred rendering
+ // and the DataObject may be read when another app pastes.
+ Bitmap bitmap;
+ using (var ms = new MemoryStream(pngBytes))
+ bitmap = new Bitmap(ms);
+ dataObject.Set(DataFormats.Bitmap, bitmap);
+ await clipboard.SetDataObjectAsync(dataObject);
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "Failed to copy image to clipboard");
+ }
+ });
}
-
- public static unsafe byte[] GetRawBytes(Bitmap bmp)
- {
- var rect = new Rectangle(0, 0, bmp.Width, bmp.Height);
- var bmpData = bmp.LockBits(rect, ImageLockMode.ReadOnly, bmp.PixelFormat);
- var bytes = (uint) (Math.Abs(bmpData.Stride) * bmp.Height);
- var buffer = new byte[bytes];
- fixed (byte* pBuffer = buffer)
- {
- Unsafe.CopyBlockUnaligned(pBuffer, bmpData.Scan0.ToPointer(), bytes);
- }
-
- bmp.UnlockBits(bmpData);
- return buffer;
- }
-
- private static void WriteIntToByteArray(byte[] data, int startIndex, int bytes, bool littleEndian, uint value)
- {
- var lastByte = bytes - 1;
-
- if (data.Length < startIndex + bytes)
- {
- throw new ArgumentOutOfRangeException(nameof(startIndex), "Data array is too small to write a " + bytes + "-byte value at offset " + startIndex + ".");
- }
-
- for (var index = 0; index < bytes; index++)
- {
- var offs = startIndex + (littleEndian ? index : lastByte - index);
- data[offs] = (byte) (value >> 8 * index & 0xFF);
- }
- }
-
- private static string GenerateHTMLFragment(string html)
- {
- var sb = new StringBuilder();
-
- const string header = "Version:0.9\r\nStartHTML:<<<<<<<<<1\r\nEndHTML:<<<<<<<<<2\r\nStartFragment:<<<<<<<<<3\r\nEndFragment:<<<<<<<<<4\r\n";
- const string startHTML = "\r\n
\r\n";
- const string startFragment = "";
- const string endFragment = "";
- const string endHTML = "\r\n\r\n";
-
- sb.Append(header);
-
- var startHTMLLength = header.Length;
- var startFragmentLength = startHTMLLength + startHTML.Length + startFragment.Length;
- var endFragmentLength = startFragmentLength + Encoding.UTF8.GetByteCount(html);
- var endHTMLLength = endFragmentLength + endFragment.Length + endHTML.Length;
-
- sb.Replace("<<<<<<<<<1", startHTMLLength.ToString("D10"));
- sb.Replace("<<<<<<<<<2", endHTMLLength.ToString("D10"));
- sb.Replace("<<<<<<<<<3", startFragmentLength.ToString("D10"));
- sb.Replace("<<<<<<<<<4", endFragmentLength.ToString("D10"));
-
- sb.Append(startHTML);
- sb.Append(startFragment);
- sb.Append(html);
- sb.Append(endFragment);
- sb.Append(endHTML);
-
- return sb.ToString();
- }
-}
\ No newline at end of file
+}
diff --git a/FModel/FModel.csproj b/FModel/FModel.csproj
index 66ea1c80..8cd811a4 100644
--- a/FModel/FModel.csproj
+++ b/FModel/FModel.csproj
@@ -3,6 +3,7 @@
Exe
net8.0
+ enable
FModel.ico
4.4.4.0
4.4.4.0
diff --git a/FModel/Framework/ImGuiController.cs b/FModel/Framework/ImGuiController.cs
index b62fde21..af52fb4a 100644
--- a/FModel/Framework/ImGuiController.cs
+++ b/FModel/Framework/ImGuiController.cs
@@ -4,8 +4,6 @@ using System.Diagnostics;
using System.IO;
using System.Numerics;
using System.Runtime.InteropServices;
-using System.Windows;
-using System.Windows.Forms;
using FModel.Settings;
using ImGuiNET;
using ImGuizmoNET;
@@ -64,19 +62,18 @@ public class ImGuiController : IDisposable
unsafe
{
var iniFileNamePtr = Marshal.StringToCoTaskMemUTF8(Path.Combine(UserSettings.Default.OutputDirectory, ".data", "imgui.ini"));
- io.NativePtr->IniFilename = (byte*)iniFileNamePtr;
+ io.NativePtr->IniFilename = (byte*) iniFileNamePtr;
}
-
- // If not found, Fallback to default ImGui Font
- var normalPath = @"C:\Windows\Fonts\segoeui.ttf";
- var boldPath = @"C:\Windows\Fonts\segoeuib.ttf";
- var semiBoldPath = @"C:\Windows\Fonts\seguisb.ttf";
- if (File.Exists(normalPath))
+ // Platform-specific font probing: prefer Segoe UI on Windows,
+ // fall back to common Linux fonts (DejaVu Sans, Liberation Sans).
+ var (normalPath, boldPath, semiBoldPath) = FontPaths.Value;
+
+ if (normalPath != null && File.Exists(normalPath))
FontNormal = io.Fonts.AddFontFromFileTTF(normalPath, 16 * DpiScale);
- if (File.Exists(boldPath))
+ if (boldPath != null && File.Exists(boldPath))
FontBold = io.Fonts.AddFontFromFileTTF(boldPath, 16 * DpiScale);
- if (File.Exists(semiBoldPath))
+ if (semiBoldPath != null && File.Exists(semiBoldPath))
FontSemiBold = io.Fonts.AddFontFromFileTTF(semiBoldPath, 16 * DpiScale);
io.Fonts.AddFontDefault();
@@ -192,7 +189,7 @@ void main()
ImGuiIOPtr io = ImGui.GetIO();
io.Fonts.GetTexDataAsRGBA32(out IntPtr pixels, out int width, out int height, out int bytesPerPixel);
- int mips = (int)Math.Floor(Math.Log(Math.Max(width, height), 2));
+ int mips = (int) Math.Floor(Math.Log(Math.Max(width, height), 2));
int prevActiveTexture = GL.GetInteger(GetPName.ActiveTexture);
GL.ActiveTexture(TextureUnit.Texture0);
@@ -207,19 +204,19 @@ void main()
GL.GenerateMipmap(GenerateMipmapTarget.Texture2D);
- GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat);
- GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat);
+ GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int) TextureWrapMode.Repeat);
+ GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int) TextureWrapMode.Repeat);
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMaxLevel, mips - 1);
- GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
- GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
+ GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int) TextureMagFilter.Linear);
+ GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int) TextureMinFilter.Linear);
// Restore state
GL.BindTexture(TextureTarget.Texture2D, prevTexture2D);
- GL.ActiveTexture((TextureUnit)prevActiveTexture);
+ GL.ActiveTexture((TextureUnit) prevActiveTexture);
- io.Fonts.SetTexID((IntPtr)_fontTexture);
+ io.Fonts.SetTexID((IntPtr) _fontTexture);
io.Fonts.ClearTexData();
}
@@ -288,7 +285,8 @@ void main()
foreach (Keys key in Enum.GetValues())
{
- if (key == Keys.Unknown) continue;
+ if (key == Keys.Unknown)
+ continue;
io.AddKeyEvent(TranslateKey(key), kState.IsKeyDown(key));
}
@@ -353,7 +351,7 @@ void main()
int vertexSize = cmd_list.VtxBuffer.Size * Marshal.SizeOf();
if (vertexSize > _vertexBufferSize)
{
- int newSize = (int)Math.Max(_vertexBufferSize * 1.5f, vertexSize);
+ int newSize = (int) Math.Max(_vertexBufferSize * 1.5f, vertexSize);
GL.BufferData(BufferTarget.ArrayBuffer, newSize, IntPtr.Zero, BufferUsageHint.DynamicDraw);
_vertexBufferSize = newSize;
@@ -362,7 +360,7 @@ void main()
int indexSize = cmd_list.IdxBuffer.Size * sizeof(ushort);
if (indexSize > _indexBufferSize)
{
- int newSize = (int)Math.Max(_indexBufferSize * 1.5f, indexSize);
+ int newSize = (int) Math.Max(_indexBufferSize * 1.5f, indexSize);
GL.BufferData(BufferTarget.ElementArrayBuffer, newSize, IntPtr.Zero, BufferUsageHint.DynamicDraw);
_indexBufferSize = newSize;
}
@@ -416,21 +414,21 @@ void main()
else
{
GL.ActiveTexture(TextureUnit.Texture0);
- GL.BindTexture(TextureTarget.Texture2D, (int)pcmd.TextureId);
+ GL.BindTexture(TextureTarget.Texture2D, (int) pcmd.TextureId);
CheckGLError("Texture");
// We do _windowHeight - (int)clip.W instead of (int)clip.Y because gl has flipped Y when it comes to these coordinates
var clip = pcmd.ClipRect;
- GL.Scissor((int)clip.X, _windowHeight - (int)clip.W, (int)(clip.Z - clip.X), (int)(clip.W - clip.Y));
+ GL.Scissor((int) clip.X, _windowHeight - (int) clip.W, (int) (clip.Z - clip.X), (int) (clip.W - clip.Y));
CheckGLError("Scissor");
if ((io.BackendFlags & ImGuiBackendFlags.RendererHasVtxOffset) != 0)
{
- GL.DrawElementsBaseVertex(PrimitiveType.Triangles, (int)pcmd.ElemCount, DrawElementsType.UnsignedShort, (IntPtr)(pcmd.IdxOffset * sizeof(ushort)), unchecked((int)pcmd.VtxOffset));
+ GL.DrawElementsBaseVertex(PrimitiveType.Triangles, (int) pcmd.ElemCount, DrawElementsType.UnsignedShort, (IntPtr) (pcmd.IdxOffset * sizeof(ushort)), unchecked((int) pcmd.VtxOffset));
}
else
{
- GL.DrawElements(BeginMode.Triangles, (int)pcmd.ElemCount, DrawElementsType.UnsignedShort, (int)pcmd.IdxOffset * sizeof(ushort));
+ GL.DrawElements(BeginMode.Triangles, (int) pcmd.ElemCount, DrawElementsType.UnsignedShort, (int) pcmd.IdxOffset * sizeof(ushort));
}
CheckGLError("Draw");
}
@@ -442,21 +440,33 @@ void main()
// Reset state
GL.BindTexture(TextureTarget.Texture2D, prevTexture2D);
- GL.ActiveTexture((TextureUnit)prevActiveTexture);
+ GL.ActiveTexture((TextureUnit) prevActiveTexture);
GL.UseProgram(prevProgram);
GL.BindVertexArray(prevVAO);
GL.Scissor(prevScissorBox[0], prevScissorBox[1], prevScissorBox[2], prevScissorBox[3]);
GL.BindBuffer(BufferTarget.ArrayBuffer, prevArrayBuffer);
- GL.BlendEquationSeparate((BlendEquationMode)prevBlendEquationRgb, (BlendEquationMode)prevBlendEquationAlpha);
+ GL.BlendEquationSeparate((BlendEquationMode) prevBlendEquationRgb, (BlendEquationMode) prevBlendEquationAlpha);
GL.BlendFuncSeparate(
- (BlendingFactorSrc)prevBlendFuncSrcRgb,
- (BlendingFactorDest)prevBlendFuncDstRgb,
- (BlendingFactorSrc)prevBlendFuncSrcAlpha,
- (BlendingFactorDest)prevBlendFuncDstAlpha);
- if (prevBlendEnabled) GL.Enable(EnableCap.Blend); else GL.Disable(EnableCap.Blend);
- if (prevDepthTestEnabled) GL.Enable(EnableCap.DepthTest); else GL.Disable(EnableCap.DepthTest);
- if (prevCullFaceEnabled) GL.Enable(EnableCap.CullFace); else GL.Disable(EnableCap.CullFace);
- if (prevScissorTestEnabled) GL.Enable(EnableCap.ScissorTest); else GL.Disable(EnableCap.ScissorTest);
+ (BlendingFactorSrc) prevBlendFuncSrcRgb,
+ (BlendingFactorDest) prevBlendFuncDstRgb,
+ (BlendingFactorSrc) prevBlendFuncSrcAlpha,
+ (BlendingFactorDest) prevBlendFuncDstAlpha);
+ if (prevBlendEnabled)
+ GL.Enable(EnableCap.Blend);
+ else
+ GL.Disable(EnableCap.Blend);
+ if (prevDepthTestEnabled)
+ GL.Enable(EnableCap.DepthTest);
+ else
+ GL.Disable(EnableCap.DepthTest);
+ if (prevCullFaceEnabled)
+ GL.Enable(EnableCap.CullFace);
+ else
+ GL.Disable(EnableCap.CullFace);
+ if (prevScissorTestEnabled)
+ GL.Enable(EnableCap.ScissorTest);
+ else
+ GL.Disable(EnableCap.ScissorTest);
}
///
@@ -484,7 +494,8 @@ void main()
for (int i = 0; i < n; i++)
{
string extension = GL.GetString(StringNameIndexed.Extensions, i);
- if (extension == name) return true;
+ if (extension == name)
+ return true;
}
return false;
@@ -549,7 +560,87 @@ void main()
public static float GetDpiScale()
{
- return Math.Max((float)(Screen.PrimaryScreen.Bounds.Width / SystemParameters.PrimaryScreenWidth), (float)(Screen.PrimaryScreen.Bounds.Height / SystemParameters.PrimaryScreenHeight));
+ // Avalonia: use the primary screen's pixel density as the DPI scale factor.
+ // Screens.Primary is available on the UI thread once the platform is initialised.
+ var screen = Avalonia.Application.Current?.ApplicationLifetime is
+ Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime desktop
+ ? desktop.MainWindow?.Screens?.Primary : null;
+ return screen is not null ? (float) screen.PixelDensity : 1.0f;
+ }
+
+ ///
+ /// Lazily-resolved platform font paths. Computed once and cached for the process lifetime.
+ ///
+ private static readonly Lazy<(string? normal, string? bold, string? semiBold)> FontPaths = new(ResolveFontPaths);
+
+ ///
+ /// Resolves platform-appropriate font file paths.
+ /// Returns (normal, bold, semiBold) — any element may be null if not found.
+ ///
+ private static (string? normal, string? bold, string? semiBold) ResolveFontPaths()
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ return (
+ @"C:\Windows\Fonts\segoeui.ttf",
+ @"C:\Windows\Fonts\segoeuib.ttf",
+ @"C:\Windows\Fonts\seguisb.ttf"
+ );
+ }
+
+ // Linux / macOS: probe common directories for DejaVu Sans, Liberation Sans, or Noto Sans.
+ string[] searchDirs =
+ [
+ "/usr/share/fonts",
+ "/usr/local/share/fonts",
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local/share/fonts")
+ ];
+
+ // Preferred font families in order. Each entry: (normal, bold, semibold-or-bold-fallback).
+ (string normal, string bold, string semiBold)[] fontFamilies =
+ [
+ ("DejaVuSans.ttf", "DejaVuSans-Bold.ttf", "DejaVuSans-Bold.ttf"),
+ ("LiberationSans-Regular.ttf", "LiberationSans-Bold.ttf", "LiberationSans-Bold.ttf"),
+ ("NotoSans-Regular.ttf", "NotoSans-Bold.ttf", "NotoSans-SemiBold.ttf"),
+ ];
+
+ foreach (var family in fontFamilies)
+ {
+ foreach (var dir in searchDirs)
+ {
+ if (!Directory.Exists(dir))
+ continue;
+
+ var normalPath = FindFont(dir, family.normal);
+ if (normalPath == null)
+ continue;
+
+ var boldPath = FindFont(dir, family.bold);
+ var semiBoldPath = FindFont(dir, family.semiBold) ?? boldPath;
+ return (normalPath, boldPath, semiBoldPath);
+ }
+ }
+
+ return (null, null, null);
+ }
+
+ private static string? FindFont(string directory, string fileName)
+ {
+ // Direct match in the directory.
+ var path = Path.Combine(directory, fileName);
+ if (File.Exists(path))
+ return path;
+
+ // Search subdirectories (fonts are often in type-specific subfolders).
+ try
+ {
+ var files = Directory.GetFiles(directory, fileName, SearchOption.AllDirectories);
+ return files.Length > 0 ? files[0] : null;
+ }
+ catch
+ {
+ return null;
+ }
}
public static ImGuiKey TranslateKey(Keys key)
diff --git a/FModel/MainWindow.xaml.cs b/FModel/MainWindow.xaml.cs
index 0cab111b..c8423350 100644
--- a/FModel/MainWindow.xaml.cs
+++ b/FModel/MainWindow.xaml.cs
@@ -143,6 +143,9 @@ public partial class MainWindow : Window
Height = screen.WorkingArea.Height * 0.95 / dpi;
}
+ // First-run: may show the DirectorySelector dialog before proceeding.
+ await _applicationView.EnsureInitializedAsync(this);
+
UpdateStatusBarColor();
var newOrUpdated = UserSettings.Default.ShowChangelog;
diff --git a/FModel/ViewModels/ApplicationViewModel.cs b/FModel/ViewModels/ApplicationViewModel.cs
index 7822aa70..1a27b727 100644
--- a/FModel/ViewModels/ApplicationViewModel.cs
+++ b/FModel/ViewModels/ApplicationViewModel.cs
@@ -5,7 +5,8 @@ using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Threading.Tasks;
-using System.Windows;
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
using CUE4Parse_Conversion.Textures.BC;
using CUE4Parse.Compression;
using CUE4Parse.Encryption.Aes;
@@ -18,9 +19,7 @@ using FModel.Settings;
using FModel.ViewModels.Commands;
using FModel.Views;
using FModel.Views.Resources.Controls;
-using MessageBox = AdonisUI.Controls.MessageBox;
-using MessageBoxButton = AdonisUI.Controls.MessageBoxButton;
-using MessageBoxImage = AdonisUI.Controls.MessageBoxImage;
+using Serilog;
namespace FModel.ViewModels;
@@ -65,7 +64,8 @@ public class ApplicationViewModel : ViewModel
get => _selectedLeftTabIndex;
set
{
- if (value is < 0 or > 2) return;
+ if (value is < 0 or > 2)
+ return;
SetProperty(ref _selectedLeftTabIndex, value);
}
}
@@ -78,15 +78,17 @@ public class ApplicationViewModel : ViewModel
private CopyCommand _copyCommand;
public string InitialWindowTitle => $"FModel ({Constants.APP_SHORT_COMMIT_ID} - {Constants.APP_BUILD_DATE:MMM d, yyyy})";
- public string GameDisplayName => CUE4Parse.Provider.GameDisplayName ?? "Unknown";
- public string TitleExtra => $"({UserSettings.Default.CurrentDir.UeVersion}){(Build != EBuildKind.Release ? $" ({Build})" : "")}";
+ public string GameDisplayName => CUE4Parse?.Provider.GameDisplayName ?? "Unknown";
+ public string TitleExtra => UserSettings.Default.CurrentDir is { } dir
+ ? $"({dir.UeVersion}){(Build != EBuildKind.Release ? $" ({Build})" : "")}"
+ : Build != EBuildKind.Release ? $"({Build})" : string.Empty;
public LoadingModesViewModel LoadingModes { get; }
- public CustomDirectoriesViewModel CustomDirectories { get; }
- public CUE4ParseViewModel CUE4Parse { get; }
- public SettingsViewModel SettingsView { get; }
- public AesManagerViewModel AesManager { get; }
- public AudioPlayerViewModel AudioPlayer { get; }
+ public CustomDirectoriesViewModel? CustomDirectories { get; private set; }
+ public CUE4ParseViewModel? CUE4Parse { get; private set; }
+ public SettingsViewModel? SettingsView { get; private set; }
+ public AesManagerViewModel? AesManager { get; private set; }
+ public AudioPlayerViewModel? AudioPlayer { get; private set; }
public ApplicationViewModel()
{
@@ -100,30 +102,60 @@ public class ApplicationViewModel : ViewModel
#endif
LoadingModes = new LoadingModesViewModel();
- UserSettings.Default.CurrentDir = AvoidEmptyGameDirectory(false);
- if (UserSettings.Default.CurrentDir is null)
+ // For existing installations, use the cached directory settings immediately.
+ // For first-run (no settings), initialization is deferred to EnsureInitializedAsync()
+ // which is called from MainWindow.OnLoaded after the window is visible.
+ var gameDirectory = UserSettings.Default.GameDirectory;
+ if (!string.IsNullOrEmpty(gameDirectory) &&
+ UserSettings.Default.PerDirectory.TryGetValue(gameDirectory, out var currentDir))
+ {
+ UserSettings.Default.CurrentDir = currentDir;
+ InitializeInternals();
+ }
+ }
+
+ ///
+ /// Handles the first-run case: shows the directory-selector dialog and then
+ /// completes internal initialization. Should be called from MainWindow.OnLoaded
+ /// when is still null (i.e., no prior configuration exists).
+ ///
+ public async Task EnsureInitializedAsync(Window owner)
+ {
+ if (CUE4Parse != null)
+ return; // Already initialized synchronously in constructor.
+
+ var dir = await AvoidEmptyGameDirectoryAsync(false, owner);
+ if (dir is null)
{
- //If no game is selected, many things will break before a shutdown request is processed in the normal way.
- //A hard exit is preferable to an unhandled expection in this case
Environment.Exit(0);
+ return;
}
+ UserSettings.Default.CurrentDir = dir;
+ InitializeInternals();
+ }
+
+ private void InitializeInternals()
+ {
CUE4Parse = new CUE4ParseViewModel();
CUE4Parse.Provider.VfsRegistered += (sender, count) =>
{
- if (sender is not IAesVfsReader reader) return;
+ if (sender is not IAesVfsReader reader)
+ return;
Status.UpdateStatusLabel($"{count} Archives ({reader.Name})", "Registered");
CUE4Parse.GameDirectory.Add(reader);
};
CUE4Parse.Provider.VfsMounted += (sender, count) =>
{
- if (sender is not IAesVfsReader reader) return;
+ if (sender is not IAesVfsReader reader)
+ return;
Status.UpdateStatusLabel($"{count:N0} Packages ({reader.Name})", "Mounted");
CUE4Parse.GameDirectory.Verify(reader);
};
CUE4Parse.Provider.VfsUnmounted += (sender, _) =>
{
- if (sender is not IAesVfsReader reader) return;
+ if (sender is not IAesVfsReader reader)
+ return;
CUE4Parse.GameDirectory.Disable(reader);
};
@@ -135,29 +167,85 @@ public class ApplicationViewModel : ViewModel
Status.SetStatus(EStatusKind.Ready);
}
- public DirectorySettings AvoidEmptyGameDirectory(bool bAlreadyLaunched)
+ public async Task AvoidEmptyGameDirectoryAsync(bool bAlreadyLaunched, Window? owner)
{
var gameDirectory = UserSettings.Default.GameDirectory;
if (!bAlreadyLaunched && UserSettings.Default.PerDirectory.TryGetValue(gameDirectory, out var currentDir))
return currentDir;
var gameLauncherViewModel = new GameSelectorViewModel(gameDirectory);
- var result = new DirectorySelector(gameLauncherViewModel).ShowDialog();
- if (!result.HasValue || !result.Value) return null;
+ var selector = new DirectorySelector(gameLauncherViewModel);
+
+ // Fall back to MainWindow when no explicit owner is provided.
+ owner ??= (Avalonia.Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
+ if (owner == null)
+ return null;
+
+ var ok = await selector.ShowDialog(owner);
+ if (ok != true)
+ return null;
UserSettings.Default.GameDirectory = gameLauncherViewModel.SelectedDirectory.GameDirectory;
- if (!bAlreadyLaunched || UserSettings.Default.CurrentDir.Equals(gameLauncherViewModel.SelectedDirectory))
+ if (!bAlreadyLaunched || UserSettings.Default.CurrentDir?.Equals(gameLauncherViewModel.SelectedDirectory) == true)
return gameLauncherViewModel.SelectedDirectory;
// UserSettings.Save(); // ??? change key then change game, key saved correctly what?
UserSettings.Default.CurrentDir = gameLauncherViewModel.SelectedDirectory;
- RestartWithWarning();
+ await RestartWithWarningAsync();
return null;
}
- public void RestartWithWarning()
+ public Task RestartWithWarningAsync() => RestartWithWarningAsync(null);
+
+ public async Task RestartWithWarningAsync(Window? owner)
{
- MessageBox.Show("It looks like you just changed something.\nFModel will restart to apply your changes.", "Uh oh, a restart is needed", MessageBoxButton.OK, MessageBoxImage.Warning);
+ Log.Information("FModel will restart to apply your changes.");
+
+ var okButton = new Avalonia.Controls.Button
+ {
+ Content = "OK",
+ HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right
+ };
+
+ var dialog = new Window
+ {
+ Title = "Uh oh, a restart is needed",
+ Width = 420,
+ Height = 160,
+ CanResize = false,
+ WindowStartupLocation = WindowStartupLocation.CenterOwner,
+ Content = new Avalonia.Controls.StackPanel
+ {
+ Margin = new Avalonia.Thickness(16),
+ Spacing = 12,
+ Children =
+ {
+ new Avalonia.Controls.TextBlock
+ {
+ Text = "It looks like you just changed something.\nFModel will restart to apply your changes.",
+ TextWrapping = Avalonia.Media.TextWrapping.Wrap
+ },
+ okButton
+ }
+ }
+ };
+
+ okButton.Click += (_, _) => dialog.Close();
+
+ owner ??= (Avalonia.Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
+ if (owner != null)
+ {
+ await dialog.ShowDialog(owner);
+ }
+ else
+ {
+ // No owner available — show non-modal and wait for it to close.
+ var tcs = new TaskCompletionSource();
+ dialog.Closed += (_, _) => tcs.TrySetResult();
+ dialog.Show();
+ await tcs.Task;
+ }
+
Restart();
}
@@ -194,12 +282,19 @@ public class ApplicationViewModel : ViewModel
}.Start();
}
- Application.Current.Shutdown();
+ if (Avalonia.Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ desktop.Shutdown();
+ else
+ Environment.Exit(0);
}
public async Task UpdateProvider(bool isLaunch)
{
- if (!isLaunch && !AesManager.HasChange) return;
+ if (AesManager is null || CUE4Parse is null)
+ return;
+
+ if (!isLaunch && !AesManager.HasChange)
+ return;
CUE4Parse.ClearProvider();
await ApplicationService.ThreadWorkerView.Begin(cancellationToken =>
@@ -210,7 +305,8 @@ public class ApplicationViewModel : ViewModel
cancellationToken.ThrowIfCancellationRequested(); // cancel if needed
var k = x.Key.Trim();
- if (k.Length != 66) k = Constants.ZERO_64_CHAR;
+ if (k.Length != 66)
+ k = Constants.ZERO_64_CHAR;
return new KeyValuePair(x.Guid, new FAesKey(k));
});
@@ -256,8 +352,10 @@ public class ApplicationViewModel : ViewModel
const string imgui = "imgui.ini";
var imguiPath = Path.Combine(UserSettings.Default.OutputDirectory, ".data", imgui);
- if (File.Exists(imgui)) File.Move(imgui, imguiPath, true);
- if (File.Exists(imguiPath) && !forceDownload) return;
+ if (File.Exists(imgui))
+ File.Move(imgui, imguiPath, true);
+ if (File.Exists(imguiPath) && !forceDownload)
+ return;
await ApplicationService.ApiEndpointView.DownloadFileAsync($"https://cdn.fmodel.app/d/configurations/{imgui}", imguiPath);
if (new FileInfo(imguiPath).Length == 0)
@@ -289,7 +387,8 @@ public class ApplicationViewModel : ViewModel
if (!await ZlibHelper.DownloadDllAsync(zlibPath))
{
FLogger.Append(ELog.Error, () => FLogger.Text("Failed to download Zlib-ng", Constants.WHITE, true));
- if (!zlibFileInfo.Exists) return;
+ if (!zlibFileInfo.Exists)
+ return;
}
}
diff --git a/FModel/ViewModels/CUE4ParseViewModel.cs b/FModel/ViewModels/CUE4ParseViewModel.cs
index bcd28d60..260a641b 100644
--- a/FModel/ViewModels/CUE4ParseViewModel.cs
+++ b/FModel/ViewModels/CUE4ParseViewModel.cs
@@ -9,9 +9,7 @@ using System.Net.Http.Headers;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
-using System.Windows;
-
-using AdonisUI.Controls;
+using Avalonia.Controls;
using CUE4Parse;
using CUE4Parse.Compression;
@@ -367,7 +365,7 @@ public class CUE4ParseViewModel : ViewModel
AssetsFolder.Folders.Clear();
SearchVm.SearchResults.Clear();
- Helper.CloseWindow("Search For Packages");
+ Helper.CloseWindow("Search For Packages");
Provider.UnloadNonStreamedVfs();
GC.Collect();
}
diff --git a/FModel/ViewModels/Commands/CopyCommand.cs b/FModel/ViewModels/Commands/CopyCommand.cs
index 97177db3..828dea5c 100644
--- a/FModel/ViewModels/Commands/CopyCommand.cs
+++ b/FModel/ViewModels/Commands/CopyCommand.cs
@@ -2,8 +2,8 @@ using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Text;
-using System.Windows;
using CUE4Parse.FileProvider.Objects;
+using FModel.Extensions;
using FModel.Framework;
namespace FModel.ViewModels.Commands;
@@ -34,22 +34,28 @@ public class CopyCommand : ViewModelCommand
switch (trigger)
{
case "File_Path":
- foreach (var entry in entries) sb.AppendLine(entry.Path);
+ foreach (var entry in entries)
+ sb.AppendLine(entry.Path);
break;
case "File_Name":
- foreach (var entry in entries) sb.AppendLine(entry.Name);
+ foreach (var entry in entries)
+ sb.AppendLine(entry.Name);
break;
case "Directory_Path":
- foreach (var entry in entries) sb.AppendLine(entry.Directory);
+ foreach (var entry in entries)
+ sb.AppendLine(entry.Directory);
break;
case "File_Path_No_Extension":
- foreach (var entry in entries) sb.AppendLine(entry.PathWithoutExtension);
+ foreach (var entry in entries)
+ sb.AppendLine(entry.PathWithoutExtension);
break;
case "File_Name_No_Extension":
- foreach (var entry in entries) sb.AppendLine(entry.NameWithoutExtension);
+ foreach (var entry in entries)
+ sb.AppendLine(entry.NameWithoutExtension);
break;
}
- Clipboard.SetText(sb.ToString().TrimEnd());
+ var text = sb.ToString().TrimEnd();
+ ClipboardExtensions.SetText(text);
}
}
diff --git a/FModel/ViewModels/Commands/ImageCommand.cs b/FModel/ViewModels/Commands/ImageCommand.cs
index df4b7735..e5591366 100644
--- a/FModel/ViewModels/Commands/ImageCommand.cs
+++ b/FModel/ViewModels/Commands/ImageCommand.cs
@@ -1,10 +1,11 @@
-using AdonisUI.Controls;
+using Avalonia.Controls;
+using Avalonia.Media;
+using Avalonia.Media.Imaging;
using FModel.Extensions;
using FModel.Framework;
using FModel.Views.Resources.Controls;
-using System.Windows;
-using System.Windows.Media;
using FModel.Views.Resources.Converters;
+using ImGuiController = FModel.Framework.ImGuiController;
namespace FModel.ViewModels.Commands;
@@ -16,29 +17,33 @@ public class ImageCommand : ViewModelCommand
public override void Execute(TabItem tabViewModel, object parameter)
{
- if (parameter == null || !tabViewModel.HasImage) return;
+ if (parameter == null || !tabViewModel.HasImage)
+ return;
switch (parameter)
{
case "Open":
- {
- Helper.OpenWindow(tabViewModel.SelectedImage.ExportName + " (Image)", () =>
{
- var popout = new ImagePopout
+ Helper.OpenWindow(tabViewModel.SelectedImage.ExportName + " (Image)", () =>
{
- Title = tabViewModel.SelectedImage.ExportName + " (Image)",
- Width = tabViewModel.SelectedImage.Image.Width,
- Height = tabViewModel.SelectedImage.Image.Height,
- WindowState = tabViewModel.SelectedImage.Image.Height > 1000 ? WindowState.Maximized : WindowState.Normal,
- ImageCtrl = { Source = tabViewModel.SelectedImage.Image }
- };
- RenderOptions.SetBitmapScalingMode(popout.ImageCtrl, BoolToRenderModeConverter.Instance.Convert(tabViewModel.SelectedImage.RenderNearestNeighbor));
- popout.Show();
- });
- break;
- }
+ var pixelWidth = tabViewModel.SelectedImage.Image.PixelSize.Width;
+ var pixelHeight = tabViewModel.SelectedImage.Image.PixelSize.Height;
+ var dpiScale = ImGuiController.GetDpiScale();
+ var popout = new ImagePopout
+ {
+ Title = tabViewModel.SelectedImage.ExportName + " (Image)",
+ Width = pixelWidth / dpiScale,
+ Height = pixelHeight / dpiScale,
+ WindowState = pixelHeight > 1000 ? WindowState.Maximized : WindowState.Normal,
+ };
+ popout.ImageCtrl.Source = tabViewModel.SelectedImage.Image;
+ RenderOptions.SetBitmapInterpolationMode(popout.ImageCtrl, BoolToRenderModeConverter.Instance.Convert(tabViewModel.SelectedImage.RenderNearestNeighbor));
+ popout.Show();
+ });
+ break;
+ }
case "Copy":
- ClipboardExtensions.SetImage(tabViewModel.SelectedImage.ImageBuffer, $"{tabViewModel.SelectedImage.ExportName}.png");
+ ClipboardExtensions.SetImage(tabViewModel.SelectedImage.ImageBuffer);
break;
case "Save":
tabViewModel.SaveImage();
diff --git a/FModel/ViewModels/Commands/MenuCommand.cs b/FModel/ViewModels/Commands/MenuCommand.cs
index f28ed78d..d18d5647 100644
--- a/FModel/ViewModels/Commands/MenuCommand.cs
+++ b/FModel/ViewModels/Commands/MenuCommand.cs
@@ -1,7 +1,7 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
-using AdonisUI.Controls;
+using Avalonia.Controls;
using FModel.Extensions;
using FModel.Framework;
using FModel.Services;
@@ -23,40 +23,43 @@ public class MenuCommand : ViewModelCommand
switch (parameter)
{
case "Directory_Selector":
- contextViewModel.AvoidEmptyGameDirectory(true);
+ await contextViewModel.AvoidEmptyGameDirectoryAsync(true, MainWindow.YesWeCats);
break;
case "Directory_AES":
- Helper.OpenWindow("AES Manager", () => new AesManager().Show());
+ Helper.OpenWindow("AES Manager", () => new AesManager().Show());
break;
case "Directory_Backup":
- Helper.OpenWindow("Backup Manager", () => new BackupManager(contextViewModel.CUE4Parse.Provider.ProjectName).Show());
+ if (contextViewModel.CUE4Parse is null) return;
+ Helper.OpenWindow("Backup Manager", () => new BackupManager(contextViewModel.CUE4Parse.Provider.ProjectName).Show());
break;
case "Directory_ArchivesInfo":
+ if (contextViewModel.CUE4Parse is null) return;
ApplicationService.ApplicationView.IsAssetsExplorerVisible = false;
contextViewModel.CUE4Parse.TabControl.AddTab("Archives Info");
contextViewModel.CUE4Parse.TabControl.SelectedTab.Highlighter = AvalonExtensions.HighlighterSelector("json");
contextViewModel.CUE4Parse.TabControl.SelectedTab.SetDocumentText(JsonConvert.SerializeObject(contextViewModel.CUE4Parse.GameDirectory.DirectoryFiles, Formatting.Indented), false, false);
break;
case "Views_3dViewer":
+ if (contextViewModel.CUE4Parse is null) return;
contextViewModel.CUE4Parse.SnooperViewer.Run();
break;
case "Views_AudioPlayer":
- Helper.OpenWindow("Audio Player", () => new AudioPlayer().Show());
+ Helper.OpenWindow("Audio Player", () => new AudioPlayer().Show());
break;
case "Views_ImageMerger":
- Helper.OpenWindow("Image Merger", () => new ImageMerger().Show());
+ Helper.OpenWindow("Image Merger", () => new ImageMerger().Show());
break;
case "Settings":
- Helper.OpenWindow("Settings", () => new SettingsView().Show());
+ Helper.OpenWindow("Settings", () => new SettingsView().Show());
break;
case "Help_About":
- Helper.OpenWindow("About", () => new About().Show());
+ Helper.OpenWindow("About", () => new About().Show());
break;
case "Help_Donate":
Process.Start(new ProcessStartInfo { FileName = Constants.DONATE_LINK, UseShellExecute = true });
break;
case "Help_Releases":
- Helper.OpenWindow("Releases", () => new UpdateView().Show());
+ Helper.OpenWindow("Releases", () => new UpdateView().Show());
break;
case "Help_BugsReport":
Process.Start(new ProcessStartInfo { FileName = Constants.ISSUE_LINK, UseShellExecute = true });
@@ -78,6 +81,7 @@ public class MenuCommand : ViewModelCommand
// });
// break;
case "ToolBox_Collapse_All":
+ if (contextViewModel.CUE4Parse is null) return;
await ApplicationService.ThreadWorkerView.Begin(cancellationToken =>
{
SetFoldersIsExpanded(contextViewModel.CUE4Parse.AssetsFolder, false, cancellationToken);
@@ -117,7 +121,8 @@ public class MenuCommand : ViewModelCommand
current = current.Next;
}
- if (!expand) return;
+ if (!expand)
+ return;
// Expand bottom-up (reduce layout updates)
for (var node = nodes.Last; node != null; node = node.Previous)
diff --git a/FModel/ViewModels/Commands/TabCommand.cs b/FModel/ViewModels/Commands/TabCommand.cs
index 07d1f6c0..84669343 100644
--- a/FModel/ViewModels/Commands/TabCommand.cs
+++ b/FModel/ViewModels/Commands/TabCommand.cs
@@ -1,5 +1,5 @@
-using System.Windows;
-using AdonisUI.Controls;
+using Avalonia.Controls;
+using FModel.Extensions;
using FModel.Framework;
using FModel.Services;
using FModel.Views.Resources.Controls;
@@ -68,8 +68,9 @@ public class TabCommand : ViewModelCommand
});
break;
case "Open_Properties":
- if (tabViewModel.Header == "New Tab" || tabViewModel.Document == null) return;
- Helper.OpenWindow(tabViewModel.Header + " (Properties)", () =>
+ if (tabViewModel.Header == "New Tab" || tabViewModel.Document == null)
+ return;
+ Helper.OpenWindow(tabViewModel.Header + " (Properties)", () =>
{
new PropertiesPopout(tabViewModel)
{
@@ -78,7 +79,7 @@ public class TabCommand : ViewModelCommand
});
break;
case "Copy_Asset_Path":
- Clipboard.SetText(tabViewModel.Entry.Path);
+ ClipboardExtensions.SetText(tabViewModel.Entry.Path);
break;
}
}
diff --git a/FModel/Views/ImageMerger.xaml.cs b/FModel/Views/ImageMerger.xaml.cs
index fea18162..b990c250 100644
--- a/FModel/Views/ImageMerger.xaml.cs
+++ b/FModel/Views/ImageMerger.xaml.cs
@@ -250,6 +250,6 @@ public partial class ImageMerger : Window
private void OnCopyImage(object sender, RoutedEventArgs e)
{
- ClipboardExtensions.SetImage(_imageBuffer, FILENAME);
+ ClipboardExtensions.SetImage(_imageBuffer);
}
}
diff --git a/FModel/Views/Resources/Controls/Aup/Timeclock.cs b/FModel/Views/Resources/Controls/Aup/Timeclock.cs
index eab86f96..8a4f6afc 100644
--- a/FModel/Views/Resources/Controls/Aup/Timeclock.cs
+++ b/FModel/Views/Resources/Controls/Aup/Timeclock.cs
@@ -1,12 +1,17 @@
using System;
-using System.Windows;
-using System.Windows.Controls;
-using System.Windows.Media;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Threading;
namespace FModel.Views.Resources.Controls.Aup;
-[TemplatePart(Name = "PART_Timeclock", Type = typeof(Grid))]
-[TemplatePart(Name = "PART_Time", Type = typeof(TextBlock))]
+///
+/// Displays a running clock (elapsed or remaining) bound to an .
+/// Avalonia port of the WPF Timeclock UserControl — ControlTemplate replaced by a
+/// visual tree built in the constructor.
+///
public sealed class Timeclock : UserControl
{
public enum EClockType
@@ -15,302 +20,191 @@ public sealed class Timeclock : UserControl
TimeRemaining
}
- private Grid _timeclockGrid;
- private TextBlock _timeText;
+ private readonly TextBlock _labelText;
+ private readonly TextBlock _timeText;
private const string DefaultTimeFormat = "hh\\:mm\\:ss\\.ff";
- static Timeclock()
- {
- DefaultStyleKeyProperty.OverrideMetadata(typeof(Timeclock), new FrameworkPropertyMetadata(typeof(Timeclock)));
- }
+ // -----------------------------------------------------------------------
+ // Styled properties
+ // -----------------------------------------------------------------------
- private ISource _source;
- public ISource Source
+ public static readonly StyledProperty SourceProperty =
+ AvaloniaProperty.Register(nameof(Source));
+ public ISource? Source
{
- get => (ISource) GetValue(SourceProperty);
+ get => GetValue(SourceProperty);
set => SetValue(SourceProperty, value);
}
- public static readonly DependencyProperty SourceProperty =
- DependencyProperty.Register("Source", typeof(ISource), typeof(Timeclock),
- new UIPropertyMetadata(null, OnSourceChanged, OnCoerceSource));
+ public static readonly StyledProperty ClockTypeProperty =
+ AvaloniaProperty.Register(nameof(ClockType), defaultValue: EClockType.TimeElapsed);
public EClockType ClockType
{
- get => (EClockType) GetValue(ClockTypeProperty);
+ get => GetValue(ClockTypeProperty);
set => SetValue(ClockTypeProperty, value);
}
- public static readonly DependencyProperty ClockTypeProperty =
- DependencyProperty.Register("ClockType", typeof(EClockType), typeof(Timeclock),
- new UIPropertyMetadata(EClockType.TimeElapsed, OnClockTypeChanged, OnCoerceClockType));
+ public static readonly StyledProperty LabelFontProperty =
+ AvaloniaProperty.Register(nameof(LabelFont), defaultValue: new FontFamily("Segoe UI"));
public FontFamily LabelFont
{
- get => (FontFamily) GetValue(LabelFontProperty);
+ get => GetValue(LabelFontProperty);
set => SetValue(LabelFontProperty, value);
}
- public static readonly DependencyProperty LabelFontProperty =
- DependencyProperty.Register("LabelFont", typeof(FontFamily), typeof(Timeclock),
- new UIPropertyMetadata(new FontFamily("Segoe UI"), OnLabelFontChanged, OnCoerceLabelFont));
- public Brush LabelForeground
+ public static readonly StyledProperty LabelForegroundProperty =
+ AvaloniaProperty.Register(nameof(LabelForeground), defaultValue: Brushes.Coral);
+ public IBrush? LabelForeground
{
- get => (Brush) GetValue(LabelForegroundProperty);
+ get => GetValue(LabelForegroundProperty);
set => SetValue(LabelForegroundProperty, value);
}
- public static readonly DependencyProperty LabelForegroundProperty =
- DependencyProperty.Register("LabelForeground", typeof(Brush), typeof(Timeclock),
- new UIPropertyMetadata(Brushes.Coral, OnLabelForegroundChanged, OnCoerceLabelForeground));
+ public static readonly StyledProperty TimeFontProperty =
+ AvaloniaProperty.Register(nameof(TimeFont), defaultValue: new FontFamily("Ebrima"));
public FontFamily TimeFont
{
- get => (FontFamily) GetValue(TimeFontProperty);
+ get => GetValue(TimeFontProperty);
set => SetValue(TimeFontProperty, value);
}
- public static readonly DependencyProperty TimeFontProperty =
- DependencyProperty.Register("TimeFont", typeof(FontFamily), typeof(Timeclock),
- new UIPropertyMetadata(new FontFamily("Ebrima"), OnTimeFontChanged, OnCoerceTimeFont));
- public Brush TimeForeground
+ public static readonly StyledProperty TimeForegroundProperty =
+ AvaloniaProperty.Register(nameof(TimeForeground), defaultValue: Brushes.Silver);
+ public IBrush? TimeForeground
{
- get => (Brush) GetValue(TimeForegroundProperty);
+ get => GetValue(TimeForegroundProperty);
set => SetValue(TimeForegroundProperty, value);
}
- public static readonly DependencyProperty TimeForegroundProperty =
- DependencyProperty.Register("TimeForeground", typeof(Brush), typeof(Timeclock),
- new UIPropertyMetadata(Brushes.Silver, OnTimeForegroundChanged, OnCoerceTimeForeground));
- public CornerRadius CornerRadius
- {
- get => (CornerRadius) GetValue(CornerRadiusProperty);
- set => SetValue(CornerRadiusProperty, value);
- }
- public static readonly DependencyProperty CornerRadiusProperty =
- DependencyProperty.Register("CornerRadius", typeof(CornerRadius), typeof(Timeclock),
- new UIPropertyMetadata(new CornerRadius(3), OnCornerRadiusChanged, OnCoerceCornerRadius));
+ // CornerRadius is inherited from TemplatedControl via UserControl.
+ // Default is set in the constructor via SetCurrentValue.
+ public static readonly StyledProperty LabelProperty =
+ AvaloniaProperty.Register(nameof(Label), defaultValue: string.Empty);
public string Label
{
- get => (string) GetValue(LabelProperty);
+ get => GetValue(LabelProperty);
set => SetValue(LabelProperty, value);
}
- public static readonly DependencyProperty LabelProperty =
- DependencyProperty.Register("Label", typeof(string), typeof(Timeclock),
- new UIPropertyMetadata(string.Empty, OnLabelChanged, OnCoerceLabel));
+ public static readonly StyledProperty TimeFormatProperty =
+ AvaloniaProperty.Register(nameof(TimeFormat), defaultValue: DefaultTimeFormat);
public string TimeFormat
{
- get => (string) GetValue(TimeFormatProperty);
+ get => GetValue(TimeFormatProperty);
set => SetValue(TimeFormatProperty, value);
}
- public static readonly DependencyProperty TimeFormatProperty =
- DependencyProperty.Register("TimeFormat", typeof(string), typeof(Timeclock),
- new UIPropertyMetadata(DefaultTimeFormat, OnTimeFormatChanged, OnCoerceTimeFormat));
- private void OnSourceChanged(ISource oldValue, ISource newValue)
+ // -----------------------------------------------------------------------
+ // Constructor — build visual tree directly (no ControlTemplate needed)
+ // -----------------------------------------------------------------------
+
+ public Timeclock()
{
- _source = Source;
- _source.SourceEvent += OnSourceEvent;
- _source.SourcePropertyChangedEvent += OnSourcePropertyChangedEvent;
- OnSourceEvent(this, null);
- }
-
- private void OnSourceEvent(object sender, SourceEventArgs e)
- {
- if (Source == null) return;
- Label = Source.PlayedFile.FileName;
- Dispatcher.BeginInvoke((Action) CalculateTime);
- }
-
- private void OnSourcePropertyChangedEvent(object sender, SourcePropertyChangedEventArgs e)
- {
- if (_timeText == null || e.Property != ESourceProperty.Position) return;
-
- CalculateTime();
- }
-
- private ISource OnCoerceSource(ISource value) => value;
- private static object OnCoerceSource(DependencyObject o, object value)
- {
- if (o is Timeclock timeclock)
- return timeclock.OnCoerceSource((ISource) value);
- return value;
- }
-
- private static void OnSourceChanged(DependencyObject o, DependencyPropertyChangedEventArgs e)
- {
- if (o is Timeclock timeclock)
- timeclock.OnSourceChanged((ISource) e.OldValue, (ISource) e.NewValue);
- }
-
- private EClockType OnCoerceClockType(EClockType value) => value;
- private static object OnCoerceClockType(DependencyObject o, object value)
- {
- if (o is Timeclock timeclock)
- return timeclock.OnCoerceClockType((EClockType) value);
- return value;
- }
-
- private void OnClockTypeChanged(EClockType oldValue, EClockType newValue) => CalculateTime();
- private static void OnClockTypeChanged(DependencyObject o, DependencyPropertyChangedEventArgs e)
- {
- if (o is Timeclock timeclock)
- timeclock.OnClockTypeChanged((EClockType) e.OldValue, (EClockType) e.NewValue);
- }
-
- private FontFamily OnCoerceLabelFont(FontFamily value) => value;
- private static object OnCoerceLabelFont(DependencyObject o, object value)
- {
- if (o is Timeclock timeclock)
- return timeclock.OnCoerceLabelFont((FontFamily) value);
- return value;
- }
-
- private void OnLabelFontChanged(FontFamily oldValue, FontFamily newValue)
- {
- }
-
- private static void OnLabelFontChanged(DependencyObject o, DependencyPropertyChangedEventArgs e)
- {
- if (o is Timeclock timeclock)
- timeclock.OnLabelFontChanged((FontFamily) e.OldValue, (FontFamily) e.NewValue);
- }
-
- private Brush OnCoerceLabelForeground(Brush value) => value;
- private static object OnCoerceLabelForeground(DependencyObject o, object value)
- {
- if (o is Timeclock timeclock)
- return timeclock.OnCoerceLabelForeground((Brush) value);
- return value;
- }
-
- private void OnLabelForegroundChanged(Brush oldValue, Brush newValue)
- {
- }
-
- private static void OnLabelForegroundChanged(DependencyObject o, DependencyPropertyChangedEventArgs e)
- {
- if (o is Timeclock timeclock)
- timeclock.OnLabelForegroundChanged((Brush) e.OldValue, (Brush) e.NewValue);
- }
-
- private FontFamily OnCoerceTimeFont(FontFamily value) => value;
- private static object OnCoerceTimeFont(DependencyObject o, object value)
- {
- if (o is Timeclock timeclock)
- return timeclock.OnCoerceTimeFont((FontFamily) value);
- return value;
- }
-
- private void OnTimeFontChanged(FontFamily oldValue, FontFamily newValue)
- {
- }
-
- private static void OnTimeFontChanged(DependencyObject o, DependencyPropertyChangedEventArgs e)
- {
- if (o is Timeclock timeclock)
- timeclock.OnTimeFontChanged((FontFamily) e.OldValue, (FontFamily) e.NewValue);
- }
-
- private Brush OnCoerceTimeForeground(Brush value) => value;
- private static object OnCoerceTimeForeground(DependencyObject o, object value)
- {
- if (o is Timeclock timeclock)
- return timeclock.OnCoerceTimeForeground((Brush) value);
- return value;
- }
-
- private void OnTimeForegroundChanged(Brush oldValue, Brush newValue)
- {
- }
-
- private static void OnTimeForegroundChanged(DependencyObject o, DependencyPropertyChangedEventArgs e)
- {
- if (o is Timeclock timeclock)
- timeclock.OnTimeForegroundChanged((Brush) e.OldValue, (Brush) e.NewValue);
- }
-
- private CornerRadius OnCoerceCornerRadius(CornerRadius value) => value;
- private static object OnCoerceCornerRadius(DependencyObject o, object value)
- {
- if (o is Timeclock timeclock)
- return timeclock.OnCoerceCornerRadius((CornerRadius) value);
- return value;
- }
-
- private void OnCornerRadiusChanged(CornerRadius oldValue, CornerRadius newValue)
- {
- }
-
- private static void OnCornerRadiusChanged(DependencyObject o, DependencyPropertyChangedEventArgs e)
- {
- if (o is Timeclock timeclock)
- timeclock.OnCornerRadiusChanged((CornerRadius) e.OldValue, (CornerRadius) e.NewValue);
- }
-
- private string OnCoerceLabel(string value) => value;
- private static object OnCoerceLabel(DependencyObject o, object value)
- {
- if (o is Timeclock timeclock)
- return timeclock.OnCoerceLabel((string) value);
- return value;
- }
-
- private void OnLabelChanged(string oldValue, string newValue)
- {
- }
-
- private static void OnLabelChanged(DependencyObject o, DependencyPropertyChangedEventArgs e)
- {
- if (o is Timeclock timeclock)
- timeclock.OnLabelChanged((string) e.OldValue, (string) e.NewValue);
- }
-
- private string OnCoerceTimeFormat(string value) => value;
- private static object OnCoerceTimeFormat(DependencyObject o, object value)
- {
- if (o is Timeclock timeclock)
- return timeclock.OnCoerceTimeFormat((string) value);
- return value;
- }
-
- private void OnTimeFormatChanged(string oldValue, string newValue)
- {
- try
+ _labelText = new TextBlock
{
- TimeSpan.Zero.ToString(newValue);
- }
- catch
+ VerticalAlignment = VerticalAlignment.Center,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ };
+ _timeText = new TextBlock
{
- TimeFormat = DefaultTimeFormat;
+ VerticalAlignment = VerticalAlignment.Center,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ };
+
+ var border = new Border
+ {
+ Child = new StackPanel
+ {
+ Orientation = Orientation.Vertical,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ Children = { _labelText, _timeText }
+ }
+ };
+
+ // Bind border CornerRadius to the inherited CornerRadius styled property.
+ border.Bind(Border.CornerRadiusProperty, this.GetObservable(UserControl.CornerRadiusProperty));
+ SetCurrentValue(UserControl.CornerRadiusProperty, new CornerRadius(3));
+
+ // Bind textblock fonts / foregrounds to styled properties.
+ _labelText.Bind(TextBlock.FontFamilyProperty, this.GetObservable(LabelFontProperty));
+ _labelText.Bind(TextBlock.ForegroundProperty, this.GetObservable(LabelForegroundProperty));
+ _labelText.Bind(TextBlock.TextProperty, this.GetObservable(LabelProperty));
+ _timeText.Bind(TextBlock.FontFamilyProperty, this.GetObservable(TimeFontProperty));
+ _timeText.Bind(TextBlock.ForegroundProperty, this.GetObservable(TimeForegroundProperty));
+
+ Content = border;
+
+ ZeroTime();
+ }
+
+ // -----------------------------------------------------------------------
+ // Property-change reactions
+ // -----------------------------------------------------------------------
+
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ base.OnPropertyChanged(change);
+
+ if (change.Property == SourceProperty)
+ {
+ if (change.OldValue is ISource old)
+ {
+ old.SourceEvent -= OnSourceEvent;
+ old.SourcePropertyChangedEvent -= OnSourcePropertyChangedEvent;
+ }
+ if (change.NewValue is ISource src)
+ {
+ src.SourceEvent += OnSourceEvent;
+ src.SourcePropertyChangedEvent += OnSourcePropertyChangedEvent;
+ }
+ // Refresh display immediately.
+ CalculateTime();
}
+ else if (change.Property == ClockTypeProperty
+ || change.Property == TimeFormatProperty)
+ {
+ if (change.Property == TimeFormatProperty)
+ {
+ // Validate the new format string; revert to default on error.
+ try
+ { TimeSpan.Zero.ToString(TimeFormat); }
+ catch { SetCurrentValue(TimeFormatProperty, DefaultTimeFormat); }
+ }
+ CalculateTime();
+ }
+ }
+ private void OnSourceEvent(object? sender, SourceEventArgs? e)
+ {
+ if (Source == null)
+ return;
+ Dispatcher.UIThread.Post(() =>
+ {
+ SetCurrentValue(LabelProperty, Source.PlayedFile.FileName);
+ CalculateTime();
+ });
+ }
+
+ // NOTE: This handler may be called from a background (audio) thread.
+ // CalculateTime() reads Source.PlayedFile.Position/Duration before marshalling
+ // to the UI thread. This is safe because ISource properties are immutable value
+ // types (TimeSpan) and the audio engine guarantees coherent reads.
+ private void OnSourcePropertyChangedEvent(object? sender, SourcePropertyChangedEventArgs e)
+ {
+ if (e.Property != ESourceProperty.Position)
+ return;
CalculateTime();
}
- private static void OnTimeFormatChanged(DependencyObject o, DependencyPropertyChangedEventArgs e)
- {
- if (o is Timeclock timeclock)
- timeclock.OnTimeFormatChanged((string) e.OldValue, (string) e.NewValue);
- }
-
- public override void OnApplyTemplate()
- {
- base.OnApplyTemplate();
-
- _timeclockGrid = GetTemplateChild("PART_Timeclock") as Grid;
- _timeText = GetTemplateChild("PART_Time") as TextBlock;
- _timeclockGrid.CacheMode = new BitmapCache();
-
- OnSourceEvent(this, null);
- }
-
private void CalculateTime()
{
- if (_source != null)
+ if (Source != null)
{
- var position = _source.PlayedFile.Position;
- var length = _source.PlayedFile.Duration;
- Dispatcher.BeginInvoke((Action) delegate
+ var position = Source.PlayedFile.Position;
+ var length = Source.PlayedFile.Duration;
+ Dispatcher.UIThread.Post(() =>
{
_timeText.Text = ClockType switch
{
@@ -326,9 +220,20 @@ public sealed class Timeclock : UserControl
}
}
+ protected override void OnDetachedFromVisualTree(Avalonia.VisualTreeAttachmentEventArgs e)
+ {
+ base.OnDetachedFromVisualTree(e);
+
+ if (Source is { } src)
+ {
+ src.SourceEvent -= OnSourceEvent;
+ src.SourcePropertyChangedEvent -= OnSourcePropertyChangedEvent;
+ }
+ }
+
private void ZeroTime()
{
- Dispatcher.BeginInvoke((Action) delegate
+ Dispatcher.UIThread.Post(() =>
{
_timeText.Text = TimeSpan.Zero.ToString(TimeFormat);
});
diff --git a/FModel/Views/Resources/Controls/Breadcrumb.xaml b/FModel/Views/Resources/Controls/Breadcrumb.xaml
index f8c0f29c..045415f3 100644
--- a/FModel/Views/Resources/Controls/Breadcrumb.xaml
+++ b/FModel/Views/Resources/Controls/Breadcrumb.xaml
@@ -1,6 +1,9 @@
-
+
diff --git a/FModel/Views/Resources/Controls/Breadcrumb.xaml.cs b/FModel/Views/Resources/Controls/Breadcrumb.xaml.cs
index 7c8187a0..53e5cec0 100644
--- a/FModel/Views/Resources/Controls/Breadcrumb.xaml.cs
+++ b/FModel/Views/Resources/Controls/Breadcrumb.xaml.cs
@@ -1,9 +1,11 @@
using System.Linq;
-using System.Windows;
-using System.Windows.Controls;
-using System.Windows.Input;
-using System.Windows.Media;
-using System.Windows.Shapes;
+using System;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Media.Immutable;
using FModel.Services;
namespace FModel.Views.Resources.Controls;
@@ -17,22 +19,26 @@ public partial class Breadcrumb
InitializeComponent();
}
- private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
+ private void OnDataContextChanged(object? sender, EventArgs e)
{
- if (e.NewValue is not string pathAtThisPoint) return;
+ if (DataContext is not string pathAtThisPoint)
+ return;
InMeDaddy.Children.Clear();
var folders = pathAtThisPoint.Split('/');
for (var i = 0; i < folders.Length; i++)
{
+ var capturedIndex = i + 1;
+ var capturedPath = pathAtThisPoint;
+
var border = new Border
{
BorderThickness = new Thickness(1),
BorderBrush = Brushes.Transparent,
Background = Brushes.Transparent,
Padding = new Thickness(6, 3, 6, 3),
- Cursor = Cursors.Hand,
- Tag = i + 1,
+ Cursor = new Cursor(StandardCursorType.Hand),
+ Tag = capturedIndex,
IsEnabled = i < folders.Length - 1,
Child = new TextBlock
{
@@ -41,12 +47,29 @@ public partial class Breadcrumb
}
};
- border.MouseEnter += OnMouseEnter;
- border.MouseLeave += OnMouseLeave;
- border.MouseUp += OnMouseClick;
+ border.PointerEntered += (_, _) =>
+ {
+ border.BorderBrush = new ImmutableSolidColorBrush(Color.FromRgb(127, 127, 144));
+ border.Background = new ImmutableSolidColorBrush(Color.FromRgb(72, 73, 92));
+ };
+ border.PointerExited += (_, _) =>
+ {
+ border.BorderBrush = Brushes.Transparent;
+ border.Background = Brushes.Transparent;
+ };
+ border.PointerReleased += (_, args) =>
+ {
+ if (args.InitialPressMouseButton != Avalonia.Input.MouseButton.Left)
+ return;
+ var directory = string.Join('/', capturedPath.Split('/').Take(capturedIndex));
+ if (capturedPath.Equals(directory))
+ return;
+ ApplicationService.ApplicationView.CustomDirectories?.GoToCommand.JumpTo(directory);
+ };
InMeDaddy.Children.Add(border);
- if (i >= folders.Length - 1) continue;
+ if (i >= folders.Length - 1)
+ continue;
InMeDaddy.Children.Add(new Viewbox
{
@@ -59,7 +82,7 @@ public partial class Breadcrumb
Height = 24,
Children =
{
- new Path
+ new Avalonia.Controls.Shapes.Path
{
Fill = Brushes.White,
Data = Geometry.Parse(NavigateNext),
@@ -70,32 +93,4 @@ public partial class Breadcrumb
});
}
}
-
- private void OnMouseEnter(object sender, MouseEventArgs e)
- {
- if (sender is Border border)
- {
- border.BorderBrush = new SolidColorBrush(Color.FromRgb(127, 127, 144));
- border.Background = new SolidColorBrush(Color.FromRgb(72, 73, 92));
- }
- }
-
- private void OnMouseLeave(object sender, MouseEventArgs e)
- {
- if (sender is Border border)
- {
- border.BorderBrush = Brushes.Transparent;
- border.Background = Brushes.Transparent;
- }
- }
-
- private void OnMouseClick(object sender, MouseButtonEventArgs e)
- {
- if (sender is not Border { DataContext: string pathAtThisPoint, Tag: int index }) return;
-
- var directory = string.Join('/', pathAtThisPoint.Split('/').Take(index));
- if (pathAtThisPoint.Equals(directory)) return;
-
- ApplicationService.ApplicationView.CustomDirectories.GoToCommand.JumpTo(directory);
- }
}
diff --git a/FModel/Views/Resources/Controls/CommitDownloaderControl.xaml b/FModel/Views/Resources/Controls/CommitDownloaderControl.xaml
index 734cebf2..735a711c 100644
--- a/FModel/Views/Resources/Controls/CommitDownloaderControl.xaml
+++ b/FModel/Views/Resources/Controls/CommitDownloaderControl.xaml
@@ -1,8 +1,7 @@
+ xmlns:converters="clr-namespace:FModel.Views.Resources.Converters">
@@ -10,39 +9,52 @@
-
+
-
-
-
-
+
+
-
-
diff --git a/FModel/Views/Resources/Controls/CommitDownloaderControl.xaml.cs b/FModel/Views/Resources/Controls/CommitDownloaderControl.xaml.cs
index ac9c788a..c6bd7b3b 100644
--- a/FModel/Views/Resources/Controls/CommitDownloaderControl.xaml.cs
+++ b/FModel/Views/Resources/Controls/CommitDownloaderControl.xaml.cs
@@ -1,5 +1,6 @@
-using System.Windows;
-using System.Windows.Controls;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
using FModel.ViewModels.ApiEndpoints.Models;
namespace FModel.Views.Resources.Controls;
@@ -11,18 +12,18 @@ public partial class CommitDownloaderControl : UserControl
InitializeComponent();
}
- public static readonly DependencyProperty CommitProperty =
- DependencyProperty.Register(nameof(Commit), typeof(GitHubCommit), typeof(CommitDownloaderControl), new PropertyMetadata(null));
+ public static readonly StyledProperty CommitProperty =
+ AvaloniaProperty.Register(nameof(Commit));
- public GitHubCommit Commit
+ public GitHubCommit? Commit
{
- get { return (GitHubCommit)GetValue(CommitProperty); }
- set { SetValue(CommitProperty, value); }
+ get => GetValue(CommitProperty);
+ set => SetValue(CommitProperty, value);
}
- private void OnDownload(object sender, RoutedEventArgs e)
+ private void OnDownload(object? sender, RoutedEventArgs e)
{
- Commit.Download();
+ Commit?.Download();
}
}
diff --git a/FModel/Views/Resources/Controls/ContextMenus/FileContextMenu.xaml b/FModel/Views/Resources/Controls/ContextMenus/FileContextMenu.xaml
index b72206f3..8c07bab3 100644
--- a/FModel/Views/Resources/Controls/ContextMenus/FileContextMenu.xaml
+++ b/FModel/Views/Resources/Controls/ContextMenus/FileContextMenu.xaml
@@ -1,34 +1,43 @@
-
-
-
diff --git a/FModel/Views/Resources/Controls/EndpointEditor.xaml.cs b/FModel/Views/Resources/Controls/EndpointEditor.xaml.cs
index a85842bf..cf582833 100644
--- a/FModel/Views/Resources/Controls/EndpointEditor.xaml.cs
+++ b/FModel/Views/Resources/Controls/EndpointEditor.xaml.cs
@@ -1,5 +1,7 @@
+using System;
using System.Diagnostics;
-using System.Windows.Controls;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
using Avalonia.Threading;
using FModel.Extensions;
using FModel.Services;
@@ -9,10 +11,11 @@ using Newtonsoft.Json;
namespace FModel.Views.Resources.Controls;
-public partial class EndpointEditor
+public partial class EndpointEditor : Window
{
private readonly EEndpointType _type;
private bool _isTested;
+ private bool _isInitialized;
public EndpointEditor(EndpointSettings endpoint, string title, EEndpointType type)
{
@@ -26,6 +29,7 @@ public partial class EndpointEditor
TargetResponse.SyntaxHighlighting =
EndpointResponse.SyntaxHighlighting = AvalonExtensions.HighlighterSelector("json");
+ _isInitialized = true;
InstructionBox.Text = type switch
{
EEndpointType.Aes =>
@@ -48,13 +52,12 @@ public partial class EndpointEditor
};
}
- private void OnClick(object sender, RoutedEventArgs e)
+ private void OnClick(object? sender, RoutedEventArgs e)
{
- DialogResult = _isTested && DataContext is EndpointSettings { IsValid: true };
- Close();
+ Close(_isTested && DataContext is EndpointSettings { IsValid: true });
}
- private async void OnSend(object sender, RoutedEventArgs e)
+ private async void OnSend(object? sender, RoutedEventArgs e)
{
if (DataContext is not EndpointSettings endpoint)
return;
@@ -67,7 +70,7 @@ public partial class EndpointEditor
});
}
- private void OnTest(object sender, RoutedEventArgs e)
+ private void OnTest(object? sender, RoutedEventArgs e)
{
if (DataContext is not EndpointSettings endpoint)
return;
@@ -79,20 +82,21 @@ public partial class EndpointEditor
TargetResponse.Document.Text = JsonConvert.SerializeObject(response, Formatting.Indented);
}
- private void OnTextChanged(object sender, TextChangedEventArgs e)
+ private void OnTextChanged(object? sender, TextChangedEventArgs e)
{
- if (sender is not TextBox { IsLoaded: true } ||
+ if (!_isInitialized ||
+ sender is not TextBox ||
DataContext is not EndpointSettings endpoint)
return;
endpoint.IsValid = false;
}
- private void OnSyntax(object sender, RoutedEventArgs e)
+ private void OnSyntax(object? sender, RoutedEventArgs e)
{
Process.Start(new ProcessStartInfo { FileName = "https://support.smartbear.com/alertsite/docs/monitors/api/endpoint/jsonpath.html", UseShellExecute = true });
}
- private void OnEvaluator(object sender, RoutedEventArgs e)
+ private void OnEvaluator(object? sender, RoutedEventArgs e)
{
Process.Start(new ProcessStartInfo { FileName = "https://jsonpath.herokuapp.com/", UseShellExecute = true });
}
diff --git a/FModel/Views/Resources/Controls/ListBoxItemBehavior.cs b/FModel/Views/Resources/Controls/ListBoxItemBehavior.cs
index d44b11eb..9304f193 100644
--- a/FModel/Views/Resources/Controls/ListBoxItemBehavior.cs
+++ b/FModel/Views/Resources/Controls/ListBoxItemBehavior.cs
@@ -1,42 +1,151 @@
-using System.Windows;
-using System.Windows.Controls;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Threading;
+using FModel.Services;
+using FModel.ViewModels;
namespace FModel.Views.Resources.Controls;
-public sealed class ListBoxItemBehavior
+public static class ListBoxItemBehavior
{
+ public static readonly AttachedProperty IsBroughtIntoViewWhenSelectedProperty =
+ AvaloniaProperty.RegisterAttached("IsBroughtIntoViewWhenSelected");
+
+ public static readonly AttachedProperty OpenOnDoubleTapProperty =
+ AvaloniaProperty.RegisterAttached("OpenOnDoubleTap");
+
+ public static readonly AttachedProperty SelectFileOnRightClickProperty =
+ AvaloniaProperty.RegisterAttached("SelectFileOnRightClick");
+
public static bool GetIsBroughtIntoViewWhenSelected(ListBoxItem listBoxItem)
- {
- return (bool) listBoxItem.GetValue(IsBroughtIntoViewWhenSelectedProperty);
- }
+ => listBoxItem.GetValue(IsBroughtIntoViewWhenSelectedProperty);
public static void SetIsBroughtIntoViewWhenSelected(ListBoxItem listBoxItem, bool value)
+ => listBoxItem.SetValue(IsBroughtIntoViewWhenSelectedProperty, value);
+
+ public static bool GetOpenOnDoubleTap(ListBoxItem listBoxItem)
+ => listBoxItem.GetValue(OpenOnDoubleTapProperty);
+
+ public static void SetOpenOnDoubleTap(ListBoxItem listBoxItem, bool value)
+ => listBoxItem.SetValue(OpenOnDoubleTapProperty, value);
+
+ public static bool GetSelectFileOnRightClick(ListBoxItem listBoxItem)
+ => listBoxItem.GetValue(SelectFileOnRightClickProperty);
+
+ public static void SetSelectFileOnRightClick(ListBoxItem listBoxItem, bool value)
+ => listBoxItem.SetValue(SelectFileOnRightClickProperty, value);
+
+ static ListBoxItemBehavior()
{
- listBoxItem.SetValue(IsBroughtIntoViewWhenSelectedProperty, value);
+ IsBroughtIntoViewWhenSelectedProperty.Changed.AddClassHandler(OnIsBroughtIntoViewWhenSelectedChanged);
+ OpenOnDoubleTapProperty.Changed.AddClassHandler(OnOpenOnDoubleTapChanged);
+ SelectFileOnRightClickProperty.Changed.AddClassHandler(OnSelectFileOnRightClickChanged);
}
- public static readonly DependencyProperty IsBroughtIntoViewWhenSelectedProperty =
- DependencyProperty.RegisterAttached("IsBroughtIntoViewWhenSelected", typeof(bool), typeof(ListBoxItemBehavior),
- new UIPropertyMetadata(false, OnIsBroughtIntoViewWhenSelectedChanged));
-
- private static void OnIsBroughtIntoViewWhenSelectedChanged(DependencyObject depObj, DependencyPropertyChangedEventArgs e)
+ private static void OnIsBroughtIntoViewWhenSelectedChanged(ListBoxItem item, AvaloniaPropertyChangedEventArgs e)
{
- if (depObj is not ListBoxItem item)
- return;
+ item.PropertyChanged -= OnListBoxItemPropertyChanged;
- if (e.NewValue is not bool value)
- return;
-
- if (value)
- item.Selected += OnListBoxItemSelected;
- else
- item.Selected -= OnListBoxItemSelected;
+ if (e.GetNewValue())
+ item.PropertyChanged += OnListBoxItemPropertyChanged;
}
- private static void OnListBoxItemSelected(object sender, RoutedEventArgs e)
+ private static void OnOpenOnDoubleTapChanged(ListBoxItem item, AvaloniaPropertyChangedEventArgs e)
{
- if (e.OriginalSource is ListBoxItem item)
+ item.DoubleTapped -= OnListBoxItemDoubleTapped;
+
+ if (e.GetNewValue())
+ item.DoubleTapped += OnListBoxItemDoubleTapped;
+ }
+
+ private static void OnSelectFileOnRightClickChanged(ListBoxItem item, AvaloniaPropertyChangedEventArgs e)
+ {
+ item.PointerPressed -= OnListBoxItemPointerPressed;
+
+ if (e.GetNewValue())
+ item.PointerPressed += OnListBoxItemPointerPressed;
+ }
+
+ private static void OnListBoxItemPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
+ {
+ if (sender is ListBoxItem item &&
+ e.Property.Name == nameof(ListBoxItem.IsSelected) &&
+ item.IsSelected)
+ {
item.BringIntoView();
+ }
+ }
+
+ private static void OnListBoxItemDoubleTapped(object? sender, TappedEventArgs e)
+ {
+ if (sender is not ListBoxItem item)
+ return;
+
+ switch (item.DataContext)
+ {
+ case GameFileViewModel file:
+ ApplicationService.ApplicationView.SelectedLeftTabIndex = 2;
+ file.IsSelected = true;
+ _ = file.ExtractAsync();
+ break;
+ case TreeItem folder:
+ ApplicationService.ApplicationView.SelectedLeftTabIndex = 1;
+
+ var parent = folder.Parent;
+ while (parent != null)
+ {
+ parent.IsExpanded = true;
+ parent = parent.Parent;
+ }
+
+ var childFolder = folder;
+ while (childFolder.Folders.Count == 1 && childFolder.AssetsList.Assets.Count == 0)
+ {
+ childFolder.IsExpanded = true;
+ childFolder = childFolder.Folders[0];
+ }
+
+ childFolder.IsExpanded = true;
+ childFolder.IsSelected = true;
+ break;
+ }
+ }
+
+ private static void OnListBoxItemPointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ if (sender is not ListBoxItem item)
+ return;
+ if (!e.GetCurrentPoint(item).Properties.IsRightButtonPressed)
+ return;
+ if (item.DataContext is not GameFileViewModel)
+ return;
+
+ var listBox = ItemsControl.ItemsControlFromItemContainer(item) as ListBox;
+ if (listBox == null)
+ return;
+
+ if (!item.IsSelected)
+ {
+ listBox.UnselectAll();
+ item.IsSelected = true;
+ }
+
+ item.Focus();
+
+ if (listBox.TryFindResource("FileContextMenu", out var resource) && resource is ContextMenu contextMenu)
+ {
+ listBox.ContextMenu = null;
+ Dispatcher.UIThread.Post(() =>
+ {
+ contextMenu.DataContext = listBox.DataContext;
+ listBox.ContextMenu = contextMenu;
+ contextMenu.PlacementTarget = listBox;
+ contextMenu.Open(listBox);
+ });
+
+ e.Handled = true;
+ }
}
}
diff --git a/FModel/Views/Resources/Controls/OnTagDataTemplateSelector.cs b/FModel/Views/Resources/Controls/OnTagDataTemplateSelector.cs
index 922ed92e..7134212a 100644
--- a/FModel/Views/Resources/Controls/OnTagDataTemplateSelector.cs
+++ b/FModel/Views/Resources/Controls/OnTagDataTemplateSelector.cs
@@ -1,13 +1,6 @@
-using System.Windows;
-using System.Windows.Controls;
-
+// WPF's DataTemplateSelector has no direct Avalonia equivalent.
+// Template selection is handled in code-behind (SettingsView.xaml.cs → OnSelectedItemChanged).
namespace FModel.Views.Resources.Controls;
-public class OnTagDataTemplateSelector : DataTemplateSelector
-{
- public override DataTemplate SelectTemplate(object item, DependencyObject container)
- {
- if (item is not string s || container is not FrameworkElement f) return null;
- return f.FindResource(s) as DataTemplate;
- }
-}
\ No newline at end of file
+// kept as a stub so any stale XAML/project references don't cause build errors.
+public sealed class OnTagDataTemplateSelector { }
diff --git a/FModel/Views/Resources/Controls/TiledExplorer/FileButton2.xaml b/FModel/Views/Resources/Controls/TiledExplorer/FileButton2.xaml
index 4096bb21..3675463d 100644
--- a/FModel/Views/Resources/Controls/TiledExplorer/FileButton2.xaml
+++ b/FModel/Views/Resources/Controls/TiledExplorer/FileButton2.xaml
@@ -1,16 +1,11 @@
+ Width="128"
+ Height="192"
+ Background="#1A1A2E">
@@ -18,19 +13,16 @@
-
-
-
-
-
-
+
+
+
@@ -44,40 +36,31 @@
-
-
-
-
-
+
-
+
-
+
-
+
@@ -86,15 +69,27 @@
-
+
-
+
diff --git a/FModel/Views/Resources/Controls/TiledExplorer/FileButton2.xaml.cs b/FModel/Views/Resources/Controls/TiledExplorer/FileButton2.xaml.cs
index ed250212..7f62c391 100644
--- a/FModel/Views/Resources/Controls/TiledExplorer/FileButton2.xaml.cs
+++ b/FModel/Views/Resources/Controls/TiledExplorer/FileButton2.xaml.cs
@@ -1,4 +1,4 @@
-using System.Windows.Controls;
+using Avalonia.Controls;
namespace FModel.Views.Resources.Controls.TiledExplorer;
diff --git a/FModel/Views/Resources/Controls/TiledExplorer/FolderButton2.xaml b/FModel/Views/Resources/Controls/TiledExplorer/FolderButton2.xaml
index 0eda947a..f26930cd 100644
--- a/FModel/Views/Resources/Controls/TiledExplorer/FolderButton2.xaml
+++ b/FModel/Views/Resources/Controls/TiledExplorer/FolderButton2.xaml
@@ -1,16 +1,11 @@
+ Background="#252535">
@@ -18,12 +13,15 @@
-
-
+
+
+ StartPoint="0%,0%"
+ EndPoint="0%,100%">
+ StartPoint="0%,0%"
+ EndPoint="0%,100%">
-
+
+
-
-
-
-
-
+ Fill="{Binding Header, Converter={x:Static converters:FolderToGeometryConverter.Instance}}"
+ IsVisible="{Binding Header, Converter={x:Static converters:FolderToGeometryConverter.Instance}, ConverterParameter=visible}" />
@@ -84,26 +83,26 @@
+ Foreground="#DAE5F2" />
diff --git a/FModel/Views/Resources/Controls/TiledExplorer/FolderButton2.xaml.cs b/FModel/Views/Resources/Controls/TiledExplorer/FolderButton2.xaml.cs
index e0443a44..ab45769a 100644
--- a/FModel/Views/Resources/Controls/TiledExplorer/FolderButton2.xaml.cs
+++ b/FModel/Views/Resources/Controls/TiledExplorer/FolderButton2.xaml.cs
@@ -1,4 +1,4 @@
-using System.Windows.Controls;
+using Avalonia.Controls;
namespace FModel.Views.Resources.Controls.TiledExplorer;
diff --git a/FModel/Views/Resources/Controls/TiledExplorer/FolderButton3.xaml b/FModel/Views/Resources/Controls/TiledExplorer/FolderButton3.xaml
index a0c2a76c..193c30fb 100644
--- a/FModel/Views/Resources/Controls/TiledExplorer/FolderButton3.xaml
+++ b/FModel/Views/Resources/Controls/TiledExplorer/FolderButton3.xaml
@@ -1,15 +1,10 @@
+ Width="128"
+ Height="192"
+ Background="#1A1A2E">
@@ -17,58 +12,80 @@
-
-
+
+
-
-
-
-
+
+
+
+
-
-
-
-
+
+
+
+
-
-
-
+
+
+
-
+
-
-
-
-
-
+ Fill="{Binding Header, Converter={x:Static converters:FolderToGeometryConverter.Instance}}"
+ IsVisible="{Binding Header, Converter={x:Static converters:FolderToGeometryConverter.Instance}, ConverterParameter=visible}" />
-
+
-
+
@@ -84,8 +101,10 @@
-
-
+
+
diff --git a/FModel/Views/Resources/Controls/TiledExplorer/FolderButton3.xaml.cs b/FModel/Views/Resources/Controls/TiledExplorer/FolderButton3.xaml.cs
index d8a1d73f..85d35dd4 100644
--- a/FModel/Views/Resources/Controls/TiledExplorer/FolderButton3.xaml.cs
+++ b/FModel/Views/Resources/Controls/TiledExplorer/FolderButton3.xaml.cs
@@ -1,4 +1,4 @@
-using System.Windows.Controls;
+using Avalonia.Controls;
namespace FModel.Views.Resources.Controls.TiledExplorer;
diff --git a/FModel/Views/Resources/Controls/TiledExplorer/Resources.xaml b/FModel/Views/Resources/Controls/TiledExplorer/Resources.xaml
index a21c075e..01368774 100644
--- a/FModel/Views/Resources/Controls/TiledExplorer/Resources.xaml
+++ b/FModel/Views/Resources/Controls/TiledExplorer/Resources.xaml
@@ -1,155 +1,153 @@
-
+
-
-
-
+
+
+
+
+
-
-
-
-
-
-
-
-
+ HorizontalAlignment="Right"
+ Margin="4"
+ Padding="6,2"
+ CornerRadius="3"
+ Opacity="0.85"
+ Background="#2C3245">
+ Foreground="White"
+ FontSize="11"
+ FontWeight="SemiBold"
+ VerticalAlignment="Center"
+ Margin="0,0,5,0" />
+ Fill="White"
+ Width="12"
+ Height="12"
+ Stretch="Uniform"
+ VerticalAlignment="Center" />
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
diff --git a/FModel/Views/Resources/Controls/TiledExplorer/Resources.xaml.cs b/FModel/Views/Resources/Controls/TiledExplorer/Resources.xaml.cs
index 907a9a71..ecc3dd10 100644
--- a/FModel/Views/Resources/Controls/TiledExplorer/Resources.xaml.cs
+++ b/FModel/Views/Resources/Controls/TiledExplorer/Resources.xaml.cs
@@ -1,9 +1,4 @@
-using System;
-using System.Windows.Controls;
-using System.Windows.Input;
-using System.Windows.Threading;
-using FModel.Services;
-using FModel.ViewModels;
+using Avalonia.Controls;
namespace FModel.Views.Resources.Controls.TiledExplorer;
@@ -13,75 +8,4 @@ public partial class ResourcesDictionary
{
InitializeComponent();
}
-
- private void OnMouseDoubleClick(object sender, MouseButtonEventArgs e)
- {
- if (sender is not ListBoxItem item)
- return;
-
- switch (item.DataContext)
- {
- case GameFileViewModel file:
- ApplicationService.ApplicationView.SelectedLeftTabIndex = 2;
- file.IsSelected = true;
- file.ExtractAsync();
- break;
- case TreeItem folder:
- ApplicationService.ApplicationView.SelectedLeftTabIndex = 1;
-
- // Expand all parent folders if not expanded
- var parent = folder.Parent;
- while (parent != null)
- {
- parent.IsExpanded = true;
- parent = parent.Parent;
- }
-
- // Auto expand single child folders
- var childFolder = folder;
- while (childFolder.Folders.Count == 1 && childFolder.AssetsList.Assets.Count == 0)
- {
- childFolder.IsExpanded = true;
- childFolder = childFolder.Folders[0];
- }
-
- childFolder.IsExpanded = true;
- childFolder.IsSelected = true;
- break;
- }
- }
-
- // Hack to force re-evaluation of context menu options, also prevents menu flicker from happening
- private void OnPreviewMouseRightButtonDown(object sender, MouseButtonEventArgs e)
- {
- if (sender is not ListBoxItem item)
- return;
- if (item.DataContext is not GameFileViewModel)
- return;
- var listBox = ItemsControl.ItemsControlFromItemContainer(item) as ListBox;
- if (listBox == null)
- return;
-
- if (!item.IsSelected)
- {
- listBox.UnselectAll();
- item.IsSelected = true;
- }
- item.Focus();
-
- var contextMenu = listBox.FindResource("FileContextMenu") as ContextMenu;
- if (contextMenu is not null)
- {
- listBox.ContextMenu = null;
- item.Dispatcher.BeginInvoke(new Action(() =>
- {
- contextMenu.DataContext = listBox.DataContext;
- listBox.ContextMenu = contextMenu;
- contextMenu.PlacementTarget = listBox;
- contextMenu.IsOpen = true;
- }), DispatcherPriority.Input);
- }
-
- e.Handled = true;
- }
}
diff --git a/FModel/Views/Resources/Controls/TiledExplorer/SmoothScroll.cs b/FModel/Views/Resources/Controls/TiledExplorer/SmoothScroll.cs
index cdd07d05..3b0339a6 100644
--- a/FModel/Views/Resources/Controls/TiledExplorer/SmoothScroll.cs
+++ b/FModel/Views/Resources/Controls/TiledExplorer/SmoothScroll.cs
@@ -1,7 +1,10 @@
-using System.Windows;
-using System.Windows.Controls;
-using System.Windows.Input;
-using System.Windows.Media;
+using System;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.VisualTree;
namespace FModel.Views.Resources.Controls.TiledExplorer;
@@ -12,79 +15,94 @@ namespace FModel.Views.Resources.Controls.TiledExplorer;
///
public static class SmoothScroll
{
- public static readonly DependencyProperty IsEnabledProperty = DependencyProperty.RegisterAttached(
- "IsEnabled", typeof(bool), typeof(SmoothScroll), new PropertyMetadata(false, OnIsEnabledChanged));
+ public static readonly AttachedProperty IsEnabledProperty =
+ AvaloniaProperty.RegisterAttached("IsEnabled");
- public static readonly DependencyProperty FactorProperty = DependencyProperty.RegisterAttached(
- "Factor", typeof(double), typeof(SmoothScroll), new PropertyMetadata(0.25));
+ public static readonly AttachedProperty FactorProperty =
+ AvaloniaProperty.RegisterAttached("Factor", 0.25);
- public static void SetIsEnabled(DependencyObject obj, bool value) => obj.SetValue(IsEnabledProperty, value);
- public static bool GetIsEnabled(DependencyObject obj) => (bool)obj.GetValue(IsEnabledProperty);
- public static void SetFactor(DependencyObject obj, double value) => obj.SetValue(FactorProperty, value);
- public static double GetFactor(DependencyObject obj) => (double)obj.GetValue(FactorProperty);
+ public static void SetIsEnabled(Control obj, bool value) => obj.SetValue(IsEnabledProperty, value);
+ public static bool GetIsEnabled(Control obj) => obj.GetValue(IsEnabledProperty);
+ public static void SetFactor(Control obj, double value) => obj.SetValue(FactorProperty, value);
+ public static double GetFactor(Control obj) => obj.GetValue(FactorProperty);
- private static void OnIsEnabledChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ private static readonly ConditionalWeakTable _scrollViewerCache = new();
+
+ static SmoothScroll()
{
- if (d is UIElement element)
+ IsEnabledProperty.Changed.Subscribe(OnIsEnabledChanged);
+ }
+
+ private static void OnIsEnabledChanged(AvaloniaPropertyChangedEventArgs e)
+ {
+ if (e.Sender is not Control element)
+ return;
+
+ if (e.NewValue.GetValueOrDefault())
+ element.PointerWheelChanged += Element_PointerWheelChanged;
+ else
{
- if ((bool)e.NewValue)
- element.PreviewMouseWheel += Element_PreviewMouseWheel;
- else
- element.PreviewMouseWheel -= Element_PreviewMouseWheel;
+ element.PointerWheelChanged -= Element_PointerWheelChanged;
+ _scrollViewerCache.Remove(element);
}
}
- private static void Element_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
+ private static void Element_PointerWheelChanged(object? sender, PointerWheelEventArgs e)
{
- if (sender is not DependencyObject dep) return;
+ if (sender is not Control control)
+ return;
- var sv = FindScrollViewer(dep);
- if (sv == null) return;
+ var sv = FindScrollViewer(control);
+ if (sv == null)
+ return;
- double factor = GetFactor(dep);
- if (double.IsNaN(factor) || factor <= 0) factor = 0.25;
+ double factor = GetFactor(control);
+ if (double.IsNaN(factor) || factor <= 0)
+ factor = 0.25;
- // e.Delta is typically +/-120 per notch
- double notches = e.Delta / 120.0;
-
- // Base pixels per notch (tweakable); smaller value gives smoother/less jumpy scroll
+ double notches = e.Delta.Y;
const double basePixelsPerNotch = 50.0;
-
double adjustedPixels = notches * basePixelsPerNotch * factor;
- // Prefer vertical scrolling when possible
- if (sv.ScrollableHeight > 0)
+ var scrollableHeight = Math.Max(0, sv.Extent.Height - sv.Viewport.Height);
+ var scrollableWidth = Math.Max(0, sv.Extent.Width - sv.Viewport.Width);
+
+ if (scrollableHeight > 0)
{
- double newOffset = sv.VerticalOffset - adjustedPixels;
- if (newOffset < 0) newOffset = 0;
- if (newOffset > sv.ScrollableHeight) newOffset = sv.ScrollableHeight;
- sv.ScrollToVerticalOffset(newOffset);
+ double newOffset = sv.Offset.Y - adjustedPixels;
+ if (newOffset < 0)
+ newOffset = 0;
+ if (newOffset > scrollableHeight)
+ newOffset = scrollableHeight;
+ sv.Offset = sv.Offset.WithY(newOffset);
e.Handled = true;
return;
}
- if (sv.ScrollableWidth > 0)
+ if (scrollableWidth > 0)
{
- double newOffset = sv.HorizontalOffset - adjustedPixels;
- if (newOffset < 0) newOffset = 0;
- if (newOffset > sv.ScrollableWidth) newOffset = sv.ScrollableWidth;
- sv.ScrollToHorizontalOffset(newOffset);
+ double newOffset = sv.Offset.X - adjustedPixels;
+ if (newOffset < 0)
+ newOffset = 0;
+ if (newOffset > scrollableWidth)
+ newOffset = scrollableWidth;
+ sv.Offset = sv.Offset.WithX(newOffset);
e.Handled = true;
}
}
- private static ScrollViewer FindScrollViewer(DependencyObject d)
+ private static ScrollViewer? FindScrollViewer(Control control)
{
- if (d == null) return null;
- if (d is ScrollViewer sv) return sv;
+ if (control is ScrollViewer sv)
+ return sv;
- for (int i = 0; i < VisualTreeHelper.GetChildrenCount(d); i++)
- {
- var child = VisualTreeHelper.GetChild(d, i);
- var result = FindScrollViewer(child);
- if (result != null) return result;
- }
+ if (_scrollViewerCache.TryGetValue(control, out var cached))
+ return cached;
- return null;
+ var found = control.GetVisualDescendants().OfType().FirstOrDefault();
+ if (found != null)
+ _scrollViewerCache.Add(control, found);
+
+ return found;
}
}
diff --git a/FModel/Views/Resources/Controls/TypeDataTemplateSelector.cs b/FModel/Views/Resources/Controls/TypeDataTemplateSelector.cs
index f1a4eed5..4c8d2283 100644
--- a/FModel/Views/Resources/Controls/TypeDataTemplateSelector.cs
+++ b/FModel/Views/Resources/Controls/TypeDataTemplateSelector.cs
@@ -1,18 +1,43 @@
-using System.Windows;
-using System.Windows.Controls;
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
using FModel.ViewModels;
+using FModel.Views.Resources.Controls.TiledExplorer;
namespace FModel.Views.Resources.Controls;
-public class TypeDataTemplateSelector : DataTemplateSelector
+public class TypeDataTemplateSelector : IDataTemplate
{
- public override DataTemplate SelectTemplate(object item, DependencyObject container)
+ public Control? Build(object? item)
{
return item switch
{
- TreeItem when container is FrameworkElement f => f.FindResource("TiledFolderDataTemplate") as DataTemplate,
- GameFileViewModel when container is FrameworkElement f => f.FindResource("TiledFileDataTemplate") as DataTemplate,
- _ => base.SelectTemplate(item, container)
+ TreeItem folder => BuildFolderControl(folder),
+ GameFileViewModel asset => new FileButton2 { DataContext = asset },
+ _ => null
};
}
+
+ public bool Match(object? data)
+ => data is TreeItem or GameFileViewModel;
+
+ private static Control BuildFolderControl(TreeItem folder)
+ {
+ var control = new FolderButton2 { DataContext = folder };
+
+ // Resolve context menu from visual-tree resources so x:Shared="False"
+ // returns a fresh instance per folder control.
+ // One-shot: unsubscribe after first successful resolution.
+ control.AttachedToVisualTree += OnAttached;
+
+ return control;
+
+ void OnAttached(object? sender, Avalonia.VisualTree.VisualTreeAttachmentEventArgs e)
+ {
+ if (control.TryFindResource("FolderContextMenu", out var res) && res is ContextMenu menu)
+ {
+ control.ContextMenu = menu;
+ control.AttachedToVisualTree -= OnAttached;
+ }
+ }
+ }
}
diff --git a/FModel/Views/Resources/Converters/AssetExtensionToIconConverter.cs b/FModel/Views/Resources/Converters/AssetExtensionToIconConverter.cs
new file mode 100644
index 00000000..5577011f
--- /dev/null
+++ b/FModel/Views/Resources/Converters/AssetExtensionToIconConverter.cs
@@ -0,0 +1,44 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using Avalonia.Data.Converters;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform;
+
+namespace FModel.Views.Resources.Converters;
+
+public class AssetExtensionToIconConverter : IValueConverter
+{
+ public static readonly AssetExtensionToIconConverter Instance = new();
+
+ private static readonly Lazy> CachedIcons = new(CreateIcons);
+
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ var extension = value as string ?? string.Empty;
+ return CachedIcons.Value.TryGetValue(extension, out var icon)
+ ? icon
+ : CachedIcons.Value[string.Empty];
+ }
+
+ public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => throw new NotImplementedException();
+
+ private static IReadOnlyDictionary CreateIcons()
+ {
+ return new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ [string.Empty] = LoadBitmap("unknown_asset.png"),
+ ["uasset"] = LoadBitmap("asset.png"),
+ ["ini"] = LoadBitmap("asset_ini.png"),
+ ["png"] = LoadBitmap("asset_png.png"),
+ ["psd"] = LoadBitmap("asset_psd.png")
+ };
+ }
+
+ private static Bitmap LoadBitmap(string fileName)
+ {
+ using var stream = AssetLoader.Open(new Uri($"avares://FModel/Resources/{fileName}"));
+ return new Bitmap(stream);
+ }
+}
diff --git a/FModel/Views/Resources/Converters/FolderToGeometryConverter.cs b/FModel/Views/Resources/Converters/FolderToGeometryConverter.cs
index 1d1a2956..c101fa33 100644
--- a/FModel/Views/Resources/Converters/FolderToGeometryConverter.cs
+++ b/FModel/Views/Resources/Converters/FolderToGeometryConverter.cs
@@ -46,6 +46,9 @@ public class FolderToGeometryConverter : IValueConverter
_ => (null, "NeutralBrush"),
};
+ if (targetType == typeof(bool) || (parameter is string { Length: > 0 } p && p.Equals("visible", StringComparison.OrdinalIgnoreCase)))
+ return geometry != null;
+
if (targetType == typeof(Geometry) && geometry != null)
{
Application.Current!.TryGetResource(geometry, null, out var geomRes);
diff --git a/FModel/Views/Resources/Converters/IntGreaterThanZeroConverter.cs b/FModel/Views/Resources/Converters/IntGreaterThanZeroConverter.cs
new file mode 100644
index 00000000..f459b622
--- /dev/null
+++ b/FModel/Views/Resources/Converters/IntGreaterThanZeroConverter.cs
@@ -0,0 +1,16 @@
+using System;
+using System.Globalization;
+using Avalonia.Data.Converters;
+
+namespace FModel.Views.Resources.Converters;
+
+public class IntGreaterThanZeroConverter : IValueConverter
+{
+ public static readonly IntGreaterThanZeroConverter Instance = new();
+
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => value is int n and > 0;
+
+ public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => throw new NotImplementedException();
+}
diff --git a/FModel/Views/Resources/Converters/IsNullToBoolConverter.cs b/FModel/Views/Resources/Converters/IsNullToBoolConverter.cs
new file mode 100644
index 00000000..683c0aaa
--- /dev/null
+++ b/FModel/Views/Resources/Converters/IsNullToBoolConverter.cs
@@ -0,0 +1,16 @@
+using System;
+using System.Globalization;
+using Avalonia.Data.Converters;
+
+namespace FModel.Views.Resources.Converters;
+
+public class IsNullToBoolConverter : IValueConverter
+{
+ public static readonly IsNullToBoolConverter Instance = new();
+
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => value == null;
+
+ public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => throw new NotImplementedException();
+}
diff --git a/FModel/Views/Resources/Converters/ItemsSourceEmptyToBoolConverter.cs b/FModel/Views/Resources/Converters/ItemsSourceEmptyToBoolConverter.cs
new file mode 100644
index 00000000..b8989f58
--- /dev/null
+++ b/FModel/Views/Resources/Converters/ItemsSourceEmptyToBoolConverter.cs
@@ -0,0 +1,35 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using Avalonia;
+using Avalonia.Data.Converters;
+
+namespace FModel.Views.Resources.Converters;
+
+public class ItemsSourceEmptyToBoolConverter : IMultiValueConverter
+{
+ public static readonly ItemsSourceEmptyToBoolConverter Instance = new();
+
+ public object Convert(IList values, Type targetType, object? parameter, CultureInfo culture)
+ {
+ if (values.Count < 2)
+ return false;
+
+ var itemsSource = values[0];
+ if (itemsSource == null || ReferenceEquals(itemsSource, AvaloniaProperty.UnsetValue))
+ return false;
+
+ var rawCount = values[1];
+ if (rawCount == null || ReferenceEquals(rawCount, AvaloniaProperty.UnsetValue))
+ return false;
+
+ var count = rawCount switch
+ {
+ int intCount => intCount,
+ long longCount => (int) longCount,
+ _ => -1
+ };
+
+ return count == 0;
+ }
+}
diff --git a/FModel/Views/Resources/Converters/RatioToGridLengthConverter.cs b/FModel/Views/Resources/Converters/RatioToGridLengthConverter.cs
index 04ca1246..478ade5c 100644
--- a/FModel/Views/Resources/Converters/RatioToGridLengthConverter.cs
+++ b/FModel/Views/Resources/Converters/RatioToGridLengthConverter.cs
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using Avalonia;
+using Avalonia.Controls;
using Avalonia.Data.Converters;
namespace FModel.Views.Resources.Converters;
@@ -19,7 +20,8 @@ public class RatioToGridLengthConverter : IMultiValueConverter
var count2 = values[1] is int c2 ? c2 : 0;
var total = count1 + count2;
- if (total == 0) return new GridLength(1, GridUnitType.Star);
+ if (total == 0)
+ return new GridLength(1, GridUnitType.Star);
var ratio = (double) count1 / total;
return new GridLength(ratio, GridUnitType.Star);
diff --git a/FModel/Views/SettingsView.xaml b/FModel/Views/SettingsView.xaml
index 8d0e67b5..a23ca460 100644
--- a/FModel/Views/SettingsView.xaml
+++ b/FModel/Views/SettingsView.xaml
@@ -13,7 +13,6 @@
Title="Settings">
-
@@ -1181,7 +1180,7 @@
Grid.Column="1"
Margin="10,5"
HorizontalAlignment="Stretch">
-
diff --git a/FModel/Views/SettingsView.xaml.cs b/FModel/Views/SettingsView.xaml.cs
index dd3479ef..d241e122 100644
--- a/FModel/Views/SettingsView.xaml.cs
+++ b/FModel/Views/SettingsView.xaml.cs
@@ -40,7 +40,7 @@ public partial class SettingsView : Window
{
var restart = _applicationView.SettingsView.Save(out var whatShouldIDo);
if (restart)
- _applicationView.RestartWithWarning();
+ await _applicationView.RestartWithWarningAsync(this);
Close();
@@ -165,60 +165,61 @@ public partial class SettingsView : Window
}
UserSettings.Default.LastOpenedSettingTab = i;
+
+ // Select the DataTemplate that matches the TreeViewItem's Tag.
+ if (treeItem.Tag is string tagKey &&
+ this.TryFindResource(tagKey, out var resource) &&
+ resource is Avalonia.Controls.Templates.IDataTemplate dt)
+ {
+ SettingsContentControl.ContentTemplate = dt;
+ }
+ else
+ {
+ // Clear stale template if a tag key is missing or invalid.
+ SettingsContentControl.ContentTemplate = null;
+ }
+
break;
}
}
private async void OpenCustomVersions(object sender, RoutedEventArgs e)
{
- // TODO(P2-016): DictionaryEditor not yet migrated to Avalonia.
- // Once migrated, replace body with:
- // var editor = new DictionaryEditor(_applicationView.SettingsView.SelectedCustomVersions, "Versioning Configuration (Custom Versions)");
- // if (await editor.ShowDialog(this) != true) return;
- // _applicationView.SettingsView.SelectedCustomVersions = editor.CustomVersions;
- await Task.CompletedTask;
+ var editor = new DictionaryEditor(_applicationView.SettingsView.SelectedCustomVersions, "Versioning Configuration (Custom Versions)");
+ if (await editor.ShowDialog(this) != true)
+ return;
+
+ _applicationView.SettingsView.SelectedCustomVersions = editor.CustomVersions;
}
private async void OpenOptions(object sender, RoutedEventArgs e)
{
- // TODO(P2-016): DictionaryEditor not yet migrated to Avalonia.
- // Once migrated, replace body with:
- // var editor = new DictionaryEditor(_applicationView.SettingsView.SelectedOptions, "Versioning Configuration (Options)");
- // if (await editor.ShowDialog(this) != true) return;
- // _applicationView.SettingsView.SelectedOptions = editor.Options;
- await Task.CompletedTask;
+ var editor = new DictionaryEditor(_applicationView.SettingsView.SelectedOptions, "Versioning Configuration (Options)");
+ if (await editor.ShowDialog(this) != true)
+ return;
+
+ _applicationView.SettingsView.SelectedOptions = editor.Options;
}
private async void OpenMapStructTypes(object sender, RoutedEventArgs e)
{
- // TODO(P2-016): DictionaryEditor not yet migrated to Avalonia.
- // Once migrated, replace body with:
- // var editor = new DictionaryEditor(_applicationView.SettingsView.SelectedMapStructTypes, "Versioning Configuration (MapStructTypes)");
- // if (await editor.ShowDialog(this) != true) return;
- // _applicationView.SettingsView.SelectedMapStructTypes = editor.MapStructTypes;
- await Task.CompletedTask;
+ var editor = new DictionaryEditor(_applicationView.SettingsView.SelectedMapStructTypes, "Versioning Configuration (MapStructTypes)");
+ if (await editor.ShowDialog(this) != true)
+ return;
+
+ _applicationView.SettingsView.SelectedMapStructTypes = editor.MapStructTypes;
}
private async void OpenAesEndpoint(object sender, RoutedEventArgs e)
{
- // TODO(P2-016): EndpointEditor not yet migrated to Avalonia.
- // Note: the original WPF code used ShowDialog() (modal). Preserve modal behaviour
- // in the migration — use ShowDialog(this), not Show().
- // Once migrated, replace body with:
- // var editor = new EndpointEditor(_applicationView.SettingsView.AesEndpoint, "Endpoint Configuration (AES)", EEndpointType.Aes);
- // await editor.ShowDialog(this);
- await Task.CompletedTask;
+ var editor = new EndpointEditor(_applicationView.SettingsView.AesEndpoint, "Endpoint Configuration (AES)", EEndpointType.Aes);
+ await editor.ShowDialog(this);
}
private async void OpenMappingEndpoint(object sender, RoutedEventArgs e)
{
- // TODO(P2-016): EndpointEditor not yet migrated to Avalonia.
- // Note: the original WPF code used ShowDialog() (modal). Preserve modal behaviour
- // in the migration — use ShowDialog(this), not Show().
- // Once migrated, replace body with:
- // var editor = new EndpointEditor(_applicationView.SettingsView.MappingEndpoint, "Endpoint Configuration (Mapping)", EEndpointType.Mapping);
- // await editor.ShowDialog(this);
- await Task.CompletedTask;
+ var editor = new EndpointEditor(_applicationView.SettingsView.MappingEndpoint, "Endpoint Configuration (Mapping)", EEndpointType.Mapping);
+ await editor.ShowDialog(this);
}
private void CriwareKeyBox_Loaded(object sender, RoutedEventArgs e)