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 @@ - - - + xmlns:converters="clr-namespace:FModel.Views.Resources.Converters" + x:Class="FModel.Views.Resources.Controls.ContextMenus.FileContextMenuDictionary"> + + - + - - - + + + - + - + - + @@ -39,22 +48,28 @@ - - - + + + - + - + - + @@ -66,23 +81,29 @@ - - - + + + + Command="{Binding RightClickMenuCommand}" + IsVisible="{Binding ShowDecompileOption, Source={x:Static settings:UserSettings.Default}}"> - + - + @@ -93,28 +114,21 @@ - - - + + + - - - + IsVisible="{Binding CanExportRawData, Source={x:Static settings:UserSettings.Default}}"> - @@ -122,41 +136,53 @@ - + - - - + + + - + - + - - - + + + - + - + - + @@ -167,22 +193,28 @@ - - - + + + - + - + - + @@ -193,22 +225,28 @@ - - - + + + - + - + - + @@ -219,22 +257,28 @@ - - - + + + - + - + - + @@ -245,9 +289,12 @@ - - - + + + @@ -255,49 +302,62 @@ - - - + + + - + - + - + - + - + - + - + - + - + - + diff --git a/FModel/Views/Resources/Controls/ContextMenus/FileContextMenu.xaml.cs b/FModel/Views/Resources/Controls/ContextMenus/FileContextMenu.xaml.cs new file mode 100644 index 00000000..a4d46885 --- /dev/null +++ b/FModel/Views/Resources/Controls/ContextMenus/FileContextMenu.xaml.cs @@ -0,0 +1,37 @@ +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.VisualTree; +using Serilog; +using System.Linq; + +namespace FModel.Views.Resources.Controls.ContextMenus; + +public partial class FileContextMenuDictionary : ResourceDictionary +{ + public FileContextMenuDictionary() + { + InitializeComponent(); + } + + /// + /// Resolves the DataContext from the placement target's visual tree. + /// ContextMenus are hosted in popups (not under a Window), so + /// $parent[Window].DataContext doesn't reliably resolve. + /// + private void FileContextMenu_OnOpened(object? sender, RoutedEventArgs e) + { + if (sender is not ContextMenu { PlacementTarget: { } target } menu) + return; + + // Walk up the visual tree to the Window and grab its DataContext. + var window = target.GetVisualAncestors().OfType().FirstOrDefault(); + if (window != null) + { + menu.DataContext = window.DataContext; + } + else + { + Log.Warning("FileContextMenu: could not find a Window ancestor for PlacementTarget {Target}", target.GetType().Name); + } + } +} diff --git a/FModel/Views/Resources/Controls/ContextMenus/FolderContextMenu.xaml b/FModel/Views/Resources/Controls/ContextMenus/FolderContextMenu.xaml index 835d4bf2..09aa991f 100644 --- a/FModel/Views/Resources/Controls/ContextMenus/FolderContextMenu.xaml +++ b/FModel/Views/Resources/Controls/ContextMenus/FolderContextMenu.xaml @@ -1,14 +1,14 @@ - - + IsVisible="{Binding CanExportRawData, Source={x:Static settings:UserSettings.Default}}"> @@ -21,7 +21,7 @@ Height="16"> - @@ -41,7 +41,7 @@ Height="16"> - @@ -61,7 +61,7 @@ Height="16"> - @@ -81,7 +81,7 @@ Height="16"> - @@ -101,7 +101,7 @@ Height="16"> - @@ -121,29 +121,37 @@ Height="16"> - - - - - + + + - - - - + + + diff --git a/FModel/Views/Resources/Controls/ContextMenus/FolderContextMenu.xaml.cs b/FModel/Views/Resources/Controls/ContextMenus/FolderContextMenu.xaml.cs index 89d08ec8..9501827e 100644 --- a/FModel/Views/Resources/Controls/ContextMenus/FolderContextMenu.xaml.cs +++ b/FModel/Views/Resources/Controls/ContextMenus/FolderContextMenu.xaml.cs @@ -1,11 +1,13 @@ using System.Collections.Generic; using System.Linq; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Media; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.VisualTree; +using FModel.Extensions; using FModel.Services; using FModel.Settings; using FModel.ViewModels; +using FModel.Views; namespace FModel.Views.Resources.Controls.ContextMenus; @@ -18,39 +20,39 @@ public partial class FolderContextMenuDictionary InitializeComponent(); } - private void FolderContextMenu_OnOpened(object sender, RoutedEventArgs e) + private void FolderContextMenu_OnOpened(object? sender, RoutedEventArgs e) { - if (sender is not ContextMenu { PlacementTarget: FrameworkElement fe } menu) + if (sender is not ContextMenu { PlacementTarget: Control control } menu) return; - var listBox = FindAncestor(fe); + var listBox = FindAncestor(control); if (listBox != null) { menu.DataContext = listBox.DataContext; - menu.Tag = listBox.SelectedItems; + menu.Tag = listBox.SelectedItems?.Cast().ToList() ?? []; return; } - var treeView = FindAncestor(fe); + var treeView = FindAncestor(control); if (treeView != null) { menu.DataContext = treeView.DataContext; - menu.Tag = new[] { treeView.SelectedItem }.ToList(); + menu.Tag = treeView.SelectedItem is not null ? new[] { treeView.SelectedItem }.ToList() : []; } } - private static T FindAncestor(DependencyObject current) where T : DependencyObject + private static T? FindAncestor(Control? current) where T : class { - while (current != null) - { - if (current is T t) - return t; - current = VisualTreeHelper.GetParent(current); - } - return null; + if (current is null) + return null; + + if (current is T self) + return self; + + return current.GetVisualAncestors().OfType().FirstOrDefault(); } - private void OnFavoriteDirectoryClick(object sender, RoutedEventArgs e) + private void OnFavoriteDirectoryClick(object? sender, RoutedEventArgs e) { if (sender is not MenuItem { CommandParameter: IEnumerable list } || list.FirstOrDefault() is not TreeItem folder) return; @@ -60,11 +62,11 @@ public partial class FolderContextMenuDictionary FLogger.Text($"Successfully saved '{folder.PathAtThisPoint}' as a new favorite directory", Constants.WHITE, true)); } - private void OnCopyDirectoryPathClick(object sender, RoutedEventArgs e) + private void OnCopyDirectoryPathClick(object? sender, RoutedEventArgs e) { if (sender is not MenuItem { CommandParameter: IEnumerable list } || list.FirstOrDefault() is not TreeItem folder) return; - Clipboard.SetText(folder.PathAtThisPoint); + ClipboardExtensions.SetText(folder.PathAtThisPoint); } } diff --git a/FModel/Views/Resources/Controls/DictionaryEditor.xaml b/FModel/Views/Resources/Controls/DictionaryEditor.xaml index 7e041199..8031ef5e 100644 --- a/FModel/Views/Resources/Controls/DictionaryEditor.xaml +++ b/FModel/Views/Resources/Controls/DictionaryEditor.xaml @@ -1,45 +1,74 @@ - + - - + + - + + Background="#1A1A2E"> - - - - + + + + - + -