feat(#15): migrate MainWindow.xaml + MainWindow.xaml.cs to Avalonia Window (#68)

* feat(#15): migrate MainWindow.xaml + MainWindow.xaml.cs to Avalonia Window

- Replace AdonisWindow root with Avalonia Window, remove all adonisUi xmlns
- Remove TaskbarItemInfo XAML block (guarded stub in code-behind)
- Replace StatusBar/StatusBarItem + WPF Style.Triggers with Border+DockPanel
- Replace Border.Triggers Storyboard with Avalonia Transitions + :pointerover
- Replace ToggleButton WPF trigger style with Avalonia selector style
- Rewrite MainWindow.xaml.cs: remove WPF CommandBindings/RoutedCommand,
  update event handler signatures, add PropertyChanged-based status bar
  color updates and window title updates (replacing WPF DataTrigger logic)
- Add OnLoaded screen sizing via Screens.Primary.WorkingArea
- Add [SupportedOSPlatform(windows)] InitTaskbarInfo stub
- Helper.cs: replace Application.Current.Windows with
  IClassicDesktopStyleApplicationLifetime.Windows, remove Activate()
- App.xaml.cs: wire desktop.MainWindow = new MainWindow()

* fix(#15): remove double UpdateStatusLabel invocation per SetStatus call

OnStatusKindChanged Kind branch now only calls UpdateStatusBarColor().
UpdateStatusLabel() is driven exclusively by the Label branch, which
always fires immediately after Kind during SetStatus() because distinct
enum states always produce distinct label strings — so SetProperty on
Label always raises PropertyChanged, guaranteeing the Label branch runs.
This eliminates the redundant double-invoke without any behavioral change.

* Address review feedback
This commit is contained in:
Rob Trame 2026-03-11 21:36:29 -06:00 committed by GitHub
parent 4d82992237
commit 3afea6c9fd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 755 additions and 451 deletions

View File

@ -21,6 +21,17 @@
<Color x:Key="ErrorColor">#C22B2B</Color>
<SolidColorBrush x:Key="ErrorColorBrush"
Color="{StaticResource ErrorColor}" />
<!--
WPF SystemColors aliases (M1 fix). These are stopgap values
matching AdonisUI dark-theme equivalents; they will be replaced
when the full resource-dictionary migration is done.
-->
<SolidColorBrush x:Key="SystemColors.ControlTextBrushKey"
Color="#E8E8E8" />
<SolidColorBrush x:Key="SystemColors.ControlBrushKey"
Color="#3C3C3C" />
<SolidColorBrush x:Key="SystemColors.AppWorkspaceBrushKey"
Color="#1E1E1E" />
</ResourceDictionary>
</Application.Resources>
</Application>

View File

@ -44,7 +44,7 @@ public partial class App : Application
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
// TODO(#15): desktop.MainWindow = new Views.MainWindow();
desktop.MainWindow = new MainWindow();
desktop.Exit += AppExit;
}

View File

@ -1,7 +1,9 @@
using System;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Windows;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
namespace FModel;
@ -38,7 +40,8 @@ public static class Helper
else
{
var w = GetOpenedWindow<T>(windowName);
if (windowName == "Search For Packages") w.WindowState = WindowState.Normal;
if (windowName == "Search For Packages")
w.WindowState = WindowState.Normal;
w.Focus();
}
}
@ -52,26 +55,33 @@ public static class Helper
var ret = (T) GetOpenedWindow<T>(windowName);
ret.Focus();
ret.Activate();
return ret;
}
public static void CloseWindow<T>(string windowName) where T : Window
{
if (!IsWindowOpen<T>(windowName)) return;
if (!IsWindowOpen<T>(windowName))
return;
GetOpenedWindow<T>(windowName).Close();
}
private static IEnumerable<Window> GetAllWindows()
{
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
return desktop.Windows;
return Enumerable.Empty<Window>();
}
private static bool IsWindowOpen<T>(string name = "") where T : Window
{
return string.IsNullOrEmpty(name)
? Application.Current.Windows.OfType<T>().Any()
: Application.Current.Windows.OfType<T>().Any(w => w.Title.Equals(name));
? GetAllWindows().OfType<T>().Any()
: GetAllWindows().OfType<T>().Any(w => w.Title?.Equals(name) == true);
}
private static Window GetOpenedWindow<T>(string name) where T : Window
{
return Application.Current.Windows.OfType<T>().FirstOrDefault(w => w.Title.Equals(name));
return GetAllWindows().OfType<T>().FirstOrDefault(w => w.Title?.Equals(name) == true);
}
public static bool IsNaN(double value)

