fix .Items and StyleExtensions

This commit is contained in:
Carlos Zamora
2026-05-19 17:51:18 -07:00
parent a175b6291a
commit 58e3407de6
9 changed files with 298 additions and 65 deletions

View File

@@ -179,6 +179,9 @@
<ClInclude Include="SettingsExpander.h">
<DependentUpon>SettingsExpander.idl</DependentUpon>
</ClInclude>
<ClInclude Include="StyleExtensions.h">
<DependentUpon>StyleExtensions.idl</DependentUpon>
</ClInclude>
<ClInclude Include="ControlSizeTrigger.h">
<DependentUpon>ControlSizeTrigger.idl</DependentUpon>
</ClInclude>
@@ -409,6 +412,9 @@
<ClCompile Include="SettingsExpander.cpp">
<DependentUpon>SettingsExpander.idl</DependentUpon>
</ClCompile>
<ClCompile Include="StyleExtensions.cpp">
<DependentUpon>StyleExtensions.idl</DependentUpon>
</ClCompile>
<ClCompile Include="ControlSizeTrigger.cpp">
<DependentUpon>ControlSizeTrigger.idl</DependentUpon>
</ClCompile>
@@ -531,6 +537,9 @@
<Midl Include="SettingsExpander.idl">
<SubType>Code</SubType>
</Midl>
<Midl Include="StyleExtensions.idl">
<SubType>Code</SubType>
</Midl>
<Midl Include="ControlSizeTrigger.idl">
<SubType>Code</SubType>
</Midl>

View File

@@ -32,6 +32,7 @@
<Midl Include="SettingContainer.idl" />
<Midl Include="SettingsCard.idl" />
<Midl Include="SettingsExpander.idl" />
<Midl Include="StyleExtensions.idl" />
<Midl Include="ControlSizeTrigger.idl" />
<Midl Include="CornerRadiusFilterConverters.idl" />
<Midl Include="StringDefaultTemplateSelector.idl" />

View File

