using System.ComponentModel;
using CommunityToolkit.WinUI.Controls;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Automation;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Data;
using Microsoft.UI.Xaml.Media;
namespace XFEWarframe.Desktop.Editing;
/// <summary>Shared native SelectorBar, forms and commands; the two applications differ only in their API transport.</summary>
public sealed class EquipmentEditorView : Grid
{
public EquipmentEditorModel? Model
{
get => (EquipmentEditorModel?)GetValue(ModelProperty);
set => SetValue(ModelProperty, value);
}
public static readonly DependencyProperty ModelProperty = DependencyProperty.Register(nameof(Model), typeof(EquipmentEditorModel), typeof(EquipmentEditorView), new PropertyMetadata(null, (sender, args) => ((EquipmentEditorView)sender).Changed((EquipmentEditorModel?)args.OldValue)));
public event EventHandler? BackRequested;
public SelectorBar Selector { get; } = new() { Name = "EquipmentSelector", HorizontalAlignment = HorizontalAlignment.Left };
private readonly Grid body = new();
private readonly Dictionary<string, double> offsets = [];
private readonly Dictionary<string, bool> expansion = [];
private string? shownTabKey;
private string? selectedAbilityConfig;
private readonly InfoBar notice = new() { IsClosable = false, Severity = InfoBarSeverity.Warning };
private readonly TextBlock title = new() { TextWrapping = TextWrapping.Wrap, FontSize = 30, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold };
private readonly TextBlock subtitle = new() { Opacity = .7, TextWrapping = TextWrapping.Wrap };
private readonly TextBlock summary = new() { Opacity = .7, TextWrapping = TextWrapping.Wrap };
private readonly Expander summaryBox;
private readonly ProgressRing progress = new() { Width = 36, Height = 36, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
private bool rebuilding;
public EquipmentEditorView()
{
Padding = new Thickness(28, 24, 28, 24); RowSpacing = 16;
RowDefinitions.Add(new() { Height = GridLength.Auto });
RowDefinitions.Add(new() { Height = GridLength.Auto });
RowDefinitions.Add(new() { Height = GridLength.Auto });
RowDefinitions.Add(new() { Height = new GridLength(1, GridUnitType.Star) });
var header = new Grid { ColumnSpacing = 16 };
header.ColumnDefinitions.Add(new() { Width = new GridLength(1, GridUnitType.Star) });
header.ColumnDefinitions.Add(new() { Width = GridLength.Auto });
var labels = new StackPanel { Spacing = 6 }; labels.Children.Add(subtitle); labels.Children.Add(title);
summaryBox = new Expander { Header = UiText.Translate("当前装备信息"), Content = summary, HorizontalAlignment = HorizontalAlignment.Stretch };
labels.Children.Add(summaryBox); header.Children.Add(labels);
var toolbar = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Top };
var back = new Button { Content = UiText.Translate("返回列表") }; back.Click += (_, _) => BackRequested?.Invoke(this, EventArgs.Empty);
var refresh = new Button { Content = UiText.Translate("刷新"), Name = "RefreshEquipment" };
refresh.SetBinding(Button.CommandProperty, new Binding { Path = new PropertyPath("RefreshCommand") });
toolbar.Children.Add(back); toolbar.Children.Add(refresh); Grid.SetColumn(toolbar, 1); header.Children.Add(toolbar);
Children.Add(header); Grid.SetRow(notice, 1); Children.Add(notice);
var selectionScroll = new ScrollViewer { Content = Selector, HorizontalScrollMode = ScrollMode.Enabled,
HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, VerticalScrollMode = ScrollMode.Disabled,
VerticalScrollBarVisibility = ScrollBarVisibility.Disabled };
Grid.SetRow(selectionScroll, 2); Children.Add(selectionScroll);
Grid.SetRow(body, 3); Children.Add(body); Grid.SetRow(progress, 3); Children.Add(progress);
Selector.SelectionChanged += (_, _) =>
{
if (rebuilding || Model is null || Selector.SelectedItem?.Tag is not EquipmentTab group) return;
Model.SelectedTabKey = group.Key;
ShowSection(group);
Selector.SelectedItem.StartBringIntoView();
};
// Model and view belong to the same page; Changed detaches the old model when replaced.
// Avoid losing subscriptions to spurious native Unloaded events during template transitions.
Loaded += (_, _) => Update();
}
private void Changed(EquipmentEditorModel? previous)
{
if (previous is not null) previous.PropertyChanged -= ModelChanged;
DataContext = Model;
if (Model is not null) Model.PropertyChanged += ModelChanged;
Update(); BuildTabs();
}
private void ModelChanged(object? sender, PropertyChangedEventArgs args)
{
Update();
if (args.PropertyName == nameof(EquipmentEditorModel.Tabs)) BuildTabs();
}
private void Update()
{
title.Text = Model?.Title ?? ""; subtitle.Text = Model?.Subtitle ?? "";
summary.Text = Model?.Summary ?? ""; summaryBox.Visibility = summary.Text.Length > 0 ? Visibility.Visible : Visibility.Collapsed;
notice.Message = Model?.Message ?? ""; notice.IsOpen = Model?.HasMessage == true;
Selector.IsEnabled = Model?.IsIdle == true;
foreach (var section in body.Children.OfType<Control>()) section.IsEnabled = Selector.IsEnabled;
progress.IsActive = Model?.IsBusy == true;
progress.Visibility = progress.IsActive ? Visibility.Visible : Visibility.Collapsed;
}
private void BuildTabs()
{
rebuilding = true;
try
{
RememberScroll(); body.Children.Clear(); Selector.SelectedItem = null; Selector.Items.Clear();
foreach (var group in Model?.Tabs ?? [])
{
var item = new SelectorBarItem { Text = group.Title, Tag = group };
AutomationProperties.SetAutomationId(item, "equipment-section-" + group.Key);
Selector.Items.Add(item);
}
var selected = Selector.Items.FirstOrDefault(x => ((EquipmentTab)x.Tag).Key == Model?.SelectedTabKey) ?? Selector.Items.FirstOrDefault();
if (selected is not null)
{
Selector.SelectedItem = selected;
Model!.SelectedTabKey = ((EquipmentTab)selected.Tag).Key;
ShowSection((EquipmentTab)selected.Tag);
}
}
finally { rebuilding = false; }
}
private void ShowSection(EquipmentTab group)
{
RememberScroll();
// Cache drafts in the model, not templated controls. Reattaching a cached ScrollViewer can
// leave its SettingsExpander inputs detached from native Loaded/TextChanged notifications.
var content = CreateContent(group);
content.IsEnabled = Model?.IsIdle == true;
shownTabKey = group.Key;
content.Loaded += (_, _) => content.ChangeView(null, offsets.GetValueOrDefault(group.Key), null, true);
body.Children.Clear();
body.Children.Add(content);
}
private void RememberScroll()
{
if (shownTabKey is not null && body.Children.FirstOrDefault() is ScrollViewer previous) offsets[shownTabKey] = previous.VerticalOffset;
}
private ScrollViewer CreateContent(EquipmentTab group)
{
if (group.Key == "experimental-abilities" && group.Actions.Any(x => x.Fields.Count > 4)) return CreateAbilityBanks(group);
var content = new StackPanel { Spacing = 12, Padding = new Thickness(0, 0, 12, 12) };
foreach (var action in group.Actions)
{
var submit = new Button { Content = action.Fields.Count > 0 ? UiText.Translate("保存") : UiText.Translate("执行"), Command = Model!.SaveCommand, CommandParameter = action };
AutomationProperties.SetAutomationId(submit, "equipment-save-" + action.Key);
if (action.Fields.Count > 0) submit.Style = (Style)Application.Current.Resources["OrbitAccentButtonStyle"];
if (action.Fields.Count == 0)
{
content.Children.Add(new SettingsCard { Header = action.Label, Description = action.Description, Content = submit });
continue;
}
var buttons = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };
var reset = new Button { Content = UiText.Translate("重置输入"), Command = Model.ResetCommand, CommandParameter = action };
AutomationProperties.SetAutomationId(reset, "equipment-reset-" + action.Key);
buttons.Children.Add(reset); buttons.Children.Add(submit);
var section = new SettingsExpander { Header = action.Label, Description = action.Description, Content = buttons,
IsExpanded = expansion.GetValueOrDefault(action.Key, group.Key is not ("abilities" or "experimental-abilities") || action == group.Actions[0]), HorizontalAlignment = HorizontalAlignment.Stretch, Tag = action };
section.RegisterPropertyChangedCallback(SettingsExpander.IsExpandedProperty, (_, _) => expansion[action.Key] = section.IsExpanded);
AutomationProperties.SetAutomationId(section, "equipment-operation-" + action.Key);
// SettingsExpander applies its item style directly to SettingsCard children.
foreach (var card in EditorForm.CreateCards(action.Fields)) section.Items.Add(card);
content.Children.Add(section);
}
return new ScrollViewer { Content = content, HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, VerticalScrollBarVisibility = ScrollBarVisibility.Auto };
}
private ScrollViewer CreateAbilityBanks(EquipmentTab group)
{
var content = new StackPanel { Spacing = 12, Padding = new Thickness(0, 0, 12, 12) };
var configs = new SelectorBar();
AutomationProperties.SetAutomationId(configs, "equipment-ability-configs");
var editor = new StackPanel { Spacing = 8 };
content.Children.Add(configs); content.Children.Add(editor);
foreach (var action in group.Actions) configs.Items.Add(new SelectorBarItem { Text = action.Label, Tag = action });
configs.SelectionChanged += (_, _) =>
{
if (configs.SelectedItem?.Tag is not EquipmentAction action) return;
selectedAbilityConfig = action.Key;
editor.Children.Clear();
var buttons = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };
var reset = new Button { Content = UiText.Translate("重置输入"), Command = Model!.ResetCommand, CommandParameter = action };
var save = new Button { Content = UiText.Translate("保存"), Command = Model.SaveCommand, CommandParameter = action,
Style = (Style)Application.Current.Resources["OrbitAccentButtonStyle"] };
AutomationProperties.SetAutomationId(reset, "equipment-reset-" + action.Key);
AutomationProperties.SetAutomationId(save, "equipment-save-" + action.Key);
buttons.Children.Add(reset); buttons.Children.Add(save);
editor.Children.Add(new SettingsCard { Header = action.Label, Description = action.Description, Content = buttons });
for (var slot = 1; slot <= 4; slot++)
{
var prefix = "ability" + slot;
var fields = action.Fields.Where(x => x.Key == prefix || x.Key.StartsWith(prefix + "_", StringComparison.Ordinal)).ToArray();
var key = action.Key + ".slot" + slot;
var section = new SettingsExpander { Header = UiText.Translate("技能槽") + " " + slot,
IsExpanded = expansion.GetValueOrDefault(key, slot == 1), HorizontalAlignment = HorizontalAlignment.Stretch };
section.RegisterPropertyChangedCallback(SettingsExpander.IsExpandedProperty, (_, _) => expansion[key] = section.IsExpanded);
AutomationProperties.SetAutomationId(section, "equipment-operation-" + key);
foreach (var card in EditorForm.CreateCards(fields)) section.Items.Add(card);
editor.Children.Add(section);
}
};
configs.SelectedItem = configs.Items.FirstOrDefault(x => ((EquipmentAction)x.Tag).Key == selectedAbilityConfig) ?? configs.Items.FirstOrDefault();
return new ScrollViewer { Content = content, HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, VerticalScrollBarVisibility = ScrollBarVisibility.Auto };
}
}
using System.ComponentModel;
using CommunityToolkit.WinUI.Controls;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Automation;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Data;
using Microsoft.UI.Xaml.Media;
namespace XFEWarframe.Desktop.Editing;
/// <summary>Shared native SelectorBar, forms and commands; the two applications differ only in their API transport.</summary>
public sealed class EquipmentEditorView : Grid
{
public EquipmentEditorModel? Model
{
get => (EquipmentEditorModel?)GetValue(ModelProperty);
set => SetValue(ModelProperty, value);
}
public static readonly DependencyProperty ModelProperty = DependencyProperty.Register(nameof(Model), typeof(EquipmentEditorModel), typeof(EquipmentEditorView), new PropertyMetadata(null, (sender, args) => ((EquipmentEditorView)sender).Changed((EquipmentEditorModel?)args.OldValue)));
public event EventHandler? BackRequested;
public SelectorBar Selector { get; } = new() { Name = "EquipmentSelector", HorizontalAlignment = HorizontalAlignment.Left };
private readonly Grid body = new();
private readonly Dictionary<string, double> offsets = [];
private readonly Dictionary<string, bool> expansion = [];
private string? shownTabKey;
private string? selectedAbilityConfig;
private readonly InfoBar notice = new() { IsClosable = false, Severity = InfoBarSeverity.Warning };
private readonly TextBlock title = new() { TextWrapping = TextWrapping.Wrap, FontSize = 30, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold };
private readonly TextBlock subtitle = new() { Opacity = .7, TextWrapping = TextWrapping.Wrap };
private readonly TextBlock summary = new() { Opacity = .7, TextWrapping = TextWrapping.Wrap };
private readonly Expander summaryBox;
private readonly ProgressRing progress = new() { Width = 36, Height = 36, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
private bool rebuilding;
public EquipmentEditorView()
{
Padding = new Thickness(28, 24, 28, 24); RowSpacing = 16;
RowDefinitions.Add(new() { Height = GridLength.Auto });
RowDefinitions.Add(new() { Height = GridLength.Auto });
RowDefinitions.Add(new() { Height = GridLength.Auto });
RowDefinitions.Add(new() { Height = new GridLength(1, GridUnitType.Star) });
var header = new Grid { ColumnSpacing = 16 };
header.ColumnDefinitions.Add(new() { Width = new GridLength(1, GridUnitType.Star) });
header.ColumnDefinitions.Add(new() { Width = GridLength.Auto });
var labels = new StackPanel { Spacing = 6 }; labels.Children.Add(subtitle); labels.Children.Add(title);
summaryBox = new Expander { Header = UiText.Translate("当前装备信息"), Content = summary, HorizontalAlignment = HorizontalAlignment.Stretch };
labels.Children.Add(summaryBox); header.Children.Add(labels);
var toolbar = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Top };
var back = new Button { Content = UiText.Translate("返回列表") }; back.Click += (_, _) => BackRequested?.Invoke(this, EventArgs.Empty);
var refresh = new Button { Content = UiText.Translate("刷新"), Name = "RefreshEquipment" };
refresh.SetBinding(Button.CommandProperty, new Binding { Path = new PropertyPath("RefreshCommand") });
toolbar.Children.Add(back); toolbar.Children.Add(refresh); Grid.SetColumn(toolbar, 1); header.Children.Add(toolbar);
Children.Add(header); Grid.SetRow(notice, 1); Children.Add(notice);
var selectionScroll = new ScrollViewer { Content = Selector, HorizontalScrollMode = ScrollMode.Enabled,
HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, VerticalScrollMode = ScrollMode.Disabled,
VerticalScrollBarVisibility = ScrollBarVisibility.Disabled };
Grid.SetRow(selectionScroll, 2); Children.Add(selectionScroll);
Grid.SetRow(body, 3); Children.Add(body); Grid.SetRow(progress, 3); Children.Add(progress);
Selector.SelectionChanged += (_, _) =>
{
if (rebuilding || Model is null || Selector.SelectedItem?.Tag is not EquipmentTab group) return;
Model.SelectedTabKey = group.Key;
ShowSection(group);
Selector.SelectedItem.StartBringIntoView();
};
// Model and view belong to the same page; Changed detaches the old model when replaced.
// Avoid losing subscriptions to spurious native Unloaded events during template transitions.
Loaded += (_, _) => Update();
}
private void Changed(EquipmentEditorModel? previous)
{
if (previous is not null) previous.PropertyChanged -= ModelChanged;
DataContext = Model;
if (Model is not null) Model.PropertyChanged += ModelChanged;
Update(); BuildTabs();
}
private void ModelChanged(object? sender, PropertyChangedEventArgs args)
{
Update();
if (args.PropertyName == nameof(EquipmentEditorModel.Tabs)) BuildTabs();
}
private void Update()
{
title.Text = Model?.Title ?? ""; subtitle.Text = Model?.Subtitle ?? "";
summary.Text = Model?.Summary ?? ""; summaryBox.Visibility = summary.Text.Length > 0 ? Visibility.Visible : Visibility.Collapsed;
notice.Message = Model?.Message ?? ""; notice.IsOpen = Model?.HasMessage == true;
Selector.IsEnabled = Model?.IsIdle == true;
foreach (var section in body.Children.OfType<Control>()) section.IsEnabled = Selector.IsEnabled;
progress.IsActive = Model?.IsBusy == true;
progress.Visibility = progress.IsActive ? Visibility.Visible : Visibility.Collapsed;
}
private void BuildTabs()
{
rebuilding = true;
try
{
RememberScroll(); body.Children.Clear(); Selector.SelectedItem = null; Selector.Items.Clear();
foreach (var group in Model?.Tabs ?? [])
{
var item = new SelectorBarItem { Text = group.Title, Tag = group };
AutomationProperties.SetAutomationId(item, "equipment-section-" + group.Key);
Selector.Items.Add(item);
}
var selected = Selector.Items.FirstOrDefault(x => ((EquipmentTab)x.Tag).Key == Model?.SelectedTabKey) ?? Selector.Items.FirstOrDefault();
if (selected is not null)
{
Selector.SelectedItem = selected;
Model!.SelectedTabKey = ((EquipmentTab)selected.Tag).Key;
ShowSection((EquipmentTab)selected.Tag);
}
}
finally { rebuilding = false; }
}
private void ShowSection(EquipmentTab group)
{
RememberScroll();
// Cache drafts in the model, not templated controls. Reattaching a cached ScrollViewer can
// leave its SettingsExpander inputs detached from native Loaded/TextChanged notifications.
var content = CreateContent(group);
content.IsEnabled = Model?.IsIdle == true;
shownTabKey = group.Key;
content.Loaded += (_, _) => content.ChangeView(null, offsets.GetValueOrDefault(group.Key), null, true);
body.Children.Clear();
body.Children.Add(content);
}
private void RememberScroll()
{
if (shownTabKey is not null && body.Children.FirstOrDefault() is ScrollViewer previous) offsets[shownTabKey] = previous.VerticalOffset;
}
private ScrollViewer CreateContent(EquipmentTab group)
{
if (group.Key == "experimental-abilities" && group.Actions.Any(x => x.Fields.Count > 4)) return CreateAbilityBanks(group);
var content = new StackPanel { Spacing = 12, Padding = new Thickness(0, 0, 12, 12) };
foreach (var action in group.Actions)
{
var submit = new Button { Content = action.Fields.Count > 0 ? UiText.Translate("保存") : UiText.Translate("执行"), Command = Model!.SaveCommand, CommandParameter = action };
AutomationProperties.SetAutomationId(submit, "equipment-save-" + action.Key);
if (action.Fields.Count > 0) submit.Style = (Style)Application.Current.Resources["OrbitAccentButtonStyle"];
if (action.Fields.Count == 0)
{
content.Children.Add(new SettingsCard { Header = action.Label, Description = action.Description, Content = submit });
continue;
}
var buttons = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };
var reset = new Button { Content = UiText.Translate("重置输入"), Command = Model.ResetCommand, CommandParameter = action };
AutomationProperties.SetAutomationId(reset, "equipment-reset-" + action.Key);
buttons.Children.Add(reset); buttons.Children.Add(submit);
var section = new SettingsExpander { Header = action.Label, Description = action.Description, Content = buttons,
IsExpanded = expansion.GetValueOrDefault(action.Key, group.Key is not ("abilities" or "experimental-abilities") || action == group.Actions[0]), HorizontalAlignment = HorizontalAlignment.Stretch, Tag = action };
section.RegisterPropertyChangedCallback(SettingsExpander.IsExpandedProperty, (_, _) => expansion[action.Key] = section.IsExpanded);
AutomationProperties.SetAutomationId(section, "equipment-operation-" + action.Key);
// SettingsExpander applies its item style directly to SettingsCard children.
foreach (var card in EditorForm.CreateCards(action.Fields)) section.Items.Add(card);
content.Children.Add(section);
}
return new ScrollViewer { Content = content, HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, VerticalScrollBarVisibility = ScrollBarVisibility.Auto };
}
private ScrollViewer CreateAbilityBanks(EquipmentTab group)
{
var content = new StackPanel { Spacing = 12, Padding = new Thickness(0, 0, 12, 12) };
var configs = new SelectorBar();
AutomationProperties.SetAutomationId(configs, "equipment-ability-configs");
var editor = new StackPanel { Spacing = 8 };
content.Children.Add(configs); content.Children.Add(editor);
foreach (var action in group.Actions) configs.Items.Add(new SelectorBarItem { Text = action.Label, Tag = action });
configs.SelectionChanged += (_, _) =>
{
if (configs.SelectedItem?.Tag is not EquipmentAction action) return;
selectedAbilityConfig = action.Key;
editor.Children.Clear();
var buttons = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };
var reset = new Button { Content = UiText.Translate("重置输入"), Command = Model!.ResetCommand, CommandParameter = action };
var save = new Button { Content = UiText.Translate("保存"), Command = Model.SaveCommand, CommandParameter = action,
Style = (Style)Application.Current.Resources["OrbitAccentButtonStyle"] };
AutomationProperties.SetAutomationId(reset, "equipment-reset-" + action.Key);
AutomationProperties.SetAutomationId(save, "equipment-save-" + action.Key);
buttons.Children.Add(reset); buttons.Children.Add(save);
editor.Children.Add(new SettingsCard { Header = action.Label, Description = action.Description, Content = buttons });
for (var slot = 1; slot <= 4; slot++)
{
var prefix = "ability" + slot;
var fields = action.Fields.Where(x => x.Key == prefix || x.Key.StartsWith(prefix + "_", StringComparison.Ordinal)).ToArray();
var key = action.Key + ".slot" + slot;
var section = new SettingsExpander { Header = UiText.Translate("技能槽") + " " + slot,
IsExpanded = expansion.GetValueOrDefault(key, slot == 1), HorizontalAlignment = HorizontalAlignment.Stretch };
section.RegisterPropertyChangedCallback(SettingsExpander.IsExpandedProperty, (_, _) => expansion[key] = section.IsExpanded);
AutomationProperties.SetAutomationId(section, "equipment-operation-" + key);
foreach (var card in EditorForm.CreateCards(fields)) section.Items.Add(card);
editor.Children.Add(section);
}
};
configs.SelectedItem = configs.Items.FirstOrDefault(x => ((EquipmentAction)x.Tag).Key == selectedAbilityConfig) ?? configs.Items.FirstOrDefault();
return new ScrollViewer { Content = content, HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, VerticalScrollBarVisibility = ScrollBarVisibility.Auto };
}
}