using System.Collections.Generic; using System.Collections.Specialized; using System.Runtime.CompilerServices; using Avalonia; using Avalonia.Controls; using Avalonia.Data; using Avalonia.Data.Converters; using Avalonia.Input; using Avalonia.Threading; using Avalonia.VisualTree; using LANCommander.Launcher.ViewModels; using LANCommander.Launcher.ViewModels.Components; namespace LANCommander.Launcher.Controls; /// /// Builds the consolidated, state-driven game menu and exposes it both as a flyout for the /// action bar split buttons and as a right-click / gamepad context menu on game covers and /// list rows. /// /// The menu items bind against a via an explicit /// , so the same definition works regardless of the host control's /// own DataContext (a in grids and lists). /// /// Attached-behavior usage: controls:GameContextMenu.IsEnabled="True" on a focusable /// game cover or list row. /// public static class GameContextMenu { private static readonly IValueConverter Invert = new FuncValueConverter(b => !b); // Keeps each flyout's secondary-action subscription alive for the flyout's lifetime and // allows callers to detach it explicitly when the flyout is discarded. private static readonly ConditionalWeakTable Binders = new(); // ── Attached behavior ─────────────────────────────────────────────────────────────────── public static readonly AttachedProperty IsEnabledProperty = AvaloniaProperty.RegisterAttached("IsEnabled", typeof(GameContextMenu)); static GameContextMenu() { IsEnabledProperty.Changed.AddClassHandler(OnIsEnabledChanged); } public static bool GetIsEnabled(Control control) => control.GetValue(IsEnabledProperty); public static void SetIsEnabled(Control control, bool value) => control.SetValue(IsEnabledProperty, value); private static void OnIsEnabledChanged(Control control, AvaloniaPropertyChangedEventArgs e) { control.ContextRequested -= OnContextRequested; control.KeyDown -= OnKeyDown; if (e.NewValue is true) { control.ContextRequested += OnContextRequested; control.KeyDown += OnKeyDown; } } private static void OnContextRequested(object? sender, ContextRequestedEventArgs e) { if (sender is Control control && OpenMenu(control, atPointer: true)) e.Handled = true; } private static void OnKeyDown(object? sender, KeyEventArgs e) { // The gamepad service maps the North (Y) button to Key.Apps on the focused element. if (e.Key == Key.Apps && sender is Control control && OpenMenu(control, atPointer: false)) e.Handled = true; } private static bool OpenMenu(Control control, bool atPointer) { if (control.DataContext is not GameItemViewModel item) return false; if (App.Services is not { } serviceProvider) return false; var vm = new GameActionBarViewModel(serviceProvider); // Load asynchronously, then show. Disposed on close so no timers leak (LoadForMenuAsync // doesn't start any, but the VM owns disposable timers in other code paths). _ = ShowAsync(); return true; async System.Threading.Tasks.Task ShowAsync() { try { await vm.LoadForMenuAsync(item); } catch { vm.Dispose(); return; } var flyout = CreateFlyout(vm); void OnLibraryChanged(object? s, System.EventArgs e) => RefreshHost(control); void OnClosed(object? s, System.EventArgs e) { flyout.Closed -= OnClosed; DetachBinder(flyout); // Don't unsubscribe LibraryChanged/InstallRequested here: clicking a menu item // closes the flyout immediately, but the backing command (e.g. Remove from // Library) runs asynchronously and only raises these events after its await. We // must stay subscribed so the host grid/list still refreshes once it completes. // Disposing is safe (it only stops running-check timers, which the menu path // never starts) and the VM is collected once the in-flight command finishes. vm.Dispose(); } vm.LibraryChanged += OnLibraryChanged; vm.InstallRequested += OnLibraryChanged; flyout.Closed += OnClosed; flyout.ShowAt(control, atPointer); } } /// /// Refreshes the nearest ancestor games collection so library/install changes made from the /// menu (add/remove, install, uninstall) are reflected in the grid or list immediately. /// private static void RefreshHost(Control control) { Visual? current = control; while (current != null) { if (current is StyledElement { DataContext: GamesCollectionViewModel collection }) { _ = collection.LoadGamesAsync(); return; } current = current.GetVisualParent(); } } // ── Flyout factory ────────────────────────────────────────────────────────────────────── /// /// Creates a consolidated game menu bound to the given view model. The caller owns the /// returned flyout; call when it is discarded. /// public static MenuFlyout CreateFlyout(GameActionBarViewModel vm) { var flyout = new MenuFlyout { Placement = PlacementMode.BottomEdgeAlignedLeft }; Populate(flyout, vm); Binders.AddOrUpdate(flyout, new SecondaryActionsBinder(flyout, vm)); return flyout; } /// /// Stops keeping the flyout's secondary-action subscription so the flyout and its view model /// can be collected. /// public static void DetachBinder(MenuFlyout flyout) { if (Binders.TryGetValue(flyout, out var binder)) { binder.Detach(); Binders.Remove(flyout); } } private static void Populate(MenuFlyout flyout, GameActionBarViewModel vm) { var items = new List { // Primary action (state-driven) Item("Install", vm, "InstallCommand", visiblePath: "IsInstalled", visibleInvert: true, enabledPath: "CanInstall"), Item("Play", vm, "PlayCommand", visiblePath: "IsInstalled"), Item("Update", vm, "UpdateGameCommand", visiblePath: "ShowUpdateLabel"), Item("Play Without Updating", vm, "PlayOrStopCommand", visiblePath: "IsUpdateAvailable"), }; // Dynamic, game-defined secondary actions foreach (var action in vm.SecondaryActions) items.Add(new MenuItem { Header = action.Name, Command = action.RunCommand }); items.Add(Separator(vm, "IsInstalled")); items.Add(Item("Browse Files", vm, "BrowseFilesCommand", visiblePath: "IsInstalled")); items.Add(Item("View Manual", vm, "OpenFirstManualCommand", visiblePath: "HasManuals")); items.Add(Item("Modify", vm, "ModifyCommand", visiblePath: "IsInstalled")); items.Add(Separator(vm, "IsInstalled")); items.Add(Item("Verify Files", vm, "VerifyFilesCommand", visiblePath: "IsInstalled", enabledPath: "IsVerifyingFiles", enabledInvert: true)); items.Add(Item("Uninstall", vm, "UninstallCommand", visiblePath: "IsInstalled", enabledPath: "IsUninstalling", enabledInvert: true)); items.Add(Item("Add to Library", vm, "AddToLibraryCommand", visiblePath: "IsInLibrary", visibleInvert: true)); items.Add(Item("Remove from Library", vm, "RemoveFromLibraryCommand", enabledPath: "IsInLibrary")); flyout.Items.Clear(); foreach (var item in items) flyout.Items.Add(item); } private static MenuItem Item( string header, GameActionBarViewModel vm, string commandPath, string? visiblePath = null, bool visibleInvert = false, string? enabledPath = null, bool enabledInvert = false) { var item = new MenuItem { Header = header }; item.Bind(MenuItem.CommandProperty, new Binding(commandPath) { Source = vm }); if (visiblePath != null) item.Bind(Visual.IsVisibleProperty, new Binding(visiblePath) { Source = vm, Converter = visibleInvert ? Invert : null, }); if (enabledPath != null) item.Bind(InputElement.IsEnabledProperty, new Binding(enabledPath) { Source = vm, Converter = enabledInvert ? Invert : null, }); return item; } private static Separator Separator(GameActionBarViewModel vm, string visiblePath) { var separator = new Separator(); separator.Bind(Visual.IsVisibleProperty, new Binding(visiblePath) { Source = vm }); return separator; } /// /// Keeps the dynamic secondary actions in the flyout in sync with the view model, which may /// populate them asynchronously after the flyout is created. /// private sealed class SecondaryActionsBinder { private readonly MenuFlyout _flyout; private readonly GameActionBarViewModel _vm; public SecondaryActionsBinder(MenuFlyout flyout, GameActionBarViewModel vm) { _flyout = flyout; _vm = vm; _vm.SecondaryActions.CollectionChanged += OnChanged; } private void OnChanged(object? sender, NotifyCollectionChangedEventArgs e) => Dispatcher.UIThread.Post(() => Populate(_flyout, _vm)); public void Detach() => _vm.SecondaryActions.CollectionChanged -= OnChanged; } }