refactor(NumericUpDown): template-bind clear icon, motion, and font-size parts; align the theme-first rule wording

This commit is contained in:
chinboy
2026-08-31 12:44:05 +08:00
parent 8705d6af5e
commit 9ba8733bab
4 changed files with 23 additions and 18 deletions
+10 -1
View File
@@ -115,7 +115,16 @@ Detailed AI collaboration rules live in [docs/engineering/contributing/agent-gui
- Treat AOT compatibility as a first-class design constraint for new features and bug fixes.
- Verify with tests or publish checks that match the risk of the change.
- Follow the mandatory Superpowers workflow before any creation, change, implementation, or bug fix: invoke `superpowers:using-superpowers`, then route through `superpowers:brainstorming` (new features/components), `superpowers:systematic-debugging` (bugs), `superpowers:test-driven-development` (implementation), `superpowers:writing-plans`/`superpowers:executing-plans` (multi-step work), and `superpowers:verification-before-completion` (before claiming done). Never skip a skill that could apply; only an explicit user instruction may narrow it.
- Prefer declaring template/style bindings (RenderTransform, Transitions, template-part properties, pseudo-class driven setters) in AXAML ControlThemes whenever a selector/Setter can reach the target, the runtime behavior is identical, and there is no measurable performance cost. Fall back to code-behind binding only when the ControlTheme cannot express it — for example targets inside a Content subtree beyond the `/template/` chain, internal members invisible to compiled bindings, runtime-computed values, or AOT-unsafe reflection — and state the reason in a code comment. When an existing code-side binding can be migrated to a ControlTheme with identical behavior and performance, migrate it as part of any change that touches the file. Note: `Transitions` must be declared in ControlTheme style setters, never directly inside ControlTemplate content (template build has no clock attached and throws NRE).
## 主题绑定优先强约束(本项目生效)
1. **ControlTheme 优先**:Avalonia 控件的模板/样式类绑定(含 RenderTransform、Transitions、模板部件属性、伪类驱动的 Setter 等),只要同时满足以下全部条件,必须优先放在 AXAML ControlTheme 中声明,禁止先写成代码绑定:
- ControlTheme 能表达该绑定(选择器 / Setter / 模板绑定可达目标元素);
- 逻辑效果与代码绑定完全一致(触发时机、状态条件、取值、动画行为);
- 性能无可测量差异。
2. **代码绑定是兜底**:仅当 ControlTheme 无法完成时(如目标元素在 Content 子树跨不进 `/template/` 链、依赖 internal 成员而 XAML 编译绑定不可见、目标值为运行时计算值、AOT 约束禁止反射绑定等),才允许放到控件代码中绑定,且必须在代码注释中写明 ControlTheme 不可行的具体原因。
3. **迁移义务**:发现既有的代码绑定实际上可以等价迁移到 ControlTheme 时,在触及该文件的改动中一并迁移,不留“下次再说”。
4. **声明位置边界**:`Transitions` 必须声明在 ControlTheme 的 style setter 中,禁止直接写在 ControlTemplate 内容里——模板构建期元素尚未挂载 clock,会抛 NullReferenceException。
## Common Commands
@@ -13,6 +13,7 @@
- Behavior
- Restore the floating-handle hover shift for the suffix group: since the suffix group moved into the spinner content segment (outside the decorated box `ContentRightAddOn` slot), the animated `ButtonSpinnerDecoratedBox.ContentRightShift` no longer reached it through the template. The transform (`translateX`) and its `TransformOperationsTransition` are declared in the ControlTheme behind the `IsMotionEnabled` gate; `NumericUpDown` code only reacts to the decorated box hover state and replaces the `RenderTransform` value, which arms the transition. Re-mirroring the animated property frame by frame was rejected: the per-frame relay fights the render clock and shows up as a one-frame jump ("flicker") when the pointer re-enters quickly. Note: `Transitions` cannot be declared directly inside ControlTemplate content — the template build has no clock attached and throws NRE — so the transition lives in a style setter.
- Fix `InnerLeftContent` never rendering: the prefix presenter lives in a `ButtonSpinner.InnerLeftContent` property-element subtree where `TemplatedParent` never propagates, so its `TemplateBinding`s stayed dead. The presenter is now named `PART_InnerLeftContentPresenter` and relays `InnerLeftContent` / `InnerLeftContentTemplate` in code, matching the suffix presenter pattern.
- Consolidate part bindings into the ControlTheme where the template can express them: the clear button's `Icon` / `IsMotionEnabled`, the text box's `IsCustomFontSize`, and the inner-right presenter's `Content` / `ContentTemplate` (already template-bound since the suffix group moved into direct template content) no longer go through code relays. The remaining code relays are exactly the ones the ControlTheme cannot express: the prefix presenter's property-element subtree (dead `TemplatedParent`), and the clear button's visibility from the internal direct property `IsEffectiveShowClearButton`.
- Move the `clear` button from the suffix addon row into the input segment (right edge of the text area, before the spinner action buttons) in both mode templates; its semantic route simplifies to `/template/ .semantic-clear`.
- Keep the inner-right content (`suffix` group) attached after the clear button inside the input segment in both mode templates, so `clear`, the user suffix content and the spinner action buttons keep consistent spacing; the `suffix` semantic route simplifies to `/template/ .semantic-suffix`. The Spinner mode adds an 8px inset between the suffix group and the action-button divider, whose content frame has no padding of its own.
- Relay the owner `BorderBrush` onto the input frame as a local value (mirroring the shared `AbstractTextInput` behavior and antd `styles.root.borderColor`), so application root border customization wins over the frame state machine; clearing the owner value restores the themed border.
@@ -488,10 +488,9 @@ public partial class NumericUpDown : AvaloniaNumericUpDown,
if (e.NameScope.Find<InputClearIconButton>("PART_ClearButton") is { } clearButton)
{
_templatePartBindings.Add(BindUtils.RelayBind(this, ClearIconProperty, clearButton,
AbstractIconButton.IconProperty));
_templatePartBindings.Add(BindUtils.RelayBind(this, IsMotionEnabledProperty, clearButton,
AbstractIconButton.IsMotionEnabledProperty));
// Only the visibility relay stays in code: IsEffectiveShowClearButton is an internal
// direct property, invisible to compiled template bindings. Icon and IsMotionEnabled
// are template-bound in the ControlTheme.
_templatePartBindings.Add(BindUtils.RelayBind(this, IsEffectiveShowClearButtonProperty, clearButton,
Visual.IsVisibleProperty));
}
@@ -506,19 +505,9 @@ public partial class NumericUpDown : AvaloniaNumericUpDown,
innerLeftContent, ContentPresenter.ContentTemplateProperty));
}
if (e.NameScope.Find<ContentPresenter>("PART_InnerRightContentPresenter") is { } innerRightContent)
{
_templatePartBindings.Add(BindUtils.RelayBind(this, InnerRightContentProperty, innerRightContent,
ContentPresenter.ContentProperty));
_templatePartBindings.Add(BindUtils.RelayBind(this, InnerRightContentTemplateProperty,
innerRightContent, ContentPresenter.ContentTemplateProperty));
}
if (e.NameScope.Find<TextBox>("PART_TextBox") is { } textBox)
{
_templatePartBindings.Add(BindUtils.RelayBind(this, IsCustomFontSizeProperty, textBox,
TextBox.IsCustomFontSizeProperty));
}
// InnerRightContent / InnerRightContentTemplate and the text box IsCustomFontSize are
// template-bound in the ControlTheme (the parts sit in direct template content where
// TemplatedParent propagates), so no code relay is needed for them.
// The suffix group renders inside the spinner content segment, outside the decorated box's
// ContentRightAddOn slot, so the floating-handle shift no longer reaches it through the
@@ -49,6 +49,8 @@
Name="PART_ClearButton"
Classes.semantic-clear="True"
VerticalAlignment="Center"
Icon="{TemplateBinding ClearIcon}"
IsMotionEnabled="{TemplateBinding IsMotionEnabled}"
IsPassthroughMouseEvent="False" />
<atom:AddOnContentPresenter
Name="PART_InnerRightContentPresenter"
@@ -75,6 +77,7 @@
AcceptsReturn="False"
DataValidationErrors.Errors="{TemplateBinding DataValidationErrors.Errors}"
SizeType="Custom"
IsCustomFontSize="{TemplateBinding IsCustomFontSize}"
IsReadOnly="{TemplateBinding IsReadOnly}"
Text="{TemplateBinding Text, Mode=TwoWay}"
PlaceholderText="{TemplateBinding PlaceholderText}"
@@ -131,6 +134,8 @@
Name="PART_ClearButton"
Classes.semantic-clear="True"
VerticalAlignment="Center"
Icon="{TemplateBinding ClearIcon}"
IsMotionEnabled="{TemplateBinding IsMotionEnabled}"
IsPassthroughMouseEvent="False" />
<atom:AddOnContentPresenter
Name="PART_InnerRightContentPresenter"
@@ -157,6 +162,7 @@
AcceptsReturn="False"
DataValidationErrors.Errors="{TemplateBinding DataValidationErrors.Errors}"
SizeType="Custom"
IsCustomFontSize="{TemplateBinding IsCustomFontSize}"
IsReadOnly="{TemplateBinding IsReadOnly}"
Text="{TemplateBinding Text, Mode=TwoWay}"
TextAlignment="Center"