From 96d0762a77d45dd3638737f9dc058b7f6a799f2e Mon Sep 17 00:00:00 2001 From: Carlos Zamora Date: Mon, 18 May 2026 15:06:28 -0700 Subject: [PATCH] port over more WCT SettingsCard/Expander functionality --- .../ControlSizeTrigger.cpp | 139 ++++ .../ControlSizeTrigger.h | 64 ++ .../ControlSizeTrigger.idl | 30 + .../CornerRadiusFilterConverters.cpp | 58 ++ .../CornerRadiusFilterConverters.h | 42 ++ .../CornerRadiusFilterConverters.idl | 20 + ...Microsoft.Terminal.Settings.Editor.vcxproj | 18 + ...t.Terminal.Settings.Editor.vcxproj.filters | 2 + .../TerminalSettingsEditor/SettingsCard.cpp | 117 +++- .../TerminalSettingsEditor/SettingsCard.h | 5 + .../SettingsControlsStyle.xaml | 594 +++++++++++++++++- .../SettingsExpander.cpp | 149 ++++- .../TerminalSettingsEditor/SettingsExpander.h | 31 +- .../SettingsExpander.idl | 22 + 14 files changed, 1264 insertions(+), 27 deletions(-) create mode 100644 src/cascadia/TerminalSettingsEditor/ControlSizeTrigger.cpp create mode 100644 src/cascadia/TerminalSettingsEditor/ControlSizeTrigger.h create mode 100644 src/cascadia/TerminalSettingsEditor/ControlSizeTrigger.idl create mode 100644 src/cascadia/TerminalSettingsEditor/CornerRadiusFilterConverters.cpp create mode 100644 src/cascadia/TerminalSettingsEditor/CornerRadiusFilterConverters.h create mode 100644 src/cascadia/TerminalSettingsEditor/CornerRadiusFilterConverters.idl diff --git a/src/cascadia/TerminalSettingsEditor/ControlSizeTrigger.cpp b/src/cascadia/TerminalSettingsEditor/ControlSizeTrigger.cpp new file mode 100644 index 0000000000..52ba58d0c5 --- /dev/null +++ b/src/cascadia/TerminalSettingsEditor/ControlSizeTrigger.cpp @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +#include "pch.h" +#include "ControlSizeTrigger.h" +#include "ControlSizeTrigger.g.cpp" + +#include + +using namespace winrt::Windows::Foundation; +using namespace winrt::Windows::UI::Xaml; + +namespace winrt::Microsoft::Terminal::Settings::Editor::implementation +{ + DependencyProperty ControlSizeTrigger::_CanTriggerProperty{ nullptr }; + DependencyProperty ControlSizeTrigger::_MinWidthProperty{ nullptr }; + DependencyProperty ControlSizeTrigger::_MaxWidthProperty{ nullptr }; + DependencyProperty ControlSizeTrigger::_MinHeightProperty{ nullptr }; + DependencyProperty ControlSizeTrigger::_MaxHeightProperty{ nullptr }; + DependencyProperty ControlSizeTrigger::_TargetElementProperty{ nullptr }; + + ControlSizeTrigger::ControlSizeTrigger() + { + _InitializeProperties(); + } + + void ControlSizeTrigger::_InitializeProperties() + { + // Defaults mirror the toolkit: trigger is always evaluatable, bounds + // are wide open, no target element until one is bound. + if (!_CanTriggerProperty) + { + _CanTriggerProperty = DependencyProperty::Register( + L"CanTrigger", + xaml_typename(), + xaml_typename(), + PropertyMetadata{ box_value(true), PropertyChangedCallback{ &ControlSizeTrigger::_OnTriggerInputChanged } }); + } + if (!_MinWidthProperty) + { + _MinWidthProperty = DependencyProperty::Register( + L"MinWidth", + xaml_typename(), + xaml_typename(), + PropertyMetadata{ box_value(0.0), PropertyChangedCallback{ &ControlSizeTrigger::_OnTriggerInputChanged } }); + } + if (!_MaxWidthProperty) + { + _MaxWidthProperty = DependencyProperty::Register( + L"MaxWidth", + xaml_typename(), + xaml_typename(), + PropertyMetadata{ box_value(std::numeric_limits::infinity()), PropertyChangedCallback{ &ControlSizeTrigger::_OnTriggerInputChanged } }); + } + if (!_MinHeightProperty) + { + _MinHeightProperty = DependencyProperty::Register( + L"MinHeight", + xaml_typename(), + xaml_typename(), + PropertyMetadata{ box_value(0.0), PropertyChangedCallback{ &ControlSizeTrigger::_OnTriggerInputChanged } }); + } + if (!_MaxHeightProperty) + { + _MaxHeightProperty = DependencyProperty::Register( + L"MaxHeight", + xaml_typename(), + xaml_typename(), + PropertyMetadata{ box_value(std::numeric_limits::infinity()), PropertyChangedCallback{ &ControlSizeTrigger::_OnTriggerInputChanged } }); + } + if (!_TargetElementProperty) + { + _TargetElementProperty = DependencyProperty::Register( + L"TargetElement", + xaml_typename(), + xaml_typename(), + PropertyMetadata{ nullptr, PropertyChangedCallback{ &ControlSizeTrigger::_OnTargetElementChanged } }); + } + } + + void ControlSizeTrigger::_OnTriggerInputChanged(const DependencyObject& d, const DependencyPropertyChangedEventArgs& /*e*/) + { + if (const auto obj{ d.try_as() }) + { + get_self(obj)->_UpdateTrigger(); + } + } + + void ControlSizeTrigger::_OnTargetElementChanged(const DependencyObject& d, const DependencyPropertyChangedEventArgs& e) + { + const auto obj{ d.try_as() }; + if (!obj) + { + return; + } + const auto oldElement = e.OldValue().try_as(); + const auto newElement = e.NewValue().try_as(); + get_self(obj)->_UpdateTargetElement(oldElement, newElement); + } + + void ControlSizeTrigger::_UpdateTargetElement(const FrameworkElement& /*oldValue*/, const FrameworkElement& newValue) + { + // Revoking handles both unhooking the previous element and a null `newValue`. + _sizeChangedRevoker.revoke(); + if (newValue) + { + _sizeChangedRevoker = newValue.SizeChanged(winrt::auto_revoke, [weakThis = get_weak()](auto&&, auto&&) { + if (const auto strongThis = weakThis.get()) + { + strongThis->_UpdateTrigger(); + } + }); + } + _UpdateTrigger(); + } + + void ControlSizeTrigger::_UpdateTrigger() + { + const auto target = TargetElement(); + if (!target || !CanTrigger()) + { + _isActive = false; + SetActive(false); + return; + } + + const auto width = target.ActualWidth(); + const auto height = target.ActualHeight(); + + const bool activate = + MinWidth() <= width && + width < MaxWidth() && + MinHeight() <= height && + height < MaxHeight(); + + _isActive = activate; + SetActive(activate); + } +} diff --git a/src/cascadia/TerminalSettingsEditor/ControlSizeTrigger.h b/src/cascadia/TerminalSettingsEditor/ControlSizeTrigger.h new file mode 100644 index 0000000000..49e6a561f1 --- /dev/null +++ b/src/cascadia/TerminalSettingsEditor/ControlSizeTrigger.h @@ -0,0 +1,64 @@ +/*++ +Copyright (c) Microsoft Corporation +Licensed under the MIT license. + +Module Name: +- ControlSizeTrigger + +Abstract: +- A conditional state trigger that activates based on the size (width and/or + height) of a target FrameworkElement. Lets XAML visual states swap based on + the live size of a templated part. Ported from the Windows Community Toolkit + primitive `CommunityToolkit.WinUI.ControlSizeTrigger`. + + The trigger is "active" when: + MinWidth <= TargetElement.ActualWidth < MaxWidth AND + MinHeight <= TargetElement.ActualHeight < MaxHeight + + Defaults: MinWidth = MinHeight = 0; MaxWidth = MaxHeight = +inf, which makes + the trigger always active unless `CanTrigger` is false or `TargetElement` is + null. + +Author(s): +- Carlos Zamora - May 2026 (port from CommunityToolkit.WinUI.ControlSizeTrigger) + +--*/ + +#pragma once + +#include "ControlSizeTrigger.g.h" +#include "Utils.h" + +namespace winrt::Microsoft::Terminal::Settings::Editor::implementation +{ + struct ControlSizeTrigger : ControlSizeTriggerT + { + public: + ControlSizeTrigger(); + + bool IsActive() const { return _isActive; } + + DEPENDENCY_PROPERTY(bool, CanTrigger); + DEPENDENCY_PROPERTY(double, MinWidth); + DEPENDENCY_PROPERTY(double, MaxWidth); + DEPENDENCY_PROPERTY(double, MinHeight); + DEPENDENCY_PROPERTY(double, MaxHeight); + DEPENDENCY_PROPERTY(Windows::UI::Xaml::FrameworkElement, TargetElement); + + private: + static void _InitializeProperties(); + static void _OnTriggerInputChanged(const Windows::UI::Xaml::DependencyObject& d, const Windows::UI::Xaml::DependencyPropertyChangedEventArgs& e); + static void _OnTargetElementChanged(const Windows::UI::Xaml::DependencyObject& d, const Windows::UI::Xaml::DependencyPropertyChangedEventArgs& e); + + void _UpdateTargetElement(const Windows::UI::Xaml::FrameworkElement& oldValue, const Windows::UI::Xaml::FrameworkElement& newValue); + void _UpdateTrigger(); + + Windows::UI::Xaml::FrameworkElement::SizeChanged_revoker _sizeChangedRevoker; + bool _isActive{ false }; + }; +} + +namespace winrt::Microsoft::Terminal::Settings::Editor::factory_implementation +{ + BASIC_FACTORY(ControlSizeTrigger); +} diff --git a/src/cascadia/TerminalSettingsEditor/ControlSizeTrigger.idl b/src/cascadia/TerminalSettingsEditor/ControlSizeTrigger.idl new file mode 100644 index 0000000000..7bfabbc5ae --- /dev/null +++ b/src/cascadia/TerminalSettingsEditor/ControlSizeTrigger.idl @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +namespace Microsoft.Terminal.Settings.Editor +{ + [default_interface] runtimeclass ControlSizeTrigger : Windows.UI.Xaml.StateTriggerBase + { + ControlSizeTrigger(); + + Boolean CanTrigger; + static Windows.UI.Xaml.DependencyProperty CanTriggerProperty { get; }; + + Double MinWidth; + static Windows.UI.Xaml.DependencyProperty MinWidthProperty { get; }; + + Double MaxWidth; + static Windows.UI.Xaml.DependencyProperty MaxWidthProperty { get; }; + + Double MinHeight; + static Windows.UI.Xaml.DependencyProperty MinHeightProperty { get; }; + + Double MaxHeight; + static Windows.UI.Xaml.DependencyProperty MaxHeightProperty { get; }; + + Windows.UI.Xaml.FrameworkElement TargetElement; + static Windows.UI.Xaml.DependencyProperty TargetElementProperty { get; }; + + Boolean IsActive { get; }; + } +} diff --git a/src/cascadia/TerminalSettingsEditor/CornerRadiusFilterConverters.cpp b/src/cascadia/TerminalSettingsEditor/CornerRadiusFilterConverters.cpp new file mode 100644 index 0000000000..0d9b9168b6 --- /dev/null +++ b/src/cascadia/TerminalSettingsEditor/CornerRadiusFilterConverters.cpp @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +#include "pch.h" +#include "CornerRadiusFilterConverters.h" +#include "CornerRadiusConverter.g.cpp" +#include "TopCornerRadiusFilterConverter.g.cpp" +#include "BottomCornerRadiusFilterConverter.g.cpp" + +using namespace winrt::Windows::UI::Xaml; + +namespace winrt::Microsoft::Terminal::Settings::Editor::implementation +{ + winrt::Windows::Foundation::IInspectable CornerRadiusConverter::Convert(const winrt::Windows::Foundation::IInspectable& value, const Interop::TypeName& /*targetType*/, const winrt::Windows::Foundation::IInspectable& /*parameter*/, const hstring& /*language*/) + { + if (!value) + { + return value; + } + const auto cr = unbox_value_or(value, CornerRadius{ 0, 0, 0, 0 }); + return box_value(CornerRadius{ 0, 0, cr.BottomRight, cr.BottomLeft }); + } + + winrt::Windows::Foundation::IInspectable CornerRadiusConverter::ConvertBack(const winrt::Windows::Foundation::IInspectable& value, const Interop::TypeName& /*targetType*/, const winrt::Windows::Foundation::IInspectable& /*parameter*/, const hstring& /*language*/) + { + return value; + } + + winrt::Windows::Foundation::IInspectable TopCornerRadiusFilterConverter::Convert(const winrt::Windows::Foundation::IInspectable& value, const Interop::TypeName& /*targetType*/, const winrt::Windows::Foundation::IInspectable& /*parameter*/, const hstring& /*language*/) + { + if (!value) + { + return value; + } + const auto cr = unbox_value_or(value, CornerRadius{ 0, 0, 0, 0 }); + return box_value(CornerRadius{ cr.TopLeft, cr.TopRight, 0, 0 }); + } + + winrt::Windows::Foundation::IInspectable TopCornerRadiusFilterConverter::ConvertBack(const winrt::Windows::Foundation::IInspectable& value, const Interop::TypeName& /*targetType*/, const winrt::Windows::Foundation::IInspectable& /*parameter*/, const hstring& /*language*/) + { + return value; + } + + winrt::Windows::Foundation::IInspectable BottomCornerRadiusFilterConverter::Convert(const winrt::Windows::Foundation::IInspectable& value, const Interop::TypeName& /*targetType*/, const winrt::Windows::Foundation::IInspectable& /*parameter*/, const hstring& /*language*/) + { + if (!value) + { + return value; + } + const auto cr = unbox_value_or(value, CornerRadius{ 0, 0, 0, 0 }); + return box_value(CornerRadius{ 0, 0, cr.BottomRight, cr.BottomLeft }); + } + + winrt::Windows::Foundation::IInspectable BottomCornerRadiusFilterConverter::ConvertBack(const winrt::Windows::Foundation::IInspectable& value, const Interop::TypeName& /*targetType*/, const winrt::Windows::Foundation::IInspectable& /*parameter*/, const hstring& /*language*/) + { + return value; + } +} diff --git a/src/cascadia/TerminalSettingsEditor/CornerRadiusFilterConverters.h b/src/cascadia/TerminalSettingsEditor/CornerRadiusFilterConverters.h new file mode 100644 index 0000000000..bd7b9b01c1 --- /dev/null +++ b/src/cascadia/TerminalSettingsEditor/CornerRadiusFilterConverters.h @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation +// Licensed under the MIT license. + +#pragma once + +#include "CornerRadiusConverter.g.h" +#include "TopCornerRadiusFilterConverter.g.h" +#include "BottomCornerRadiusFilterConverter.g.h" + +namespace winrt::Microsoft::Terminal::Settings::Editor::implementation +{ + struct CornerRadiusConverter : CornerRadiusConverterT + { + CornerRadiusConverter() = default; + + Windows::Foundation::IInspectable Convert(const Windows::Foundation::IInspectable& value, const Windows::UI::Xaml::Interop::TypeName& targetType, const Windows::Foundation::IInspectable& parameter, const hstring& language); + Windows::Foundation::IInspectable ConvertBack(const Windows::Foundation::IInspectable& value, const Windows::UI::Xaml::Interop::TypeName& targetType, const Windows::Foundation::IInspectable& parameter, const hstring& language); + }; + + struct TopCornerRadiusFilterConverter : TopCornerRadiusFilterConverterT + { + TopCornerRadiusFilterConverter() = default; + + Windows::Foundation::IInspectable Convert(const Windows::Foundation::IInspectable& value, const Windows::UI::Xaml::Interop::TypeName& targetType, const Windows::Foundation::IInspectable& parameter, const hstring& language); + Windows::Foundation::IInspectable ConvertBack(const Windows::Foundation::IInspectable& value, const Windows::UI::Xaml::Interop::TypeName& targetType, const Windows::Foundation::IInspectable& parameter, const hstring& language); + }; + + struct BottomCornerRadiusFilterConverter : BottomCornerRadiusFilterConverterT + { + BottomCornerRadiusFilterConverter() = default; + + Windows::Foundation::IInspectable Convert(const Windows::Foundation::IInspectable& value, const Windows::UI::Xaml::Interop::TypeName& targetType, const Windows::Foundation::IInspectable& parameter, const hstring& language); + Windows::Foundation::IInspectable ConvertBack(const Windows::Foundation::IInspectable& value, const Windows::UI::Xaml::Interop::TypeName& targetType, const Windows::Foundation::IInspectable& parameter, const hstring& language); + }; +} + +namespace winrt::Microsoft::Terminal::Settings::Editor::factory_implementation +{ + BASIC_FACTORY(CornerRadiusConverter); + BASIC_FACTORY(TopCornerRadiusFilterConverter); + BASIC_FACTORY(BottomCornerRadiusFilterConverter); +} diff --git a/src/cascadia/TerminalSettingsEditor/CornerRadiusFilterConverters.idl b/src/cascadia/TerminalSettingsEditor/CornerRadiusFilterConverters.idl new file mode 100644 index 0000000000..83b5a87a4c --- /dev/null +++ b/src/cascadia/TerminalSettingsEditor/CornerRadiusFilterConverters.idl @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +namespace Microsoft.Terminal.Settings.Editor +{ + [default_interface] runtimeclass CornerRadiusConverter : Windows.UI.Xaml.Data.IValueConverter + { + CornerRadiusConverter(); + } + + [default_interface] runtimeclass TopCornerRadiusFilterConverter : Windows.UI.Xaml.Data.IValueConverter + { + TopCornerRadiusFilterConverter(); + } + + [default_interface] runtimeclass BottomCornerRadiusFilterConverter : Windows.UI.Xaml.Data.IValueConverter + { + BottomCornerRadiusFilterConverter(); + } +} diff --git a/src/cascadia/TerminalSettingsEditor/Microsoft.Terminal.Settings.Editor.vcxproj b/src/cascadia/TerminalSettingsEditor/Microsoft.Terminal.Settings.Editor.vcxproj index 7525a5623f..8523416e6a 100644 --- a/src/cascadia/TerminalSettingsEditor/Microsoft.Terminal.Settings.Editor.vcxproj +++ b/src/cascadia/TerminalSettingsEditor/Microsoft.Terminal.Settings.Editor.vcxproj @@ -179,6 +179,12 @@ SettingsExpander.idl + + ControlSizeTrigger.idl + + + CornerRadiusFilterConverters.idl + StringDefaultTemplateSelector.idl @@ -403,6 +409,12 @@ SettingsExpander.idl + + ControlSizeTrigger.idl + + + CornerRadiusFilterConverters.idl + StringDefaultTemplateSelector.idl @@ -519,6 +531,12 @@ Code + + Code + + + Code + Code diff --git a/src/cascadia/TerminalSettingsEditor/Microsoft.Terminal.Settings.Editor.vcxproj.filters b/src/cascadia/TerminalSettingsEditor/Microsoft.Terminal.Settings.Editor.vcxproj.filters index a744b6b358..345489b897 100644 --- a/src/cascadia/TerminalSettingsEditor/Microsoft.Terminal.Settings.Editor.vcxproj.filters +++ b/src/cascadia/TerminalSettingsEditor/Microsoft.Terminal.Settings.Editor.vcxproj.filters @@ -32,6 +32,8 @@ + + diff --git a/src/cascadia/TerminalSettingsEditor/SettingsCard.cpp b/src/cascadia/TerminalSettingsEditor/SettingsCard.cpp index 6b925e0241..6aa4cbca1e 100644 --- a/src/cascadia/TerminalSettingsEditor/SettingsCard.cpp +++ b/src/cascadia/TerminalSettingsEditor/SettingsCard.cpp @@ -32,15 +32,38 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation static constexpr std::wstring_view PressedState{ L"Pressed" }; static constexpr std::wstring_view DisabledState{ L"Disabled" }; + static constexpr std::wstring_view BitmapHeaderIconEnabledState{ L"BitmapHeaderIconEnabled" }; + static constexpr std::wstring_view BitmapHeaderIconDisabledState{ L"BitmapHeaderIconDisabled" }; + static constexpr std::wstring_view RightState{ L"Right" }; static constexpr std::wstring_view LeftState{ L"Left" }; static constexpr std::wstring_view VerticalState{ L"Vertical" }; + static constexpr std::wstring_view NoContentSpacingState{ L"NoContentSpacing" }; + static constexpr std::wstring_view ContentSpacingState{ L"ContentSpacing" }; + + static constexpr std::wstring_view ContentAlignmentStatesGroup{ L"ContentAlignmentStates" }; + static constexpr std::wstring_view ActionIconPresenterHolder{ L"PART_ActionIconPresenterHolder" }; static constexpr std::wstring_view HeaderPresenter{ L"PART_HeaderPresenter" }; static constexpr std::wstring_view DescriptionPresenter{ L"PART_DescriptionPresenter" }; static constexpr std::wstring_view HeaderIconPresenterHolder{ L"PART_HeaderIconPresenterHolder" }; + // Returns true if the given object is null, or is a string that is empty. + // Non-string non-null objects (e.g. a TextBlock) are considered "non-empty". + static bool _isNullOrEmpty(const winrt::Windows::Foundation::IInspectable& obj) + { + if (!obj) + { + return true; + } + if (const auto pv{ obj.try_as() }; pv && pv.Type() == PropertyType::String) + { + return unbox_value_or(obj, hstring{}).empty(); + } + return false; + } + SettingsCard::SettingsCard() { _InitializeProperties(); @@ -78,7 +101,7 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation L"ActionIcon", xaml_typename(), xaml_typename(), - PropertyMetadata{ nullptr }); + PropertyMetadata{ box_value(hstring{ L"\uE974" }) }); } if (!_ActionIconToolTipProperty) { @@ -123,6 +146,7 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation { // Drop any handlers from a previous template. _isEnabledChangedRevoker.revoke(); + _contentAlignmentStatesChangedRevoker.revoke(); _DisableButtonInteraction(); if (_contentChangedToken != 0) { @@ -136,6 +160,7 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation _UpdateHeaderIconVisibility(); // Initial visual states. _CheckInitialVisualState(); + _CheckHeaderIconState(); _SetAccessibleContentName(); // Watch for Content changing later (we may need to refresh the AutomationProperties.Name on it). @@ -156,6 +181,7 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation if (const auto strongThis = weakThis.get()) { strongThis->_GoToCommonState(strongThis->IsEnabled() ? NormalState : DisabledState, true); + strongThis->_CheckHeaderIconState(); } }); } @@ -164,6 +190,60 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation { VisualStateManager::GoToState(*this, IsEnabled() ? hstring{ NormalState } : hstring{ DisabledState }, true); _UpdateContentAlignmentState(); + + // Subscribe to ContentAlignmentStates so we can drive ContentSpacingStates + // whenever the alignment shifts to a stacked layout (Vertical, RightWrapped*). + if (const auto child{ GetTemplateChild(hstring{ ContentAlignmentStatesGroup }) }) + { + if (const auto group{ child.try_as() }) + { + _CheckVerticalSpacingState(group.CurrentState()); + _contentAlignmentStatesChangedRevoker = group.CurrentStateChanged(winrt::auto_revoke, [weakThis = get_weak()](auto&&, const VisualStateChangedEventArgs& args) { + if (const auto strongThis = weakThis.get()) + { + strongThis->_CheckVerticalSpacingState(args.NewState()); + } + }); + } + } + } + + void SettingsCard::_CheckHeaderIconState() + { + // The Disabled common state recolors text/glyph foregrounds via the brush, but a + // BitmapIcon is an image and won't pick up the disabled brush. Lower its opacity + // instead, via the BitmapHeaderIconStates group. Mirrors the toolkit's + // SettingsCard.cs::CheckHeaderIconState. + if (HeaderIcon().try_as()) + { + VisualStateManager::GoToState(*this, + hstring{ IsEnabled() ? BitmapHeaderIconEnabledState : BitmapHeaderIconDisabledState }, + true); + } + else + { + // Reset to the enabled state when a non-bitmap icon (or none) is present so the + // opacity setter doesn't stick around from a previous bitmap icon. + VisualStateManager::GoToState(*this, hstring{ BitmapHeaderIconEnabledState }, true); + } + } + + void SettingsCard::_CheckVerticalSpacingState(const VisualState& state) + { + // Add row spacing whenever the content sits below the header (Vertical or RightWrapped*) + // AND there's both Content and (Header or Description) to space apart. + const auto stateName{ state ? state.Name() : hstring{} }; + const bool stackedLayout = + stateName == VerticalState || + stateName == L"RightWrapped" || + stateName == L"RightWrappedNoIcon"; + + const bool hasContent{ static_cast(Content()) }; + const bool hasHeaderOrDescription = !_isNullOrEmpty(Header()) || !_isNullOrEmpty(Description()); + + VisualStateManager::GoToState(*this, + hstring{ (stackedLayout && hasContent && hasHeaderOrDescription) ? ContentSpacingState : NoContentSpacingState }, + true); } void SettingsCard::_SetAccessibleContentName() @@ -216,6 +296,18 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation strongThis->_GoToCommonState(NormalState, true); } }); + _pointerPressedRevoker = PointerPressed(winrt::auto_revoke, [weakThis = get_weak()](auto&&, auto&&) { + if (const auto strongThis = weakThis.get()) + { + strongThis->_GoToCommonState(PressedState, true); + } + }); + _pointerReleasedRevoker = PointerReleased(winrt::auto_revoke, [weakThis = get_weak()](auto&&, auto&&) { + if (const auto strongThis = weakThis.get()) + { + strongThis->_GoToCommonState(NormalState, true); + } + }); _pointerCaptureLostRevoker = PointerCaptureLost(winrt::auto_revoke, [weakThis = get_weak()](auto&&, auto&&) { if (const auto strongThis = weakThis.get()) { @@ -264,6 +356,8 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation IsTabStop(false); _pointerEnteredRevoker.revoke(); _pointerExitedRevoker.revoke(); + _pointerPressedRevoker.revoke(); + _pointerReleasedRevoker.revoke(); _pointerCaptureLostRevoker.revoke(); _pointerCanceledRevoker.revoke(); _previewKeyDownRevoker.revoke(); @@ -295,21 +389,6 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation } } - // Returns true if the given object is null, or is a string that is empty. - // Non-string non-null objects (e.g. a TextBlock) are considered "non-empty". - static bool _isNullOrEmpty(const winrt::Windows::Foundation::IInspectable& obj) - { - if (!obj) - { - return true; - } - if (const auto pv{ obj.try_as() }; pv && pv.Type() == PropertyType::String) - { - return unbox_value_or(obj, hstring{}).empty(); - } - return false; - } - void SettingsCard::_UpdateHeaderVisibility() { if (const auto child{ GetTemplateChild(hstring{ HeaderPresenter }) }) @@ -378,7 +457,11 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation void SettingsCard::_OnHeaderIconChanged(const DependencyObject& d, const DependencyPropertyChangedEventArgs& /*e*/) { const auto obj{ d.try_as() }; - get_self(obj)->_UpdateHeaderIconVisibility(); + const auto self = get_self(obj); + self->_UpdateHeaderIconVisibility(); + // HeaderIcon type may have flipped between BitmapIcon and other icon types — re-evaluate + // the BitmapHeaderIcon visual state so the disabled-opacity setter is applied (or cleared). + self->_CheckHeaderIconState(); } void SettingsCard::_OnIsClickEnabledChanged(const DependencyObject& d, const DependencyPropertyChangedEventArgs& /*e*/) diff --git a/src/cascadia/TerminalSettingsEditor/SettingsCard.h b/src/cascadia/TerminalSettingsEditor/SettingsCard.h index b31ae565f5..063491deab 100644 --- a/src/cascadia/TerminalSettingsEditor/SettingsCard.h +++ b/src/cascadia/TerminalSettingsEditor/SettingsCard.h @@ -59,6 +59,8 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation void _UpdateHeaderIconVisibility(); void _UpdateContentAlignmentState(); void _CheckInitialVisualState(); + void _CheckHeaderIconState(); + void _CheckVerticalSpacingState(const Windows::UI::Xaml::VisualState& state); void _SetAccessibleContentName(); Windows::UI::Xaml::FrameworkElement _GetFocusedElement(); @@ -66,10 +68,13 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation Windows::UI::Xaml::Controls::Control::IsEnabledChanged_revoker _isEnabledChangedRevoker; Windows::UI::Xaml::UIElement::PointerEntered_revoker _pointerEnteredRevoker; Windows::UI::Xaml::UIElement::PointerExited_revoker _pointerExitedRevoker; + Windows::UI::Xaml::UIElement::PointerPressed_revoker _pointerPressedRevoker; + Windows::UI::Xaml::UIElement::PointerReleased_revoker _pointerReleasedRevoker; Windows::UI::Xaml::UIElement::PointerCaptureLost_revoker _pointerCaptureLostRevoker; Windows::UI::Xaml::UIElement::PointerCanceled_revoker _pointerCanceledRevoker; Windows::UI::Xaml::UIElement::PreviewKeyDown_revoker _previewKeyDownRevoker; Windows::UI::Xaml::UIElement::PreviewKeyUp_revoker _previewKeyUpRevoker; + Windows::UI::Xaml::VisualStateGroup::CurrentStateChanged_revoker _contentAlignmentStatesChangedRevoker; int64_t _contentChangedToken{ 0 }; }; diff --git a/src/cascadia/TerminalSettingsEditor/SettingsControlsStyle.xaml b/src/cascadia/TerminalSettingsEditor/SettingsControlsStyle.xaml index 4b9f713e4b..04f4087b9c 100644 --- a/src/cascadia/TerminalSettingsEditor/SettingsControlsStyle.xaml +++ b/src/cascadia/TerminalSettingsEditor/SettingsControlsStyle.xaml @@ -115,10 +115,32 @@ 2,0,20,0 14,0,0,0 13 + 0.4 + 8 + 476 + 286 + 16,16,4,16 58,8,44,8 + 58,8,16,8 0,1,0,0 16 + 32 + 32 + + + + Show all settings + + + + @@ -127,7 +149,7 @@ no left/right rounded corners, a 1-pixel top border between siblings, deeper left padding to line up with the parent expander's content. --> - + + + + + + + + + + + + diff --git a/src/cascadia/TerminalSettingsEditor/SettingsExpander.cpp b/src/cascadia/TerminalSettingsEditor/SettingsExpander.cpp index 784acb4ea1..2f80164804 100644 --- a/src/cascadia/TerminalSettingsEditor/SettingsExpander.cpp +++ b/src/cascadia/TerminalSettingsEditor/SettingsExpander.cpp @@ -5,13 +5,17 @@ #include "SettingsExpander.h" #include "SettingsExpander.g.cpp" #include "SettingsExpanderAutomationPeer.g.cpp" +#include "SettingsExpanderItemStyleSelector.g.cpp" using namespace winrt::Windows::Foundation; +using namespace winrt::Windows::Foundation::Collections; using namespace winrt::Windows::UI::Xaml; 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 }; @@ -21,10 +25,18 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation DependencyProperty SettingsExpander::_IsExpandedProperty{ nullptr }; DependencyProperty SettingsExpander::_ItemsHeaderProperty{ nullptr }; DependencyProperty SettingsExpander::_ItemsFooterProperty{ nullptr }; + DependencyProperty SettingsExpander::_ItemsProperty{ nullptr }; + DependencyProperty SettingsExpander::_ItemsSourceProperty{ nullptr }; + DependencyProperty SettingsExpander::_ItemTemplateProperty{ nullptr }; + DependencyProperty SettingsExpander::_ItemContainerStyleSelectorProperty{ nullptr }; + + static constexpr std::wstring_view PART_ItemsRepeater{ L"PART_ItemsRepeater" }; SettingsExpander::SettingsExpander() { _InitializeProperties(); + + Items(single_threaded_vector()); } void SettingsExpander::_InitializeProperties() @@ -85,6 +97,38 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation xaml_typename(), PropertyMetadata{ nullptr }); } + if (!_ItemsProperty) + { + _ItemsProperty = DependencyProperty::Register( + L"Items", + xaml_typename>(), + xaml_typename(), + PropertyMetadata{ nullptr, PropertyChangedCallback{ &SettingsExpander::_OnItemsConnectedPropertyChanged } }); + } + if (!_ItemsSourceProperty) + { + _ItemsSourceProperty = DependencyProperty::Register( + L"ItemsSource", + xaml_typename(), + xaml_typename(), + PropertyMetadata{ nullptr, PropertyChangedCallback{ &SettingsExpander::_OnItemsConnectedPropertyChanged } }); + } + if (!_ItemTemplateProperty) + { + _ItemTemplateProperty = DependencyProperty::Register( + L"ItemTemplate", + xaml_typename(), + xaml_typename(), + PropertyMetadata{ nullptr }); + } + if (!_ItemContainerStyleSelectorProperty) + { + _ItemContainerStyleSelectorProperty = DependencyProperty::Register( + L"ItemContainerStyleSelector", + xaml_typename(), + xaml_typename(), + PropertyMetadata{ nullptr }); + } } AutomationPeer SettingsExpander::OnCreateAutomationPeer() @@ -94,8 +138,77 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation void SettingsExpander::OnApplyTemplate() { - // No template-part lookups required: our template uses regular bindings - // through TemplateBinding / x:Bind. The DPs do their own work. + _SetAccessibleName(); + + // Drop the prior template's repeater hookups before locating the new one. + _elementPreparedRevoker.revoke(); + _itemsRepeater = nullptr; + + if (const auto child{ GetTemplateChild(hstring{ PART_ItemsRepeater }) }) + { + _itemsRepeater = child.try_as(); + } + + if (_itemsRepeater) + { + _elementPreparedRevoker = _itemsRepeater.ElementPrepared(winrt::auto_revoke, { get_weak(), &SettingsExpander::_ItemsRepeater_ElementPrepared }); + + // Push our initial ItemsSource through to the repeater. + _UpdateItemsSource(); + } + } + + void SettingsExpander::_SetAccessibleName() + { + if (!AutomationProperties::GetName(*this).empty()) + { + return; + } + if (const auto headerString{ unbox_value_or(Header(), hstring{}) }; !headerString.empty()) + { + AutomationProperties::SetName(*this, headerString); + } + } + + void SettingsExpander::_UpdateItemsSource() + { + if (!_itemsRepeater) + { + return; + } + // ItemsSource wins when set; otherwise fall back to the inline Items collection. + if (const auto source{ ItemsSource() }) + { + _itemsRepeater.ItemsSource(source); + } + else + { + _itemsRepeater.ItemsSource(Items()); + } + } + + void SettingsExpander::_OnItemsConnectedPropertyChanged(const DependencyObject& d, const DependencyPropertyChangedEventArgs& /*e*/) + { + if (const auto obj{ d.try_as() }) + { + get_self(obj)->_UpdateItemsSource(); + } + } + + 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() }) + { + if (element.ReadLocalValue(FrameworkElement::StyleProperty()) == DependencyProperty::UnsetValue()) + { + element.Style(selector.SelectStyle(nullptr, element)); + } + } } void SettingsExpander::_OnIsExpandedChanged(const DependencyObject& d, const DependencyPropertyChangedEventArgs& e) @@ -107,6 +220,13 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation } const auto self = get_self(obj); const auto newValue = unbox_value_or(e.NewValue(), false); + + // Notify the automation peer so screen readers see the expand/collapse state change. + if (const auto peer{ FrameworkElementAutomationPeer::FromElement(obj).try_as() }) + { + peer.RaiseExpandedChangedEvent(newValue); + } + if (newValue) { self->Expanded.raise(obj, nullptr); @@ -147,4 +267,29 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation } return {}; } + + void SettingsExpanderAutomationPeer::RaiseExpandedChangedEvent(bool newValue) + { + // Mirrors the toolkit's SettingsExpanderAutomationPeer.RaiseExpandedChangedEvent. + // Narrator doesn't actually announce this today (microsoft/microsoft-ui-xaml#3469), + // but other AT can subscribe and we keep parity with the toolkit. + const auto newState = newValue ? ExpandCollapseState::Expanded : ExpandCollapseState::Collapsed; + const auto oldState = newValue ? ExpandCollapseState::Collapsed : ExpandCollapseState::Expanded; + RaisePropertyChangedEvent(ExpandCollapsePatternIdentifiers::ExpandCollapseStateProperty(), box_value(oldState), box_value(newState)); + } + + Windows::UI::Xaml::Style SettingsExpanderItemStyleSelector::SelectStyleCore(const winrt::Windows::Foundation::IInspectable& /*item*/, const Windows::UI::Xaml::DependencyObject& container) + { + // When the prepared container is a clickable SettingsCard, give it the + // clickable item style (which adds the right padding for the chevron). + // Otherwise fall back to the default item style. + if (const auto card{ container.try_as() }) + { + if (card.IsClickEnabled()) + { + return _ClickableStyle; + } + } + return _DefaultStyle; + } } diff --git a/src/cascadia/TerminalSettingsEditor/SettingsExpander.h b/src/cascadia/TerminalSettingsEditor/SettingsExpander.h index 5e66a0a53b..3a0ea96f26 100644 --- a/src/cascadia/TerminalSettingsEditor/SettingsExpander.h +++ b/src/cascadia/TerminalSettingsEditor/SettingsExpander.h @@ -10,7 +10,7 @@ Abstract: on the Windows Community Toolkit's SettingsExpander. Author(s): -- Carlos Zamora - 2026 (port from CommunityToolkit.WinUI.Controls.SettingsExpander) +- Carlos Zamora - May 2026 (port from CommunityToolkit.WinUI.Controls.SettingsExpander) --*/ @@ -18,6 +18,7 @@ Author(s): #include "SettingsExpander.g.h" #include "SettingsExpanderAutomationPeer.g.h" +#include "SettingsExpanderItemStyleSelector.g.h" #include "Utils.h" namespace winrt::Microsoft::Terminal::Settings::Editor::implementation @@ -41,10 +42,22 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation DEPENDENCY_PROPERTY(bool, IsExpanded); DEPENDENCY_PROPERTY(Windows::UI::Xaml::UIElement, ItemsHeader); DEPENDENCY_PROPERTY(Windows::UI::Xaml::UIElement, ItemsFooter); + DEPENDENCY_PROPERTY(Windows::Foundation::Collections::IVector, Items); + DEPENDENCY_PROPERTY(Windows::Foundation::IInspectable, ItemsSource); + DEPENDENCY_PROPERTY(Windows::Foundation::IInspectable, ItemTemplate); + DEPENDENCY_PROPERTY(Windows::UI::Xaml::Controls::StyleSelector, ItemContainerStyleSelector); private: static void _InitializeProperties(); static void _OnIsExpandedChanged(const Windows::UI::Xaml::DependencyObject& d, const Windows::UI::Xaml::DependencyPropertyChangedEventArgs& e); + static void _OnItemsConnectedPropertyChanged(const Windows::UI::Xaml::DependencyObject& d, const Windows::UI::Xaml::DependencyPropertyChangedEventArgs& e); + + void _SetAccessibleName(); + void _UpdateItemsSource(); + void _ItemsRepeater_ElementPrepared(const Microsoft::UI::Xaml::Controls::ItemsRepeater& sender, const Microsoft::UI::Xaml::Controls::ItemsRepeaterElementPreparedEventArgs& args); + + Microsoft::UI::Xaml::Controls::ItemsRepeater _itemsRepeater{ nullptr }; + Microsoft::UI::Xaml::Controls::ItemsRepeater::ElementPrepared_revoker _elementPreparedRevoker; }; // AutomationPeer for SettingsExpander. Reports class name and falls back to @@ -57,6 +70,21 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation Windows::UI::Xaml::Automation::Peers::AutomationControlType GetAutomationControlTypeCore() const; hstring GetClassNameCore() const; hstring GetNameCore() const; + + void RaiseExpandedChangedEvent(bool newValue); + }; + + // StyleSelector used by SettingsExpander to choose between a clickable vs. + // non-clickable SettingsCard container style for items in its ItemsRepeater. + // Ported from the Windows Community Toolkit's SettingsExpanderItemStyleSelector. + struct SettingsExpanderItemStyleSelector : SettingsExpanderItemStyleSelectorT + { + SettingsExpanderItemStyleSelector() = default; + + Windows::UI::Xaml::Style SelectStyleCore(const Windows::Foundation::IInspectable& item, const Windows::UI::Xaml::DependencyObject& container); + + WINRT_PROPERTY(Windows::UI::Xaml::Style, DefaultStyle, nullptr); + WINRT_PROPERTY(Windows::UI::Xaml::Style, ClickableStyle, nullptr); }; } @@ -64,4 +92,5 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::factory_implementation { BASIC_FACTORY(SettingsExpander); BASIC_FACTORY(SettingsExpanderAutomationPeer); + BASIC_FACTORY(SettingsExpanderItemStyleSelector); } diff --git a/src/cascadia/TerminalSettingsEditor/SettingsExpander.idl b/src/cascadia/TerminalSettingsEditor/SettingsExpander.idl index 6c0b30b6a8..fb2de8e159 100644 --- a/src/cascadia/TerminalSettingsEditor/SettingsExpander.idl +++ b/src/cascadia/TerminalSettingsEditor/SettingsExpander.idl @@ -28,6 +28,18 @@ namespace Microsoft.Terminal.Settings.Editor Windows.UI.Xaml.UIElement ItemsFooter; static Windows.UI.Xaml.DependencyProperty ItemsFooterProperty { get; }; + Windows.Foundation.Collections.IVector Items; + static Windows.UI.Xaml.DependencyProperty ItemsProperty { get; }; + + Object ItemsSource; + static Windows.UI.Xaml.DependencyProperty ItemsSourceProperty { get; }; + + Object ItemTemplate; + static Windows.UI.Xaml.DependencyProperty ItemTemplateProperty { get; }; + + Windows.UI.Xaml.Controls.StyleSelector ItemContainerStyleSelector; + static Windows.UI.Xaml.DependencyProperty ItemContainerStyleSelectorProperty { get; }; + event Windows.Foundation.TypedEventHandler Expanded; event Windows.Foundation.TypedEventHandler Collapsed; }; @@ -35,5 +47,15 @@ namespace Microsoft.Terminal.Settings.Editor [default_interface] runtimeclass SettingsExpanderAutomationPeer : Windows.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer { SettingsExpanderAutomationPeer(SettingsExpander owner); + + void RaiseExpandedChangedEvent(Boolean newValue); + }; + + [default_interface] runtimeclass SettingsExpanderItemStyleSelector : Windows.UI.Xaml.Controls.StyleSelector + { + SettingsExpanderItemStyleSelector(); + + Windows.UI.Xaml.Style DefaultStyle; + Windows.UI.Xaml.Style ClickableStyle; }; }