@@ -5,11 +5,25 @@
Default styles for SettingsCard and SettingsExpander.
These were ported from the Windows Community Toolkit
(components/SettingsControls) and trimmed for use in this project:
(components/SettingsControls) and adapted for this project's WinUI 2 host:
- The Card's responsive RightWrapped/RightWrappedNoIcon visual states are
omitted (they depend on tk:ControlSizeTrigger).
- The Expander uses muxc:Expander's built-in template rather than the
custom 500+ line template the toolkit ships.
driven by local:ControlSizeTrigger (which this project ports from the
toolkit's tk:ControlSizeTrigger).
- The Card's PointerOver/Pressed visual states do NOT toggle
muxc:AnimatedIcon.State on PART_RootGrid. The toolkit uses this to drive
a Lottie chevron animation for the ActionIcon; WinUI 2 doesn't ship the
underlying AnimatedChevronUpDownSmallVisualSource, so we use a static
FontIcon ActionIcon throughout.
- The Expander uses a hand-rolled muxc:Expander template
(SettingsExpanderExpanderStyle below) so the header can pick up the
matching SettingsCard hover/press brushes; the toolkit's WinUI 3 version
also rolls its own template for the same reason.
- The Expander template's ExpandDirection=Up branch is intentionally
abridged because it references a WinUI 3 system style
(ExpanderHeaderUpStyle) that isn't shipped in WinUI 2.
- The chevron rotation is a RotateTransform driven by a DoubleAnimation
(instead of the toolkit's Lottie AnimatedChevronUpDownSmallVisualSource,
which is again WinUI 3 only).
-->
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
@@ -111,6 +125,7 @@
<x:Double x:Key="SettingsCardMinHeight">68</x:Double>
<x:Double x:Key="SettingsCardDescriptionFontSize">12</x:Double>
<x:Double x:Key="SettingsCardHeaderIconMaxSize">20</x:Double>
<x:Double x:Key="SettingsCardLeftIndention">0</x:Double>
<x:Double x:Key="SettingsCardContentMinWidth">120</x:Double>
<Thickness x:Key="SettingsCardHeaderIconMargin">2,0,20,0</Thickness>
<Thickness x:Key="SettingsCardActionIconMargin">14,0,0,0</Thickness>
@@ -208,12 +223,16 @@
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}">
<Grid.BackgroundTransition>
<BrushTransition Duration="0:0:0.083" />
</Grid.BackgroundTransition>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto"
MinWidth="{StaticResource SettingsCardLeftIndention}" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
@@ -475,21 +494,34 @@
IsClickEnabled="False" />
</muxc:Expander.Header>
<muxc:Expander.Content>
<Grid>
<!-- CornerRadius is filtered to bottom-only so the items panel visually stitches to the rounded bottom of the expander (matches WCT). -->
<Grid CornerRadius="{Binding CornerRadius, Mode=OneWay, RelativeSource={RelativeSource TemplatedParent}, Converter={StaticResource BottomCornerRadiusFilterConverter}}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<ContentPresenter Content="{TemplateBinding ItemsHeader}" />
<muxc:ItemsRepeater x:Name="PART_ItemsRepeater"
<!--
Use ItemsControl + StackPanel rather than ItemsRepeater so every
Items entry is realized eagerly. ItemsRepeater inside an Expander
inside the Settings page's outer ScrollViewer only realized
index 0 — the EffectiveViewport propagated through the ScrollViewer
was 0 px at the moment the expand storyboard ran, and StackLayout's
cache length is relative to viewport so 0 * anything is still 0.
Settings expanders rarely hold more than a handful of cards so
losing virtualization is a non-issue.
-->
<ItemsControl x:Name="PART_ItemsHost"
Grid.Row="1"
ItemTemplate="{Binding ItemTemplate, Mode=OneWay, RelativeSource={RelativeSource TemplatedParent}}"
TabFocusNavigation="Local">
<muxc:ItemsRepeater.Layout>
<muxc:StackLayout Orientation="Vertical" />
</muxc:ItemsRepeater.Layout>
</muxc:ItemsRepeater>
IsTabStop="False"
ItemTemplate="{Binding ItemTemplate, Mode=OneWay, RelativeSource={RelativeSource TemplatedParent}}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Vertical" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
<ContentPresenter Grid.Row="2"
Content="{TemplateBinding ItemsFooter}" />
</Grid>
@@ -589,6 +621,7 @@
<VisualState x:Name="ExpandUp">
<VisualState.Setters>
<Setter Target="ExpanderHeader.CornerRadius" Value="{Binding CornerRadius, RelativeSource={RelativeSource TemplatedParent}, Converter={StaticResource BottomCornerRadiusFilterConverter}}" />
<Setter Target="ExpanderContent.Visibility" Value="Visible" />
</VisualState.Setters>
<VisualState.Storyboard>
<Storyboard>
@@ -609,13 +642,11 @@
</VisualState.Storyboard>
</VisualState>
<VisualState x:Name="CollapseDown">
<VisualState.Setters>
<Setter Target="ExpanderContent.Visibility" Value="Collapsed" />
</VisualState.Setters>
<VisualState.Storyboard>
<Storyboard>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ExpanderContent"
Storyboard.TargetProperty="Visibility">
<DiscreteObjectKeyFrame KeyTime="0:0:0.2"
Value="Collapsed" />
</ObjectAnimationUsingKeyFrames>
<DoubleAnimationUsingKeyFrames Storyboard.TargetName="ExpanderContent"
Storyboard.TargetProperty="(UIElement.RenderTransform).(CompositeTransform.TranslateY)">
<DiscreteDoubleKeyFrame KeyTime="0"
@@ -630,14 +661,10 @@
<VisualState x:Name="ExpandDown">
<VisualState.Setters>
<Setter Target="ExpanderHeader.CornerRadius" Value="{Binding CornerRadius, RelativeSource={RelativeSource TemplatedParent}, Converter={StaticResource TopCornerRadiusFilterConverter}}" />
<Setter Target="ExpanderContent.Visibility" Value="Visible" />
</VisualState.Setters>
<VisualState.Storyboard>
<Storyboard>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ExpanderContent"
Storyboard.TargetProperty="Visibility">
<DiscreteObjectKeyFrame KeyTime="0"
Value="Visible" />
</ObjectAnimationUsingKeyFrames>
<DoubleAnimationUsingKeyFrames Storyboard.TargetName="ExpanderContent"
Storyboard.TargetProperty="(UIElement.RenderTransform).(CompositeTransform.TranslateY)">
<DiscreteDoubleKeyFrame KeyTime="0"
@@ -650,13 +677,11 @@
</VisualState.Storyboard>
</VisualState>
<VisualState x:Name="CollapseUp">
<VisualState.Setters>
<Setter Target="ExpanderContent.Visibility" Value="Collapsed" />
</VisualState.Setters>
<VisualState.Storyboard>
<Storyboard>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ExpanderContent"
Storyboard.TargetProperty="Visibility">
<DiscreteObjectKeyFrame KeyTime="0:0:0.167"
Value="Collapsed" />
</ObjectAnimationUsingKeyFrames>
<DoubleAnimationUsingKeyFrames Storyboard.TargetName="ExpanderContent"
Storyboard.TargetProperty="(UIElement.RenderTransform).(CompositeTransform.TranslateY)">
<DiscreteDoubleKeyFrame KeyTime="0"