File diff suppressed because it is too large Load Diff

View File

@ -1,24 +1,27 @@
using System;
using System.ComponentModel;
using System.Linq;
using System.Runtime.Versioning;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Input;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Threading;
using FModel.Services;
using FModel.Settings;
using FModel.ViewModels;
using FModel.Views;
using FModel.Views.Resources.Controls;
using ICSharpCode.AvalonEdit.Editing;
using AvaloniaEdit.Editing;
namespace FModel;
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow
public partial class MainWindow : Window
{
public static MainWindow YesWeCats;
private ThreadWorkerViewModel _threadWorkerView => ApplicationService.ThreadWorkerView;
@ -27,56 +30,104 @@ public partial class MainWindow
public MainWindow()
{
CommandBindings.Add(new CommandBinding(new RoutedCommand("ReloadMappings", typeof(MainWindow), new InputGestureCollection { new KeyGesture(Key.F12) }), OnMappingsReload));
CommandBindings.Add(new CommandBinding(ApplicationCommands.Find, (_, _) => OnOpenAvalonFinder()));
CommandBindings.Add(new CommandBinding(NavigationCommands.BrowseBack, (_, _) =>
{
if (UserSettings.Default.FeaturePreviewNewAssetExplorer && !_applicationView.IsAssetsExplorerVisible)
{
// back browsing the json view will reopen the assets explorer
_applicationView.IsAssetsExplorerVisible = true;
return;
}
if (LeftTabControl.SelectedIndex == 2)
{
LeftTabControl.SelectedIndex = 1;
}
else if (LeftTabControl.SelectedIndex == 1 && AssetsFolderName.SelectedItem is TreeItem { Parent: TreeItem parent })
{
AssetsFolderName.Focus();
parent.IsSelected = true;
}
}));
DataContext = _applicationView;
InitializeComponent();
AssetsExplorer.ItemContainerGenerator.StatusChanged += ItemContainerGenerator_StatusChanged;
AssetsListName.ItemContainerGenerator.StatusChanged += ItemContainerGenerator_StatusChanged;
AssetsExplorer.SelectionChanged += (_, e) => SyncSelection(AssetsListName, e);
AssetsListName.SelectionChanged += (_, e) => SyncSelection(AssetsExplorer, e);
FLogger.Logger = LogRtbName;
YesWeCats = this;
// Wire status-bar colour updates; replaces WPF DataTrigger-based style
_applicationView.Status.PropertyChanged += OnStatusKindChanged;
_threadWorkerView.PropertyChanged += OnThreadWorkerPropertyChanged;
// Wire window title updates; replaces WPF DataTrigger on AdonisWindow style
_applicationView.PropertyChanged += OnApplicationViewPropertyChanged;
UpdateWindowTitle();
// Windows-only: set up TaskbarItemInfo progress
if (OperatingSystem.IsWindows())
InitTaskbarInfo();
}
// Hack to sync selection between packages tab and explorer
private void SyncSelection(ListBox target, SelectionChangedEventArgs e)
[SupportedOSPlatform("windows")]
private void InitTaskbarInfo()
{
foreach (var added in e.AddedItems.OfType<GameFileViewModel>())
{
if (!target.SelectedItems.Contains(added))
target.SelectedItems.Add(added);
}
// TODO(P2-015): set up Windows taskbar progress indicator once
// StatusToTaskbarStateConverter is migrated.
}
foreach (var removed in e.RemovedItems.OfType<GameFileViewModel>())
private void UpdateWindowTitle()
{
Title = string.IsNullOrEmpty(_applicationView.TitleExtra)
? _applicationView.InitialWindowTitle
: $"{_applicationView.InitialWindowTitle} - {_applicationView.GameDisplayName} {_applicationView.TitleExtra}";
}
private void OnApplicationViewPropertyChanged(object sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(ApplicationViewModel.TitleExtra)
or nameof(ApplicationViewModel.GameDisplayName)
or nameof(ApplicationViewModel.InitialWindowTitle))
Dispatcher.UIThread.InvokeAsync(UpdateWindowTitle);
}
// --- Status bar colour (replaces WPF DataTrigger style) ---
private void OnStatusKindChanged(object sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(FStatus.Kind))
Dispatcher.UIThread.InvokeAsync(UpdateStatusBarColor);
else if (e.PropertyName == nameof(FStatus.Label))
Dispatcher.UIThread.InvokeAsync(UpdateStatusLabel);
}
private void UpdateStatusBarColor()
{
if (StatusBarBorder is null)
return;
var brushKey = _applicationView.Status.Kind switch
{
if (target.SelectedItems.Contains(removed))
target.SelectedItems.Remove(removed);
EStatusKind.Ready or EStatusKind.Completed => "AccentColorBrush",
EStatusKind.Loading or EStatusKind.Stopping => "AlertColorBrush",
EStatusKind.Stopped or EStatusKind.Failed => "ErrorColorBrush",
_ => (string?) null
};
if (brushKey is null)
return;
if (Application.Current!.TryGetResource(brushKey, ActualThemeVariant, out var resource)
&& resource is IBrush bg)
{
StatusBarBorder.Background = bg;
StatusBarBorder.Foreground = Brushes.White;
}
}
private void OnThreadWorkerPropertyChanged(object sender, PropertyChangedEventArgs e)
{
// Flash animations for StatusChangeAttempted / OperationCancelled
// are tracked as TODO: implement via DispatcherTimer in a later pass.
if (e.PropertyName == nameof(ThreadWorkerViewModel.CanBeCanceled))
Dispatcher.UIThread.InvokeAsync(UpdateStatusLabel);
}
private void UpdateStatusLabel()
{
if (StatusLabel is null)
return;
var kind = _applicationView.Status.Kind;
var label = _applicationView.Status.Label;
StatusLabel.Text = kind == EStatusKind.Loading && _threadWorkerView.CanBeCanceled
? $"{label} \u2026 ESC to Cancel"
: kind == EStatusKind.Loading
? $"{label} \u2026"
: label;
}
// --- Lifecycle ---
private void OnClosing(object sender, CancelEventArgs e)
{
_discordHandler.Dispose();
@ -84,6 +135,17 @@ public partial class MainWindow
private async void OnLoaded(object sender, RoutedEventArgs e)
{
// Set initial window size to ~90%×95% of primary screen working area
// (replaces WPF SystemParameters binding)
var screen = Screens.Primary;
if (screen != null)
{
Width = screen.WorkingArea.Width * 0.90 / screen.PixelDensity;
Height = screen.WorkingArea.Height * 0.95 / screen.PixelDensity;
}
UpdateStatusBarColor();
var newOrUpdated = UserSettings.Default.ShowChangelog;
#if !DEBUG
ApplicationService.ApiEndpointView.FModelApi.CheckForUpdates(true);
@ -125,42 +187,47 @@ public partial class MainWindow
_discordHandler.Initialize(_applicationView.GameDisplayName);
})
).ConfigureAwait(false);
#if DEBUG
// await _threadWorkerView.Begin(cancellationToken =>
// _applicationView.CUE4Parse.Extract(cancellationToken,
// _applicationView.CUE4Parse.Provider["Marvel/Content/Marvel/Wwise/Assets/Events/Music/music_new/event/Entry.uasset"]));
#endif
}
private void OnGridSplitterDoubleClick(object sender, MouseButtonEventArgs e)
private void OnGridSplitterDoubleClick(object sender, TappedEventArgs e)
{
RootGrid.ColumnDefinitions[0].Width = GridLength.Auto;
}
private void OnWindowKeyDown(object sender, KeyEventArgs e)
{
if (e.OriginalSource is TextBox || e.OriginalSource is TextArea && Keyboard.Modifiers.HasFlag(ModifierKeys.Control))
return;
if (e.Source is TextBox || e.Source is TextArea)
{
// Let Ctrl+key through to the text editing controls
if (e.KeyModifiers.HasFlag(KeyModifiers.Control))
return;
}
if (_threadWorkerView.CanBeCanceled && e.Key == Key.Escape)
{
_applicationView.Status.SetStatus(EStatusKind.Stopping);
_threadWorkerView.Cancel();
}
else if (_applicationView.Status.IsReady && e.Key == Key.F && Keyboard.Modifiers.HasFlag(ModifierKeys.Control) && Keyboard.Modifiers.HasFlag(ModifierKeys.Shift))
else if (_applicationView.Status.IsReady && e.Key == Key.F
&& e.KeyModifiers.HasFlag(KeyModifiers.Control)
&& e.KeyModifiers.HasFlag(KeyModifiers.Shift))
OnSearchViewClick(null, null);
else if (_applicationView.Status.IsReady && e.Key == Key.R && Keyboard.Modifiers.HasFlag(ModifierKeys.Control) && Keyboard.Modifiers.HasFlag(ModifierKeys.Shift))
else if (_applicationView.Status.IsReady && e.Key == Key.R
&& e.KeyModifiers.HasFlag(KeyModifiers.Control)
&& e.KeyModifiers.HasFlag(KeyModifiers.Shift))
OnRefViewClick(null, null);
else if (e.Key == Key.F3)
OnOpenAvalonFinder();
else if (e.Key == Key.Left && !_applicationView.IsAssetsExplorerVisible && _applicationView.CUE4Parse.TabControl.SelectedTab is { HasImage: true })
else if (e.Key == Key.Left && !_applicationView.IsAssetsExplorerVisible
&& _applicationView.CUE4Parse.TabControl.SelectedTab is { HasImage: true })
_applicationView.CUE4Parse.TabControl.SelectedTab.GoPreviousImage();
else if (e.Key == Key.Right && !_applicationView.IsAssetsExplorerVisible && _applicationView.CUE4Parse.TabControl.SelectedTab is { HasImage: true })
else if (e.Key == Key.Right && !_applicationView.IsAssetsExplorerVisible
&& _applicationView.CUE4Parse.TabControl.SelectedTab is { HasImage: true })
_applicationView.CUE4Parse.TabControl.SelectedTab.GoNextImage();
else if (_applicationView.Status.IsReady && _applicationView.IsAssetsExplorerVisible && Keyboard.Modifiers.HasFlag(ModifierKeys.Alt))
else if (_applicationView.Status.IsReady && _applicationView.IsAssetsExplorerVisible
&& e.KeyModifiers.HasFlag(KeyModifiers.Alt))
{
CategoriesSelector.SelectedIndex = e.SystemKey switch
CategoriesSelector.SelectedIndex = e.Key switch
{
Key.D0 or Key.NumPad0 => 0,
Key.D1 or Key.NumPad1 => 1,
@ -175,7 +242,9 @@ public partial class MainWindow
_ => CategoriesSelector.SelectedIndex
};
}
else if (_applicationView.Status.IsReady && UserSettings.Default.FeaturePreviewNewAssetExplorer && UserSettings.Default.SwitchAssetExplorer.IsTriggered(e.Key))
else if (_applicationView.Status.IsReady
&& UserSettings.Default.FeaturePreviewNewAssetExplorer
&& UserSettings.Default.SwitchAssetExplorer.IsTriggered(e.Key))
_applicationView.IsAssetsExplorerVisible = !_applicationView.IsAssetsExplorerVisible;
else if (UserSettings.Default.AssetAddTab.IsTriggered(e.Key))
_applicationView.CUE4Parse.TabControl.AddTab();
@ -191,6 +260,11 @@ public partial class MainWindow
_applicationView.SelectedLeftTabIndex++;
}
private void OnMappingsReload(object sender, RoutedEventArgs e)
{
_ = _applicationView.CUE4Parse.InitMappings(true);
}
private void OnSearchViewClick(object sender, RoutedEventArgs e)
{
var searchView = Helper.GetWindow<SearchView>("Search For Packages", () => new SearchView().Show());
@ -205,7 +279,7 @@ public partial class MainWindow
private void OnTabItemChange(object sender, SelectionChangedEventArgs e)
{
if (e.OriginalSource is not TabControl tabControl)
if (e.Source is not TabControl tabControl)
return;
switch (tabControl.SelectedIndex)
@ -222,11 +296,6 @@ public partial class MainWindow
}
}
private async void OnMappingsReload(object sender, ExecutedRoutedEventArgs e)
{
await _applicationView.CUE4Parse.InitMappings(true);
}
private void OnOpenAvalonFinder()
{
if (_applicationView.IsAssetsExplorerVisible)
@ -242,32 +311,32 @@ public partial class MainWindow
}
}
private void OnAssetsTreeMouseDoubleClick(object sender, MouseButtonEventArgs e)
private void OnAssetsTreeMouseDoubleClick(object sender, TappedEventArgs e)
{
if (sender is not TreeView { SelectedItem: TreeItem treeItem } || treeItem.Folders.Count > 0) return;
if (sender is not TreeView { SelectedItem: TreeItem treeItem } || treeItem.Folders.Count > 0)
return;
_applicationView.SelectedLeftTabIndex++;
}
private void OnPreviewTexturesToggled(object sender, RoutedEventArgs e) => ItemContainerGenerator_StatusChanged(AssetsExplorer.ItemContainerGenerator, EventArgs.Empty);
private void ItemContainerGenerator_StatusChanged(object sender, EventArgs e)
private void OnPreviewTexturesToggled(object sender, RoutedEventArgs e) =>
ItemContainerGenerator_StatusChanged(AssetsExplorer);
private void ItemContainerGenerator_StatusChanged(ListBox listBox)
{
if (sender is not ItemContainerGenerator { Status: GeneratorStatus.ContainersGenerated } generator)
return;
// Avalonia doesn't have ItemContainerGenerator; containers are always
// available once rendered. Walk visible items instead.
var foundVisibleItem = false;
var itemCount = generator.Items.Count;
for (var i = 0; i < itemCount; i++)
for (var i = 0; i < listBox.Items.Count; i++)
{
var container = generator.ContainerFromIndex(i);
var container = listBox.ContainerFromIndex(i) as Control;
if (container == null)
{
if (foundVisibleItem) break; // we're past the visible range already
continue; // keep scrolling to find visible items
if (foundVisibleItem)
break;
continue;
}
if (container is FrameworkElement { IsVisible: true } && generator.Items[i] is GameFileViewModel file)
if (container.IsVisible && listBox.Items[i] is GameFileViewModel file)
{
foundVisibleItem = true;
file.OnIsVisible();
@ -275,20 +344,23 @@ public partial class MainWindow
}
}
private void OnAssetsTreeSelectedItemChanged(object sender, RoutedPropertyChangedEventArgs<object> e)
private void OnAssetsTreeSelectedItemChanged(object sender, SelectionChangedEventArgs e)
{
if (sender is not TreeView { SelectedItem: TreeItem }) return;
if (sender is not TreeView { SelectedItem: TreeItem })
return;
_applicationView.IsAssetsExplorerVisible = true;
_applicationView.SelectedLeftTabIndex = 1;
}
private async void OnAssetsListMouseDoubleClick(object sender, MouseButtonEventArgs e)
private async void OnAssetsListMouseDoubleClick(object sender, TappedEventArgs e)
{
if (sender is not ListBox listBox) return;
if (sender is not ListBox listBox)
return;
var selectedItems = listBox.SelectedItems.OfType<GameFileViewModel>().Select(gvm => gvm.Asset).ToArray();
if (selectedItems.Length == 0) return;
var selectedItems = listBox.SelectedItems?.OfType<GameFileViewModel>().Select(gvm => gvm.Asset).ToArray();
if (selectedItems == null || selectedItems.Length == 0)
return;
await _threadWorkerView.Begin(cancellationToken => { _applicationView.CUE4Parse.ExtractSelected(cancellationToken, selectedItems); });
}
@ -302,9 +374,10 @@ public partial class MainWindow
}
}
private void OnMouseDoubleClick(object sender, MouseButtonEventArgs e)
private void OnMouseDoubleClick(object sender, TappedEventArgs e)
{
if (!_applicationView.Status.IsReady || sender is not ListBox listBox) return;
if (!_applicationView.Status.IsReady || sender is not ListBox listBox)
return;
UserSettings.Default.LoadingMode = ELoadingMode.Multiple;
_applicationView.LoadingModes.LoadCommand.Execute(listBox.SelectedItems);
}
@ -374,4 +447,19 @@ public partial class MainWindow
childFolder.IsExpanded = true;
childFolder.IsSelected = true;
}
// Hack to sync selection between packages tab and explorer
private void SyncSelection(ListBox target, SelectionChangedEventArgs e)
{
foreach (var added in e.AddedItems.OfType<GameFileViewModel>())
{
if (target.SelectedItems != null && !target.SelectedItems.Contains(added))
target.SelectedItems.Add(added);
}
foreach (var removed in e.RemovedItems.OfType<GameFileViewModel>())
{
target.SelectedItems?.Remove(removed);
}
}
}