View File

@@ -14,8 +14,6 @@ using namespace winrt::Windows::UI::Xaml::Automation;
using namespace winrt::Windows::UI::Xaml::Automation::Peers;
using namespace winrt::Windows::UI::Xaml::Controls;
namespace MUXC = winrt::Microsoft::UI::Xaml::Controls;
namespace winrt::Microsoft::Terminal::Settings::Editor::implementation
{
DependencyProperty SettingsExpander::_HeaderProperty{ nullptr };
@@ -30,13 +28,18 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation
DependencyProperty SettingsExpander::_ItemTemplateProperty{ nullptr };
DependencyProperty SettingsExpander::_ItemContainerStyleSelectorProperty{ nullptr };
static constexpr std::wstring_view PART_ItemsRepeater{ L"PART_ItemsRepeater" };
static constexpr std::wstring_view PART_ItemsHost{ L"PART_ItemsHost" };
SettingsExpander::SettingsExpander()
{
_InitializeProperties();
Items(single_threaded_vector<IInspectable>());
// Items is backed by an observable vector so post-construction mutations
// (e.g. user code that adds cards after the expander is on screen) also
// refresh the inner ItemsControl. The XAML parser populates Items via
// Append before OnApplyTemplate runs, so the eager population path also
// works.
Items(single_threaded_observable_vector<IInspectable>());
}
void SettingsExpander::_InitializeProperties()
@@ -140,20 +143,17 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation
{
_SetAccessibleName();
// Drop the prior template's repeater hookups before locating the new one.
_elementPreparedRevoker.revoke();
_itemsRepeater = nullptr;
// Drop the prior template's host before locating the new one.
_itemsHost = nullptr;
if (const auto child{ GetTemplateChild(hstring{ PART_ItemsRepeater }) })
if (const auto child{ GetTemplateChild(hstring{ PART_ItemsHost }) })
{
_itemsRepeater = child.try_as<MUXC::ItemsRepeater>();
_itemsHost = child.try_as<Controls::ItemsControl>();
}
if (_itemsRepeater)
if (_itemsHost)
{
_elementPreparedRevoker = _itemsRepeater.ElementPrepared(winrt::auto_revoke, { get_weak(), &SettingsExpander::_ItemsRepeater_ElementPrepared });
// Push our initial ItemsSource through to the repeater.
// Push our initial ItemsSource through to the host and stamp item-container styles.
_UpdateItemsSource();
}
}
@@ -172,18 +172,75 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation
void SettingsExpander::_UpdateItemsSource()
{
if (!_itemsRepeater)
if (!_itemsHost)
{
return;
}
// ItemsSource wins when set; otherwise fall back to the inline Items collection.
if (const auto source{ ItemsSource() })
{
_itemsRepeater.ItemsSource(source);
_itemsHost.ItemsSource(source);
}
else
{
_itemsRepeater.ItemsSource(Items());
_itemsHost.ItemsSource(Items());
}
_ApplyItemContainerStyles();
_SubscribeToItemsVectorChanged();
}
// Watch our inline Items vector for changes so containers added after
// OnApplyTemplate also get the proper SettingsCard item style. (When
// ItemsSource is set, this is a no-op since the parser only touches Items.)
void SettingsExpander::_SubscribeToItemsVectorChanged()
{
_itemsVectorChangedRevoker.revoke();
if (ItemsSource())
{
return;
}
if (const auto observable{ Items().try_as<IObservableVector<IInspectable>>() })
{
_itemsVectorChangedRevoker = observable.VectorChanged(winrt::auto_revoke, [weakThis = get_weak()](auto&&, auto&&) {
if (const auto strongThis{ weakThis.get() })
{
strongThis->_ApplyItemContainerStyles();
}
});
}
}
// Apply the per-item style produced by ItemContainerStyleSelector. ItemsControl
// only generates ContentPresenter containers for non-UIElement items, so when
// SettingsCards are added directly the cards themselves are the "containers"
// and we have to set Style on them ourselves. Mirrors the ElementPrepared path
// we used when this was an ItemsRepeater.
void SettingsExpander::_ApplyItemContainerStyles()
{
const auto selector{ ItemContainerStyleSelector() };
if (!selector)
{
return;
}
const auto items{ Items() };
if (!items)
{
return;
}
for (uint32_t i = 0; i < items.Size(); ++i)
{
if (const auto element{ items.GetAt(i).try_as<FrameworkElement>() })
{
if (element.ReadLocalValue(FrameworkElement::StyleProperty()) == DependencyProperty::UnsetValue())
{
element.Style(selector.SelectStyle(items.GetAt(i), element));
}
}
}
}
@@ -195,22 +252,6 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation
}
}
void SettingsExpander::_ItemsRepeater_ElementPrepared(const MUXC::ItemsRepeater& /*sender*/, const MUXC::ItemsRepeaterElementPreparedEventArgs& args)
{
const auto selector{ ItemContainerStyleSelector() };
if (!selector)
{
return;
}
if (const auto element{ args.Element().try_as<FrameworkElement>() })
{
if (element.ReadLocalValue(FrameworkElement::StyleProperty()) == DependencyProperty::UnsetValue())
{
element.Style(selector.SelectStyle(nullptr, element));
}
}
}
void SettingsExpander::_OnIsExpandedChanged(const DependencyObject& d, const DependencyPropertyChangedEventArgs& e)
{
const auto obj{ d.try_as<Editor::SettingsExpander>() };

View File

@@ -54,10 +54,11 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation
void _SetAccessibleName();
void _UpdateItemsSource();
void _ItemsRepeater_ElementPrepared(const Microsoft::UI::Xaml::Controls::ItemsRepeater& sender, const Microsoft::UI::Xaml::Controls::ItemsRepeaterElementPreparedEventArgs& args);
void _SubscribeToItemsVectorChanged();
void _ApplyItemContainerStyles();
Microsoft::UI::Xaml::Controls::ItemsRepeater _itemsRepeater{ nullptr };
Microsoft::UI::Xaml::Controls::ItemsRepeater::ElementPrepared_revoker _elementPreparedRevoker;
Windows::UI::Xaml::Controls::ItemsControl _itemsHost{ nullptr };
Windows::Foundation::Collections::IObservableVector<Windows::Foundation::IInspectable>::VectorChanged_revoker _itemsVectorChangedRevoker;
};
// AutomationPeer for SettingsExpander. Reports class name and falls back to
@@ -75,7 +76,7 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation
};
// StyleSelector used by SettingsExpander to choose between a clickable vs.
// non-clickable SettingsCard container style for items in its ItemsRepeater.
// non-clickable SettingsCard container style for items in its ItemsControl.
// Ported from the Windows Community Toolkit's SettingsExpanderItemStyleSelector.
struct SettingsExpanderItemStyleSelector : SettingsExpanderItemStyleSelectorT<SettingsExpanderItemStyleSelector>
{

View File

@@ -3,6 +3,7 @@
namespace Microsoft.Terminal.Settings.Editor
{
[contentproperty("Content")]
[default_interface] runtimeclass SettingsExpander : Windows.UI.Xaml.Controls.Control
{
SettingsExpander();

View File

@@ -0,0 +1,105 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
#include "pch.h"
#include "StyleExtensions.h"
#include "StyleExtensions.g.cpp"
using namespace winrt::Windows::Foundation;
using namespace winrt::Windows::Foundation::Collections;
using namespace winrt::Windows::UI::Xaml;
namespace winrt::Microsoft::Terminal::Settings::Editor::implementation
{
DependencyProperty StyleExtensions::_resourcesProperty{ nullptr };
DependencyProperty StyleExtensions::ResourcesProperty()
{
_InitializeProperties();
return _resourcesProperty;
}
void StyleExtensions::_InitializeProperties()
{
if (!_resourcesProperty)
{
_resourcesProperty = DependencyProperty::RegisterAttached(
L"Resources",
xaml_typename<ResourceDictionary>(),
xaml_typename<Editor::StyleExtensions>(),
PropertyMetadata{ nullptr, PropertyChangedCallback{ &StyleExtensions::_OnResourcesChanged } });
}
}
ResourceDictionary StyleExtensions::GetResources(const DependencyObject& target)
{
return target.GetValue(ResourcesProperty()).try_as<ResourceDictionary>();
}
void StyleExtensions::SetResources(const DependencyObject& target, const ResourceDictionary& value)
{
target.SetValue(ResourcesProperty(), value);
}
void StyleExtensions::_OnResourcesChanged(const DependencyObject& d, const DependencyPropertyChangedEventArgs& e)
{
const auto frameworkElement{ d.try_as<FrameworkElement>() };
if (!frameworkElement)
{
return;
}
const auto elementResources{ frameworkElement.Resources() };
if (!elementResources)
{
return;
}
const auto mergedDictionaries{ elementResources.MergedDictionaries() };
if (!mergedDictionaries)
{
return;
}
// Remove the previously-merged dictionary (if any). Resource dictionaries
// are reference types so IndexOf walks by identity, which is exactly what
// we want: the same Setter.Value is shared across every element that uses
// this Style, so the OldValue we see here is the exact instance we
// appended last time the property changed.
if (const auto oldDict{ e.OldValue().try_as<ResourceDictionary>() })
{
uint32_t index{ 0 };
if (mergedDictionaries.IndexOf(oldDict, index))
{
mergedDictionaries.RemoveAt(index);
}
}
// Add the new dictionary directly. We deliberately do NOT clone the way
// the WCT C# port does: ResourceDictionary is sealed (so we can't tag a
// private subclass like the toolkit), and a deep clone would have to
// copy the inline dictionary's Source URI — which XAML may leave as a
// relative string like "CommonResources.xaml" and which the runtime
// then rejects with "is not a valid absolute URI". Sharing the same
// dictionary across elements is fine: each element's MergedDictionaries
// only holds a reference, and implicit styles are designed to be shared.
if (const auto newDict{ e.NewValue().try_as<ResourceDictionary>() })
{
mergedDictionaries.Append(newDict);
if (frameworkElement.IsLoaded())
{
_ForceControlToReloadThemeResources(frameworkElement);
}
}
}
void StyleExtensions::_ForceControlToReloadThemeResources(const FrameworkElement& element)
{
// Toggle RequestedTheme to force the framework to re-resolve all
// {ThemeResource} bindings under this element. Required when the
// style is applied to an already-loaded element. Matches the toolkit.
const auto currentTheme{ element.RequestedTheme() };
element.RequestedTheme(currentTheme == ElementTheme::Dark ? ElementTheme::Light : ElementTheme::Dark);
element.RequestedTheme(currentTheme);
}
}

View File

@@ -0,0 +1,31 @@
// Copyright (c) Microsoft Corporation
// Licensed under the MIT license.
#pragma once
#include "StyleExtensions.g.h"
namespace winrt::Microsoft::Terminal::Settings::Editor::implementation
{
struct StyleExtensions
{
StyleExtensions() = default;
static Windows::UI::Xaml::DependencyProperty ResourcesProperty();
static Windows::UI::Xaml::ResourceDictionary GetResources(const Windows::UI::Xaml::DependencyObject& target);
static void SetResources(const Windows::UI::Xaml::DependencyObject& target, const Windows::UI::Xaml::ResourceDictionary& value);
private:
static void _InitializeProperties();
static void _OnResourcesChanged(const Windows::UI::Xaml::DependencyObject& d, const Windows::UI::Xaml::DependencyPropertyChangedEventArgs& e);
static void _ForceControlToReloadThemeResources(const Windows::UI::Xaml::FrameworkElement& element);
static Windows::UI::Xaml::DependencyProperty _resourcesProperty;
};
}
namespace winrt::Microsoft::Terminal::Settings::Editor::factory_implementation
{
BASIC_FACTORY(StyleExtensions);
}

View File

@@ -0,0 +1,19 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
namespace Microsoft.Terminal.Settings.Editor
{
// Attached property that lets a Style merge an extra ResourceDictionary into
// the target FrameworkElement's Resources.MergedDictionaries. Used by
// SettingsCard / SettingsExpander to inject right-aligned ToggleSwitch and
// sized Slider / ComboBox / TextBox defaults so children laid out inside a
// card look right out of the box. Mirrors the Windows Community Toolkit's
// CommunityToolkit.WinUI.Controls.StyleExtensions.
static runtimeclass StyleExtensions
{
static Windows.UI.Xaml.DependencyProperty ResourcesProperty { get; };
static Windows.UI.Xaml.ResourceDictionary GetResources(Windows.UI.Xaml.DependencyObject target);
static void SetResources(Windows.UI.Xaml.DependencyObject target, Windows.UI.Xaml.ResourceDictionary value);
}
}