feat(Mentions): add size type support and localization for custom sizes

This commit is contained in:
chinboy
2026-06-21 13:55:54 +08:00
parent 38c9924c8e
commit 7c8bdfd67a
15 changed files with 751 additions and 6 deletions
@@ -11792,9 +11792,13 @@ namespace AtomUI.Theme.Language
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.InfoNamespaceLabel] = global::AtomUIGallery.ShowCases.Mentions.en_US.InfoNamespaceLabel;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.InfoPackageLabel] = global::AtomUIGallery.ShowCases.Mentions.en_US.InfoPackageLabel;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.P2PlaceholderTextBorderless] = global::AtomUIGallery.ShowCases.Mentions.en_US.P2PlaceholderTextBorderless;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.P2PlaceholderTextCustom] = global::AtomUIGallery.ShowCases.Mentions.en_US.P2PlaceholderTextCustom;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.P2PlaceholderTextFilled] = global::AtomUIGallery.ShowCases.Mentions.en_US.P2PlaceholderTextFilled;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.P2PlaceholderTextInputToMentionPeopleToMentionTag] = global::AtomUIGallery.ShowCases.Mentions.en_US.P2PlaceholderTextInputToMentionPeopleToMentionTag;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.P2PlaceholderTextLarge] = global::AtomUIGallery.ShowCases.Mentions.en_US.P2PlaceholderTextLarge;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.P2PlaceholderTextMiddle] = global::AtomUIGallery.ShowCases.Mentions.en_US.P2PlaceholderTextMiddle;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.P2PlaceholderTextOutlined] = global::AtomUIGallery.ShowCases.Mentions.en_US.P2PlaceholderTextOutlined;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.P2PlaceholderTextSmall] = global::AtomUIGallery.ShowCases.Mentions.en_US.P2PlaceholderTextSmall;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.P2PlaceholderTextThisIsDisabledMentions] = global::AtomUIGallery.ShowCases.Mentions.en_US.P2PlaceholderTextThisIsDisabledMentions;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.P2PlaceholderTextThisIsReadonlyMentions] = global::AtomUIGallery.ShowCases.Mentions.en_US.P2PlaceholderTextThisIsReadonlyMentions;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.P2PlaceholderTextUnderlined] = global::AtomUIGallery.ShowCases.Mentions.en_US.P2PlaceholderTextUnderlined;
@@ -11805,6 +11809,8 @@ namespace AtomUI.Theme.Language
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.ScenarioApi] = global::AtomUIGallery.ShowCases.Mentions.en_US.ScenarioApi;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.ScenarioDesignToken] = global::AtomUIGallery.ShowCases.Mentions.en_US.ScenarioDesignToken;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.ScenarioExamples] = global::AtomUIGallery.ShowCases.Mentions.en_US.ScenarioExamples;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.SizeTypeDescription] = global::AtomUIGallery.ShowCases.Mentions.en_US.SizeTypeDescription;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.SizeTypeTitle] = global::AtomUIGallery.ShowCases.Mentions.en_US.SizeTypeTitle;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.StatusDescription] = global::AtomUIGallery.ShowCases.Mentions.en_US.StatusDescription;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.StatusTitle] = global::AtomUIGallery.ShowCases.Mentions.en_US.StatusTitle;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.TokenColumnDescription] = global::AtomUIGallery.ShowCases.Mentions.en_US.TokenColumnDescription;
@@ -11887,9 +11893,13 @@ namespace AtomUI.Theme.Language
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.InfoNamespaceLabel] = global::AtomUIGallery.ShowCases.Mentions.zh_CN.InfoNamespaceLabel;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.InfoPackageLabel] = global::AtomUIGallery.ShowCases.Mentions.zh_CN.InfoPackageLabel;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.P2PlaceholderTextBorderless] = global::AtomUIGallery.ShowCases.Mentions.zh_CN.P2PlaceholderTextBorderless;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.P2PlaceholderTextCustom] = global::AtomUIGallery.ShowCases.Mentions.zh_CN.P2PlaceholderTextCustom;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.P2PlaceholderTextFilled] = global::AtomUIGallery.ShowCases.Mentions.zh_CN.P2PlaceholderTextFilled;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.P2PlaceholderTextInputToMentionPeopleToMentionTag] = global::AtomUIGallery.ShowCases.Mentions.zh_CN.P2PlaceholderTextInputToMentionPeopleToMentionTag;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.P2PlaceholderTextLarge] = global::AtomUIGallery.ShowCases.Mentions.zh_CN.P2PlaceholderTextLarge;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.P2PlaceholderTextMiddle] = global::AtomUIGallery.ShowCases.Mentions.zh_CN.P2PlaceholderTextMiddle;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.P2PlaceholderTextOutlined] = global::AtomUIGallery.ShowCases.Mentions.zh_CN.P2PlaceholderTextOutlined;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.P2PlaceholderTextSmall] = global::AtomUIGallery.ShowCases.Mentions.zh_CN.P2PlaceholderTextSmall;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.P2PlaceholderTextThisIsDisabledMentions] = global::AtomUIGallery.ShowCases.Mentions.zh_CN.P2PlaceholderTextThisIsDisabledMentions;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.P2PlaceholderTextThisIsReadonlyMentions] = global::AtomUIGallery.ShowCases.Mentions.zh_CN.P2PlaceholderTextThisIsReadonlyMentions;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.P2PlaceholderTextUnderlined] = global::AtomUIGallery.ShowCases.Mentions.zh_CN.P2PlaceholderTextUnderlined;
@@ -11900,6 +11910,8 @@ namespace AtomUI.Theme.Language
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.ScenarioApi] = global::AtomUIGallery.ShowCases.Mentions.zh_CN.ScenarioApi;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.ScenarioDesignToken] = global::AtomUIGallery.ShowCases.Mentions.zh_CN.ScenarioDesignToken;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.ScenarioExamples] = global::AtomUIGallery.ShowCases.Mentions.zh_CN.ScenarioExamples;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.SizeTypeDescription] = global::AtomUIGallery.ShowCases.Mentions.zh_CN.SizeTypeDescription;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.SizeTypeTitle] = global::AtomUIGallery.ShowCases.Mentions.zh_CN.SizeTypeTitle;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.StatusDescription] = global::AtomUIGallery.ShowCases.Mentions.zh_CN.StatusDescription;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.StatusTitle] = global::AtomUIGallery.ShowCases.Mentions.zh_CN.StatusTitle;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.TokenColumnDescription] = global::AtomUIGallery.ShowCases.Mentions.zh_CN.TokenColumnDescription;
@@ -11982,9 +11994,13 @@ namespace AtomUI.Theme.Language
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.InfoNamespaceLabel] = global::AtomUIGallery.ShowCases.Mentions.zh_TW.InfoNamespaceLabel;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.InfoPackageLabel] = global::AtomUIGallery.ShowCases.Mentions.zh_TW.InfoPackageLabel;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.P2PlaceholderTextBorderless] = global::AtomUIGallery.ShowCases.Mentions.zh_TW.P2PlaceholderTextBorderless;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.P2PlaceholderTextCustom] = global::AtomUIGallery.ShowCases.Mentions.zh_TW.P2PlaceholderTextCustom;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.P2PlaceholderTextFilled] = global::AtomUIGallery.ShowCases.Mentions.zh_TW.P2PlaceholderTextFilled;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.P2PlaceholderTextInputToMentionPeopleToMentionTag] = global::AtomUIGallery.ShowCases.Mentions.zh_TW.P2PlaceholderTextInputToMentionPeopleToMentionTag;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.P2PlaceholderTextLarge] = global::AtomUIGallery.ShowCases.Mentions.zh_TW.P2PlaceholderTextLarge;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.P2PlaceholderTextMiddle] = global::AtomUIGallery.ShowCases.Mentions.zh_TW.P2PlaceholderTextMiddle;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.P2PlaceholderTextOutlined] = global::AtomUIGallery.ShowCases.Mentions.zh_TW.P2PlaceholderTextOutlined;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.P2PlaceholderTextSmall] = global::AtomUIGallery.ShowCases.Mentions.zh_TW.P2PlaceholderTextSmall;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.P2PlaceholderTextThisIsDisabledMentions] = global::AtomUIGallery.ShowCases.Mentions.zh_TW.P2PlaceholderTextThisIsDisabledMentions;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.P2PlaceholderTextThisIsReadonlyMentions] = global::AtomUIGallery.ShowCases.Mentions.zh_TW.P2PlaceholderTextThisIsReadonlyMentions;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.P2PlaceholderTextUnderlined] = global::AtomUIGallery.ShowCases.Mentions.zh_TW.P2PlaceholderTextUnderlined;
@@ -11995,6 +12011,8 @@ namespace AtomUI.Theme.Language
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.ScenarioApi] = global::AtomUIGallery.ShowCases.Mentions.zh_TW.ScenarioApi;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.ScenarioDesignToken] = global::AtomUIGallery.ShowCases.Mentions.zh_TW.ScenarioDesignToken;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.ScenarioExamples] = global::AtomUIGallery.ShowCases.Mentions.zh_TW.ScenarioExamples;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.SizeTypeDescription] = global::AtomUIGallery.ShowCases.Mentions.zh_TW.SizeTypeDescription;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.SizeTypeTitle] = global::AtomUIGallery.ShowCases.Mentions.zh_TW.SizeTypeTitle;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.StatusDescription] = global::AtomUIGallery.ShowCases.Mentions.zh_TW.StatusDescription;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.StatusTitle] = global::AtomUIGallery.ShowCases.Mentions.zh_TW.StatusTitle;
dictionary[global::AtomUIGallery.Localization.MentionsShowCaseLangResourceKind.TokenColumnDescription] = global::AtomUIGallery.ShowCases.Mentions.zh_TW.TokenColumnDescription;
@@ -3770,9 +3770,13 @@ namespace AtomUIGallery.Localization
InfoNamespaceLabel,
InfoPackageLabel,
P2PlaceholderTextBorderless,
P2PlaceholderTextCustom,
P2PlaceholderTextFilled,
P2PlaceholderTextInputToMentionPeopleToMentionTag,
P2PlaceholderTextLarge,
P2PlaceholderTextMiddle,
P2PlaceholderTextOutlined,
P2PlaceholderTextSmall,
P2PlaceholderTextThisIsDisabledMentions,
P2PlaceholderTextThisIsReadonlyMentions,
P2PlaceholderTextUnderlined,
@@ -3783,6 +3787,8 @@ namespace AtomUIGallery.Localization
ScenarioApi,
ScenarioDesignToken,
ScenarioExamples,
SizeTypeDescription,
SizeTypeTitle,
StatusDescription,
StatusTitle,
TokenColumnDescription,
@@ -8,6 +8,8 @@ internal partial class en_US : LanguageProvider
{
public const string BasicTitle = "Basic";
public const string BasicDescription = "The simplest use.";
public const string SizeTypeTitle = "Mentions size";
public const string SizeTypeDescription = "Mentions supports large, middle, small, and Custom sizes with local height and font overrides.";
public const string VariantsTitle = "Variants";
public const string VariantsDescription = "Variants of Mentions, there are four variants: outlined filled borderless and underlined.";
public const string AsynchronousLoadingTitle = "Asynchronous loading";
@@ -24,6 +26,10 @@ internal partial class en_US : LanguageProvider
public const string AutoSizeDescription = "Height autoSize.";
public const string WithClearIconTitle = "With clear icon";
public const string WithClearIconDescription = "Customize clear button.";
public const string P2PlaceholderTextLarge = "Large";
public const string P2PlaceholderTextMiddle = "Middle";
public const string P2PlaceholderTextSmall = "Small";
public const string P2PlaceholderTextCustom = "Custom";
public const string P2PlaceholderTextOutlined = "Outlined";
public const string P2PlaceholderTextFilled = "Filled";
public const string P2PlaceholderTextBorderless = "Borderless";
@@ -8,6 +8,8 @@ internal partial class zh_CN : LanguageProvider
{
public const string BasicTitle = "基础用法";
public const string BasicDescription = "最简单的用法。";
public const string SizeTypeTitle = "Mentions 尺寸";
public const string SizeTypeDescription = "Mentions 支持大号、中号、小号,也支持通过 Custom 配合本地高度和字号自定义。";
public const string VariantsTitle = "变体";
public const string VariantsDescription = "Mentions 提供四种变体:描边、填充、无边框和下划线。";
public const string AsynchronousLoadingTitle = "异步加载";
@@ -24,6 +26,10 @@ internal partial class zh_CN : LanguageProvider
public const string AutoSizeDescription = "高度自动调整。";
public const string WithClearIconTitle = "带清除图标";
public const string WithClearIconDescription = "自定义清除按钮。";
public const string P2PlaceholderTextLarge = "大号";
public const string P2PlaceholderTextMiddle = "中号";
public const string P2PlaceholderTextSmall = "小号";
public const string P2PlaceholderTextCustom = "自定义";
public const string P2PlaceholderTextOutlined = "线框风格";
public const string P2PlaceholderTextFilled = "填充风格";
public const string P2PlaceholderTextBorderless = "无边框";
@@ -8,6 +8,8 @@ internal partial class zh_TW : LanguageProvider
{
public const string BasicTitle = "基礎用法";
public const string BasicDescription = "最簡單的用法。";
public const string SizeTypeTitle = "Mentions 尺寸";
public const string SizeTypeDescription = "Mentions 支持大號、中號、小號,也支持通過 Custom 配合本地高度和字號自定義。";
public const string VariantsTitle = "變體";
public const string VariantsDescription = "Mentions 提供四種變體:描邊、填充、無邊框和下划線。";
public const string AsynchronousLoadingTitle = "異步加載";
@@ -24,6 +26,10 @@ internal partial class zh_TW : LanguageProvider
public const string AutoSizeDescription = "高度自動調整。";
public const string WithClearIconTitle = "帶清除圖標";
public const string WithClearIconDescription = "自定義清除按鈕。";
public const string P2PlaceholderTextLarge = "大號";
public const string P2PlaceholderTextMiddle = "中號";
public const string P2PlaceholderTextSmall = "小號";
public const string P2PlaceholderTextCustom = "自定義";
public const string P2PlaceholderTextOutlined = "線框風格";
public const string P2PlaceholderTextFilled = "填充風格";
public const string P2PlaceholderTextBorderless = "無邊框";
@@ -2,6 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="AtomUIGallery.ShowCases.Mentions.MentionsShowCase"
xmlns:atom="https://atomui.net"
xmlns:antdicons="https://atomui.net/icons/antdesign"
xmlns:gallery="https://atomui.net/oss-controls/gallery"
xmlns:vm="using:AtomUIGallery.ShowCases.Mentions"
x:DataType="vm:MentionsViewModel">
@@ -144,6 +145,41 @@
</gallery:ShowCaseItem.DeferredContentTemplate>
</gallery:ShowCaseItem>
<gallery:ShowCaseItem
Title="{gallery:MentionsShowCaseLangResource SizeTypeTitle}"
IsDeferredContentEnabled="True"
Description="{gallery:MentionsShowCaseLangResource SizeTypeDescription}">
<gallery:ShowCaseItem.DeferredContentTemplate>
<DataTemplate x:DataType="vm:MentionsViewModel">
<StackPanel Orientation="Vertical" Spacing="10" Margin="0, 0, 20, 0">
<atom:Mentions HorizontalAlignment="Stretch"
OptionsSource="{Binding BasicMentionOptions}"
PlaceholderText="{gallery:MentionsShowCaseLangResource P2PlaceholderTextLarge}"
SizeType="Large"
ContentLeftAddOn="{antdicons:AntDesignIconProvider Kind=UserOutlined}" />
<atom:Mentions HorizontalAlignment="Stretch"
OptionsSource="{Binding BasicMentionOptions}"
PlaceholderText="{gallery:MentionsShowCaseLangResource P2PlaceholderTextMiddle}"
SizeType="Middle"
ContentLeftAddOn="{antdicons:AntDesignIconProvider Kind=UserOutlined}" />
<atom:Mentions HorizontalAlignment="Stretch"
OptionsSource="{Binding BasicMentionOptions}"
PlaceholderText="{gallery:MentionsShowCaseLangResource P2PlaceholderTextSmall}"
SizeType="Small"
ContentLeftAddOn="{antdicons:AntDesignIconProvider Kind=UserOutlined}" />
<atom:Mentions Name="CustomSizeTypeMentions"
HorizontalAlignment="Stretch"
OptionsSource="{Binding BasicMentionOptions}"
PlaceholderText="{gallery:MentionsShowCaseLangResource P2PlaceholderTextCustom}"
SizeType="Custom"
Height="38"
FontSize="15"
ContentLeftAddOn="{antdicons:AntDesignIconProvider Kind=UserOutlined}" />
</StackPanel>
</DataTemplate>
</gallery:ShowCaseItem.DeferredContentTemplate>
</gallery:ShowCaseItem>
<gallery:ShowCaseItem
Title="{gallery:MentionsShowCaseLangResource VariantsTitle}"
IsDeferredContentEnabled="True"
@@ -0,0 +1,17 @@
# Mentions Changelog
本文档记录 Mentions 控件级设计、API、主题契约、Token 和实现结构的变化。它用于维护控件设计历史,不替代仓库根目录 `CHANGELOG.md`,也不作为正式版本发布说明。
记录原则:
- 只记录会影响 Mentions 设计理解、兼容边界、实现架构或维护方式的变化。
- 不记录临时讨论、纯格式化或没有长期价值的实现细节。
- 架构文档始终描述最新设计状态;历史变化记录在本文档。
## 2026-06-21
- Docs
- Establish Mentions desktop architecture documentation under `docs/controls/desktop/data-entry/mentions/overview.md`.
- Add Mentions implementation documentation covering trigger detection, candidate popup, filtering, async loading, option insertion, Form integration and lifecycle boundaries.
- Add Mentions Token documentation for `PopupContentPadding`, `OptionHeight` and `MinPopupWidth`.
- Add Mentions changelog and link the document set from the Data Entry category entry.
@@ -0,0 +1,238 @@
# Mentions 桌面版实现原理
本文档描述 Mentions 桌面版的内部模板组合、触发符识别、候选弹层、同步/异步加载、过滤、候选插入、Form 和 Token 资源边界。公共设计与 API 契约见 [Mentions 桌面版架构设计](overview.md),Token 语义见 [Mentions Token 设计](token.md),变化记录见 [Mentions Changelog](changelog.md)。
## 1. 实现定位
Mentions 的实现以 `MentionTextArea` 为输入内核,`Popup` 和 `CandidateList` 为候选选择层,`Mentions` 本体负责状态编排、数据加载、过滤、弹层生命周期和 Form 集成。实现文档聚焦 Mentions 自身的状态流,不重新说明 TextArea 的文本布局、选择渲染和输入表面细节。
## 2. 源码文件结构
主要源码:
- `src/AtomUI.Desktop.Controls/Mentions/Mentions.cs`:public API、状态流、候选加载、过滤、弹层生命周期、键盘处理和 Form 接口。
- `src/AtomUI.Desktop.Controls/Mentions/MentionTextArea.cs`:内部 TextArea,负责触发符扫描、过滤值同步和候选插入。
- `src/AtomUI.Desktop.Controls/Mentions/MentionOption.cs`:候选项接口和默认 record。
- `src/AtomUI.Desktop.Controls/Mentions/DataLoad/IMentionOptionsAsyncLoader.cs`:异步候选加载接口。
- `src/AtomUI.Desktop.Controls/Mentions/DataLoad/MentionOptionsLoadResult.cs`:异步加载结果。
- `src/AtomUI.Desktop.Controls/Mentions/*EventArgs.cs`:候选触发、填充、加载和选项事件参数。
- `src/AtomUI.Desktop.Controls/Mentions/MentionsPlacementMode.cs`:弹层展开方向。
- `src/AtomUI.Desktop.Controls/Mentions/MentionPseudoClass.cs`:`:candidateopen` 伪类常量。
- `src/AtomUI.Desktop.Controls/Mentions/MentionsToken.cs`:候选弹层尺寸 Token。
- `src/AtomUI.Desktop.Controls/Mentions/Themes/MentionsTheme.axaml`:Mentions 模板、popup、loading 和候选列表视觉。
- `src/AtomUI.Desktop.Controls/Mentions/Themes/MentionsThemes.axaml`:Mentions 主题聚合入口。
## 3. 核心类职责
`Mentions` 是状态协调器。它不直接编辑文本 run,也不渲染候选项;它接收 `MentionTextArea` 的候选打开/关闭请求,协调本地/异步候选数据,维护候选视图,控制 popup 生命周期,并把候选提交结果写回内部文本区域。
`MentionTextArea` 是文本输入和 trigger 检测边界。它继承 `TextArea`,复用 TextArea 的多行输入、清除、状态、Form feedback 和尺寸能力,并增加 `TriggerPrefix`、`FilterValue`、`IsDropDownOpen`、`CandidateOpenRequest` 和 `CandidateCloseRequest`。
`CandidateList` 是候选选择边界。Mentions 只依赖 `ICandidateList` 的 `ItemsSource`、`SelectedItem`、`Commit`、`Cancel` 和 `HandleKeyDown()`,不直接管理候选项容器。
`IMentionOptionsAsyncLoader` 是异步数据边界。控件只调用 `LoadAsync(context, token)`,不假设远程协议、缓存策略或错误显示方式。
## 4. 状态与数据流
初始化流:
```text
OnInitialized
→ if Value == null, Value = DefaultValue
→ if TriggerPrefix == null, TriggerPrefix = ["@"]
→ if ClearIcon == null, ClearIcon = CloseCircleFilled
→ ConfigurePopupPlacement()
```
文本触发流:
```text
MentionTextArea.Text / CaretIndex changed
→ scan backward until whitespace/control char
→ find one-character TriggerPrefix
→ compute trigger bounds and predicate
→ CandidateOpenRequest / CandidateCloseRequest
```
候选打开流:
```text
CandidateOpenRequest
→ CandidateTriggered
→ compute popup offsets
→ IsDropDownOpen = true
→ DropDownOpening
→ OpenDropDown
→ debounce timer or PopulateDropDown
```
候选填充流:
```text
PopulateDropDown
→ TryPopulateAsync(FilterValue)
→ else Populating(FilterValue)
→ if !Cancel PopulateComplete()
→ RefreshView()
→ Populated(view)
```
Form 流:
```text
Form.SetValue(object?) → Value
Value changed → IFormItemAware.ValueChanged
Form.GetValue() → Value
Form.ClearValue() → Value = null
ValidateStatus → Status
FeedbackControl → FormFeedback
```
## 5. 生命周期与模板接入
`Mentions.OnApplyTemplate()` 必须解除旧模板 part 的事件订阅,再接入新 part:
- 旧 `_textArea` 解除 `CandidateOpenRequest` 和 `CandidateCloseRequest`。
- 旧 `_popup` 解除 `Opened` 和 `Closed`。
- `CandidateList` setter 解除旧列表的 `Commit`、`Cancel` 和 `ItemsSource`,再接入新列表。
- 新 `MentionTextArea` 设置 `Owner=this`。
- 新 `Popup` 设置 `OverlayInputPassThroughElement=_textArea`。
- 每次模板接入后重新调用 `ConfigurePopupPlacement()`。
`OnAttachedToVisualTree()` 订阅 TopLevel deactivation,窗口失活时关闭弹层。`OnDetachedFromVisualTree()` 释放该订阅。
Popup 打开时创建 `_subscriptionsOnOpen`,订阅自身 `IsVisible`、`IsEnabled` 和祖先可见性变化;Popup 关闭时释放这些订阅。控件不可见、禁用或祖先不可见时,必须关闭弹层。
`AsyncLoadDebounce` 变化时必须停止旧 `DispatcherTimer`、解除旧 Tick 订阅并清空字段,然后根据新值创建新 timer。
## 6. 交互与事件处理
键盘交互:
- `CandidateList.HandleKeyDown()` 优先处理弹层打开状态下的按键。
- `Escape` 取消当前候选,清空 `FilterValue` 并关闭弹层。
- `Enter` 在弹层打开时提交 `CandidateList.SelectedItem`。
- `F4` 切换 `IsDropDownOpen`。
- 弹层关闭时,`Down` 打开弹层;若该按键被 XY focus 导航占用,则不打开。
指针交互:
- `MentionTextArea` 的 pointer pressed/released 转交给 owner。
- 点击 popup 内部时保持弹层。
- 弹层打开时点击非文本区域会关闭弹层。
- 普通按下状态通过标准 `:pressed` 伪类表达。
候选提交:
```text
CandidateList.Commit
→ if SelectedItem is IMentionOption
→ InsertCandidateOption(option)
→ IsDropDownOpen = false
→ focus MentionTextArea
```
候选取消:
```text
CandidateList.Cancel / Escape
→ FilterValue = null
→ IsDropDownOpen = false
→ focus MentionTextArea
```
## 7. 内部算法与关键流程
### 7.1 触发符扫描
`MentionTextArea.CheckTriggerState()` 从当前 caret 向前扫描。遇到空白或控制字符立即停止。扫描到单字符 `TriggerPrefix` 后,控件记录触发字符、触发 bounds 和过滤文本,并进入 `TriggerState=true`。
`HandleCaretIndexChanged()` 使用相同规则计算 `FilterValue`。当 caret 离开 mention 片段或文本为空时,`FilterValue` 被置空。
### 7.2 Popup 定位
打开候选时,Mentions 通过 `MentionTextArea.GetTextPresenterBounds()` 获取文本 presenter 在输入框中的偏移,再叠加触发字符 bounds 计算 popup 偏移。
`Placement=Bottom` 时,popup 以触发字符下方为参考;`Placement=Top` 时,popup 以触发字符上方为参考。`ConfigurePopupPlacement()` 将 Mentions 自己的 placement 枚举映射为 Avalonia `PlacementMode`。
### 7.3 视图缓存与过滤
`OptionsSource` 变化时,控件构建 `_items` 缓存。若集合实现 `INotifyCollectionChanged`,控件使用弱订阅同步 add/remove/replace/reset。
`RefreshView()` 根据 `_items`、`FilterValue`、`Filter` 和 `FilterValueSelector` 生成 `_view`。过滤运行中再次触发刷新时,通过 `_cancelRequested` 退出当前过滤,避免过期结果覆盖新视图。
过滤匹配文本的选择顺序:
```text
FilterValueSelector(option)
else option.Header
else option.Value
else option.Key
```
### 7.4 异步加载
`TryPopulateAsync()` 在存在 `OptionsAsyncLoader` 时设置 `_asyncLoadCoordinator.Timeout`,将 `IsLoading` 置为 true,并启动 `PopulateAsync()`。
`PopulateAsync()` 通过 `AsyncSearchLoadCoordinator` 调用 loader。被跳过的旧结果不会更新 UI。成功结果触发 `OptionsLoaded`,再在 UI 线程写入 `OptionsSource` 并调用 `PopulateComplete()`。超时、取消和未知错误会映射到 `RpcStatusCode.Timeout`、`Cancelled` 或 `Unknown`,并通过 `OptionsLoaded` 通知业务层。
### 7.5 候选插入
`MentionTextArea.InsertMentionOption()` 先创建 undo/redo 快照,再从 caret 向前找到当前触发符位置,并选中触发符到 caret 的文本区间。插入值优先使用 `option.Value`,其次使用 `option.Header`。
`Split` 为单字符时,插入值以 split 和触发符包裹;没有 split 时,插入值使用空格分隔。该流程保证 mention 文本不会与前后普通文本直接粘连。
### 7.6 Popup 高度计算
`ConfigureMaxPopupHeight()` 使用:
```text
MaxPopupHeight = ItemHeight * DisplayCandidateCount
+ PopupContentPadding.Top
+ PopupContentPadding.Bottom
```
`ItemHeight` 和 `PopupContentPadding` 来自 `MentionsToken`,`DisplayCandidateCount` 是实例属性。
## 8. 资源、性能与 AOT 边界
Mentions 不依赖运行时反射发现模板结构。模板协作通过固定 template part、接口、事件和 owner 引用完成。
资源和生命周期边界:
- `_collectionChangeSubscription` 使用弱订阅,但 `OptionsSource` 变化时仍必须 dispose。
- `_subscriptionsOnOpen` 只在 popup 打开期间存在,popup 关闭时必须释放。
- `_deactivationSubscription` 必须在 detach 时释放。
- `_delayTimer` 更换时必须停止旧 timer 并解除 Tick。
- `CandidateList` 替换时必须清空旧列表 `ItemsSource`,避免旧视图保留。
- 异步加载通过 `AsyncSearchLoadCoordinator` 处理超时、取消和跳过旧结果,不在控件里保留业务任务。
AOT 边界:
- `MentionsToken` 通过 token generator 显式注册,生成 `MentionsTokenKind` 和 `MentionsTokenResourceExtension`。
- Gallery API / Token 表使用显式 ViewModel 数据。
- `OptionTemplate` 和 `EmptyIndicatorTemplate` 是 XAML 模板入口,不依赖运行时成员扫描。
## 9. 维护不变量
内部重构必须保持以下不变量:
- `Value` 和 `MentionTextArea.Text` 继续双向同步。
- `TriggerPrefix` 默认值、单字符识别和空白边界不能改变。
- `FilterValue` 必须随 caret 和文本变化更新。
- 打开候选弹层必须先触发 `CandidateTriggered`,再走打开和填充流程。
- 弹层打开/关闭必须尊重 `DropDownOpening` / `DropDownClosing` 的取消结果。
- 异步 loader 结果不能让过期请求覆盖当前候选视图。
- 候选提交必须使用 `MentionTextArea.InsertMentionOption()`,保留 undo/redo 和 selection 语义。
- 重新套用模板不能泄漏旧 part 的事件订阅。
- MentionsToken 只服务 popup 尺寸,不承载候选数据、过滤值、loading 或输入状态。
## 10. 测试与验证
验证范围:
- `MentionsShowCasePageTests` 覆盖 Gallery 页面结构、API 表、Token 表、示例 snapshot 和延迟加载规则。
- Gallery 走查基础用法、variant、异步加载、自定义触发符、disabled/read-only、placement、status、auto-size 和 clear 示例。
- 控件行为验证应覆盖触发符扫描、候选过滤、异步加载成功/失败/超时、键盘导航、候选提交、Form value 和模板重应用事件释放。
- Token 改动需验证 `MentionsTokenKind`、`MentionsTokenResourceExtension`、`MentionsTheme.axaml` 引用和 Gallery Token 表一致。
- 文档改动运行 `git diff --check`,并检查相对链接存在。
@@ -0,0 +1,254 @@
# Mentions 桌面版架构设计
本文档定义 `AtomUI.Desktop.Controls.Mentions` 的最新设计定位、公共契约、状态模型、视觉主题关系和兼容边界。通用控件研发约束见 [控件研发标准](../../../../engineering/control-development-guidelines.md),内部实现原理见 [Mentions 桌面版实现原理](implementation.md),Token 专项设计见 [Mentions Token 设计](token.md),设计和契约变化记录见 [Mentions Changelog](changelog.md)。
## 1. 控件定位
Mentions 是 AtomUI 桌面数据录入体系中的提及输入控件,用于在多行文本输入中识别触发前缀,展示候选项弹层,并把用户选择的候选项插入到文本中。它以 `TextArea` 为输入壳体,结合 `Popup`、`CandidateList`、同步/异步数据加载、过滤、Form 和 Token 体系,提供类似 `@user`、`#tag` 的文本提及体验。
Mentions 的职责是编辑单个字符串值,并在触发上下文中完成候选项选择和文本插入。它不负责远程服务协议、权限判断、富文本 token 渲染、结构化 mention 实体存储、消息发送或搜索结果管理。业务层需要保存结构化 mention 信息时,应在 `Value` 文本和业务数据之间建立自己的解析模型。
## 2. 设计语言
Mentions 的设计语言来自文本区域、触发符和候选弹层的组合。
| 维度 | 含义 | 典型表达 |
| --- | --- | --- |
| 文本语义 | 用户编辑包含 mention 标记的普通文本。 | `Value`、`DefaultValue`、`PlaceholderText`。 |
| 触发语义 | 输入触发符后进入候选选择上下文。 | `TriggerPrefix`、`CandidateTriggered`、`FilterValue`。 |
| 候选数据 | 候选项可来自本地集合或异步 loader。 | `OptionsSource`、`OptionsAsyncLoader`、`Populating`、`Populated`。 |
| 输入表面 | 输入框边框、背景和反馈状态。 | `StyleVariant`、`Status`、`SizeType`。 |
| 弹层位置 | 候选弹层相对触发位置向上或向下展开。 | `Placement=Top/Bottom`。 |
| 多行输入 | 输入区域可固定行数或自动高度。 | `Lines`、`MinLines`、`MaxLines`、`IsAutoSize`。 |
| 加载状态 | 异步候选加载时弹层展示 loading。 | `IsLoading`、`AsyncLoadDebounce`、`AsyncLoadTimeout`。 |
`Custom` 尺寸不是 Mentions 的第四套专属 Token。它沿用 TextArea 的 Custom size 规则:未显式设置时以 `Middle` 为视觉基线,用户可通过常规尺寸属性覆盖实际大小。
## 3. API 与契约模型
Mentions 的公共 API 由文本值、触发符、候选数据、过滤、弹层、输入表面和 Form 集成组成。属性不应被机械理解为独立功能;它们共同描述 mention 输入的状态机。
文本与输入 API:
| API | 类型 | 语义 |
| --- | --- | --- |
| `Value` | `string?` | 当前文本值,双向绑定到内部 `MentionTextArea.Text`。 |
| `DefaultValue` | `string?` | 初始化时 `Value` 为空时写入的默认文本。 |
| `PlaceholderText` | `string?` | 空文本状态的占位提示。 |
| `IsReadOnly` | `bool` | 只读状态,内部文本区域不可编辑。 |
| `IsAllowClear` / `ClearIcon` | `bool` / `PathIcon?` | 清除按钮入口和图标。 |
| `IsAutoFocus` | `bool` | 控件加载后请求内部文本区域焦点。 |
| `IsAutoSize` | `bool` | 内部 TextArea 根据内容自动调整高度。 |
| `Lines` / `MinLines` / `MaxLines` | `int` | 多行输入显示行数和自动高度边界。 |
候选与触发 API:
| API | 类型 | 语义 |
| --- | --- | --- |
| `TriggerPrefix` | `IList<string>?` | mention 触发前缀。当前触发识别只支持长度为 1 的字符串。默认值为 `["@"]`。 |
| `Split` | `string?` | 插入候选项时使用的分隔符。为空时默认用空格包裹 mention 文本。 |
| `OptionsSource` | `IEnumerable<IMentionOption>?` | 本地候选项集合。集合变更会同步到内部缓存和候选视图。 |
| `OptionsAsyncLoader` | `IMentionOptionsAsyncLoader?` | 异步候选加载器。存在时,触发候选弹层会优先使用异步加载。 |
| `OptionTemplate` | `IDataTemplate?` | 候选项显示模板。默认模板显示 `IMentionOption.Header`。 |
| `Filter` | `IValueFilter?` | 候选过滤器。为空时使用 contains 过滤。 |
| `FilterValueSelector` | `DefaultFilterValueSelector?` | 从候选项提取过滤文本的 selector。 |
| `DisplayCandidateCount` | `int` | 弹层可显示的候选项数量,用于计算最大高度。默认 `10`。 |
弹层与异步 API:
| API | 类型 | 语义 |
| --- | --- | --- |
| `Placement` | `MentionsPlacementMode` | 候选弹层相对触发位置的展开方向,默认 `Bottom`。 |
| `IsDropDownOpen` | `bool` | 候选弹层打开状态。 |
| `ShouldUseOverlayPopup` | `bool` | 是否使用 overlay popup 宿主,默认 `true`。 |
| `AsyncLoadDebounce` | `TimeSpan` | 异步加载前的防抖时间,默认 `TimeSpan.Zero`,值必须非负。 |
| `AsyncLoadTimeout` | `TimeSpan` | 异步加载超时时间,默认 `10` 秒。 |
| `IsLoading` | `bool` | 异步或事件填充过程中的内部 loading 状态,只读公开。 |
输入表面 API:
| API | 类型 | 语义 |
| --- | --- | --- |
| `SizeType` | `CustomizableSizeType` | 输入尺寸密度。 |
| `StyleVariant` | `InputControlStyleVariant` | 输入表面样式。 |
| `Status` | `InputControlStatus` | 输入反馈状态。 |
| `ContentLeftAddOn` / `ContentRightAddOn` | `object?` | 内部左右附加内容,传递给内部 `MentionTextArea`。 |
| `ContentLeftAddOnTemplate` / `ContentRightAddOnTemplate` | `IDataTemplate?` | 内部左右附加内容模板。 |
| `IsMotionEnabled` | `bool` | 内部输入壳体、候选列表和 popup 动效开关。 |
候选项契约:
| 类型 | 语义 |
| --- | --- |
| `IMentionOption.Key` | 候选项稳定 key,可作为过滤候选值回退。 |
| `IMentionOption.Header` | 候选项显示内容,默认模板显示该值。 |
| `IMentionOption.Value` | 插入文本时优先使用的值。 |
| `IMentionOption.IsEnabled` | 候选项启用状态,供候选列表视觉和选择逻辑使用。 |
事件契约:
| 事件 | 语义 |
| --- | --- |
| `CandidateTriggered` | 触发符被识别时触发,事件参数包含触发字符。 |
| `Populating` | 候选弹层准备填充前触发,可取消默认填充并由业务手动更新候选集合。 |
| `Populated` | 候选视图刷新后触发,事件参数包含只读候选视图。 |
| `OptionsLoaded` | 异步 loader 完成后触发,包含加载结果或错误状态。 |
| `DropDownOpening` / `DropDownClosing` | 弹层打开/关闭前触发,可取消。 |
| `DropDownOpened` / `DropDownClosed` | 弹层打开/关闭后触发。 |
稳定 template part:
| Template Part | 类型 | 职责 |
| --- | --- | --- |
| `PART_TextArea` | `MentionTextArea` | 文本输入、触发符识别、过滤值同步和候选插入。 |
| `PART_Popup` | `Popup` | 候选弹层宿主。 |
| `PART_CandidateList` | `ICandidateList` / `CandidateList` | 候选项展示、键盘导航、提交和取消。 |
| `PopupFrame` | `Border` | 候选弹层背景、圆角、宽度、高度和 padding。 |
| `LoadingIndicator` | `Spin` | 异步候选加载状态展示。 |
`EmptyIndicator`、`EmptyIndicatorTemplate`、`IsShowEmptyIndicator` 和 `EmptyIndicatorPadding` 是当前 public surface 的一部分;默认 `MentionsTheme` 尚未把这些属性绑定到候选弹层视觉结构中,维护时不能把它们描述为已经稳定生效的默认视觉契约。
## 4. 行为与状态模型
Mentions 的核心状态流:
```text
Value / DefaultValue
↓
MentionTextArea.Text
↓
Text input + caret position
↓
TriggerPrefix scan
↓
FilterValue + CandidateOpenRequest
↓
Populate / async load / filter
↓
CandidateList view
↓
Commit option
↓
Insert mention text back into Value
```
触发符识别从当前 `CaretIndex` 向前扫描,遇到空白或控制字符停止。只有长度为 1 的 `TriggerPrefix` 项会被识别。触发后,`MentionTextArea` 计算触发字符位置和过滤文本,并向外请求打开候选弹层。
候选填充优先级:
```text
OptionsAsyncLoader != null
→ async LoadAsync(FilterValue, token)
→ OptionsLoaded
→ OptionsSource = result.Data
→ PopulateComplete()
OptionsAsyncLoader == null
→ Populating(FilterValue)
→ if !Cancel PopulateComplete()
```
弹层状态优先级:
```text
Disabled / invisible / window deactivated
> DropDownClosing cancellation
> DropDownOpening cancellation
> IsDropDownOpen
> Candidate trigger state
```
键盘行为:
- 弹层打开时,按键优先交给 `CandidateList.HandleKeyDown()`。
- `Escape` 取消候选并关闭弹层。
- `Enter` 在弹层打开时提交当前候选。
- `F4` 切换弹层打开状态。
- 弹层关闭时,`Down` 可打开弹层,除非该按键被 XY focus 导航占用。
Form 集成以 `Value` 作为表单值。`NotifyValidateStatus` 将 Form 校验状态映射为 `Status=Error/Warning/Default`,Form feedback 控件传递给内部 `MentionTextArea`。
## 5. 视觉与主题模型
Mentions 的默认视觉由 `MentionsTheme.axaml` 和内部 TextArea 主题协作:
| 主题或资源 | 职责 |
| --- | --- |
| `MentionsTheme.axaml` | 根模板、`MentionTextArea`、popup、loading、候选列表和 MentionsToken 资源绑定。 |
| `TextAreaTheme.axaml` | 输入表面、placeholder、清除按钮、Form feedback、多行高度和状态视觉。 |
| `CandidateList` 主题 | 候选项容器、选中项、键盘导航和提交取消事件。 |
| `PopupHostToken` | popup 阴影、圆角和 anchor margin。 |
| `MentionsToken` | popup 内容 padding、候选项高度和最小宽度。 |
输入框本体不由 Mentions 自己重写边框和文本 presenter,而是通过内部 `MentionTextArea` 复用 TextArea 体系。Mentions 的控件级 Token 只服务候选弹层,不承载文本高度、输入边框、状态颜色或 Form feedback。
## 6. 控件家族或集成关系
Mentions 属于 Data Entry 文本输入控件家族,与 LineEdit/TextArea 共享输入表面、状态、尺寸、清除按钮和 Form 语义。
集成关系:
- `MentionTextArea` 继承 `TextArea`,复用多行输入能力,并增加 trigger scan、过滤值同步和候选插入。
- `Popup` 提供候选弹层宿主,可通过 `ShouldUseOverlayPopup` 选择 overlay 宿主。
- `CandidateList` 提供候选项展示、键盘导航、提交和取消。
- `AsyncSearchLoadCoordinator` 协调异步加载、跳过过期结果、取消和超时。
- `IFormItemAware` / `IFormItemFeedbackAware` 将 `Value`、校验状态和 feedback 接入 Form。
- `MentionsToken` 定义候选弹层尺寸相关主题值。
## 7. 兼容性不变量
维护 Mentions 时必须保持以下不变量:
- `Value` 必须继续与内部 `MentionTextArea.Text` 双向同步。
- `DefaultValue` 只在初始化且 `Value` 为空时写入。
- `TriggerPrefix` 默认值为 `["@"]`,当前触发识别只支持单字符前缀。
- 触发扫描不能跨越空白或控制字符。
- `OptionsSource` 集合变化必须同步到内部缓存和候选视图。
- `OptionsAsyncLoader` 存在时必须优先走异步加载,并通过 `OptionsLoaded` 报告结果。
- `AsyncLoadDebounce` 变化必须释放旧 timer,避免重复填充。
- 重新套用模板必须解绑旧 `MentionTextArea`、旧 `Popup` 和旧 `CandidateList` 事件。
- 弹层打开期间的可见性、启用状态和窗口失活必须关闭弹层。
- 候选提交必须通过 `MentionTextArea.InsertMentionOption()` 写回文本,保持 undo/redo 快照和 caret/selection 语义。
- MentionsToken 不承载输入文本、过滤值、候选数据、loading、Form 状态或交互状态。
## 8. 专项模型
### 8.1 候选过滤模型
Mentions 将 `OptionsSource` 缓存为 `List<IMentionOption>`,再根据 `FilterValue` 构造当前候选视图。默认过滤器是 contains。`FilterValueSelector` 存在时使用 selector 返回值;否则按 `Header`、`Value`、`Key` 的顺序选择过滤文本。
过滤过程通过 `_filterInAction` 和 `_cancelRequested` 避免重入时生成过期视图。刷新完成后,候选列表自动选中第一项。
### 8.2 异步加载模型
`OptionsAsyncLoader` 接收当前过滤值和 cancellation token。`AsyncSearchLoadCoordinator` 负责超时、取消、跳过过期结果和错误状态归一。成功结果会写入 `OptionsSource` 并继续走 `PopulateComplete()`;失败结果只通过 `OptionsLoaded` 通知业务层。
### 8.3 候选插入模型
提交候选时,控件优先使用 `option.Value`,其次使用 `option.Header`。插入逻辑回溯到当前 mention 触发符位置,选中触发符到当前 caret 的文本片段,然后按 `Split` 或空格规则生成插入文本。
`Split` 为单字符时,插入文本以 split 包裹;未设置 split 时,插入文本默认以空格分隔,避免 mention 与相邻普通文本粘连。
### 8.4 Popup 定位模型
`Placement=Bottom` 映射为 `PlacementMode.BottomEdgeAlignedLeft`,`Placement=Top` 映射为 `PlacementMode.TopEdgeAlignedLeft`。打开候选弹层时,控件根据触发字符 bounds 和 text presenter bounds 计算 popup 偏移,使候选弹层对齐当前触发位置。
## 9. 文档导航与验证策略
关联文档:
- [Mentions 桌面版实现原理](implementation.md)
- [Mentions Token 设计](token.md)
- [Mentions Changelog](changelog.md)
- [LineEdit Token 设计](../line-edit/token.md)
验证策略:
| 层次 | 验证内容 |
| --- | --- |
| Public API | `Value`、`TriggerPrefix`、候选数据、异步 loader、弹层、输入表面、Form 和事件契约。 |
| 状态 | 触发符识别、过滤值、弹层打开/关闭、loading、disabled、readonly、error、warning、auto-size。 |
| AXAML/Theme | `PART_TextArea`、`PART_Popup`、`PART_CandidateList`、loading、popup shadow、SizeType、StyleVariant。 |
| Token | `PopupContentPadding`、`OptionHeight`、`MinPopupWidth` 的默认值、生成 TokenKind、Gallery Token 表和主题引用。 |
| Gallery | 走查基础、variant、异步加载、自定义触发符、disabled/read-only、placement、status、auto-size 和 clear 示例。 |
| 回归测试 | `MentionsShowCasePageTests`、相关控件测试和 `git diff --check`。 |
@@ -0,0 +1,89 @@
# Mentions Token 设计
本文档定义 `AtomUI.Desktop.Controls.MentionsToken` 的专属语义、分类、使用范围和兼容边界。控件 Token 的通用分层、命名、计算、Theme Variables 边界和预设色规则见 [AtomUI 控件 Token 设计规范](../../../../engineering/control-token-guidelines.md)。Mentions 整体架构见 [Mentions 桌面版架构设计](overview.md),内部实现原理见 [Mentions 桌面版实现原理](implementation.md),设计和契约变化记录见 [Mentions Changelog](changelog.md)。
## 1. 定位
MentionsToken 是 Mentions 的组件级 Token scope,只描述候选弹层的结构尺寸。输入框本体由内部 `MentionTextArea` 复用 TextArea / AddOnDecoratedBox / SharedToken 体系;候选列表项的选择、hover、disabled 和文本状态由 CandidateList 主题处理。
MentionsToken 不承载以下状态:
- `Value`、`FilterValue`、`TriggerPrefix`、`OptionsSource`、`OptionsAsyncLoader` 等数据状态。
- `IsDropDownOpen`、`IsLoading`、`IsReadOnly`、`IsAutoSize`、`Status` 等运行状态。
- 候选项 selected、hover、disabled、commit、cancel 等交互状态。
- Popup 当前 offset、placement、实际高度或异步加载结果。
## 2. Token 分类
### 2.1 候选弹层 padding Token
- `PopupContentPadding`
该 Token 控制 `PopupFrame` 内部 padding,并参与 `MaxPopupHeight` 计算。默认值为 `SharedToken.UniformlyPaddingXXS / 2` 的四边等距 padding。
### 2.2 候选项高度 Token
- `OptionHeight`
该 Token 表示候选项行高基准,默认值为 `SharedToken.ControlHeight`。Mentions 使用它结合 `DisplayCandidateCount` 计算候选弹层最大高度。
### 2.3 候选弹层最小宽度 Token
- `MinPopupWidth`
该 Token 控制候选弹层最小宽度,默认值为 `120`。实际弹层宽度仍受 popup placement、候选内容和布局约束影响。
## 3. 控件专项模型中的 Token 使用
MentionsToken 使用路径:
```text
SharedToken
↓
MentionsToken
↓
MentionsTheme.axaml
↓
PopupFrame + MaxPopupHeight + CandidateList viewport
```
主题使用点:
- `ItemHeight="{atom:MentionsTokenResource OptionHeight}"`
- `PopupContentPadding="{atom:MentionsTokenResource PopupContentPadding}"`
- `MinPopupWidth="{atom:MentionsTokenResource MinPopupWidth}"`
`MaxPopupHeight` 不是 Token。它由 `ItemHeight`、`DisplayCandidateCount` 和 `PopupContentPadding` 在控件实例上计算,属于运行时布局结果。
## 4. 控件家族影响
MentionsToken 只影响 Mentions 候选弹层,不直接影响 AutoComplete、Select、TreeSelect、ComboBox 或普通 TextArea。虽然这些控件也有 popup padding、option height 和 min popup width 语义,但它们各自拥有独立 Token scope。
调整 MentionsToken 时必须评估:
- `MentionsTheme.axaml`
- `MentionsShowCase` 的 Design Token 表
- `MentionsShowCasePageTests`
- 候选弹层 loading、候选列表可视高度和 popup 最小宽度
## 5. 兼容性要求
MentionsToken 属于 Mentions 主题契约。即使 `MentionsToken` 是 internal 类型,生成的 `MentionsTokenKind`、AXAML resource 使用点和 Gallery Token 表已经形成稳定依赖。
Token 变更要求:
- 不擅自重命名或删除 `PopupContentPadding`、`OptionHeight`、`MinPopupWidth`。
- 不把实例数据、过滤状态、loading 状态、候选选择状态或 Form 状态迁移为 Token。
- 不在 MentionsToken 中复制 TextArea 输入边框、字体、清除按钮或校验状态 Token。
- 不把 `DisplayCandidateCount` 变成 Token;它是实例级行为属性。
- 需要破坏性变更时,必须先说明影响范围并获得授权。
## 6. 验证策略
| 改动类型 | 验证要求 |
| --- | --- |
| 修改 `PopupContentPadding` | 验证 popup 内边距、loading 布局、候选项裁剪和 `MaxPopupHeight`。 |
| 修改 `OptionHeight` | 验证候选项高度、键盘选中项可见性和 `DisplayCandidateCount` 对弹层高度的影响。 |
| 修改 `MinPopupWidth` | 验证短候选、长候选、Top/Bottom placement 和 Gallery 示例弹层宽度。 |
| 删除或重命名 Token | 默认不允许;如获授权,需同步 AXAML 引用、生成文件、Gallery Token 表和控件文档。 |
| 文档改动 | 运行 `git diff --check`,检查文档链接存在。 |
@@ -13,6 +13,10 @@
- [SearchEdit 桌面版架构设计](search-edit/overview.md)
- [SearchEdit 桌面版实现原理](search-edit/implementation.md)
- [SearchEdit Changelog](search-edit/changelog.md)
- [Mentions 桌面版架构设计](mentions/overview.md)
- [Mentions 桌面版实现原理](mentions/implementation.md)
- [Mentions Token 设计](mentions/token.md)
- [Mentions Changelog](mentions/changelog.md)
- [NumericUpDown 桌面版架构设计](numeric-up-down/overview.md)
- [NumericUpDown 桌面版实现原理](numeric-up-down/implementation.md)
- [NumericUpDown Token 设计](numeric-up-down/token.md)
@@ -67,6 +67,9 @@
<Setter Property="ClipToBounds" Value="True" />
<Setter Property="ResizeIndicatorLineBrush"
Value="{atom:TextAreaTokenResource ResizeIndicatorLineColor}" />
<Style Selector="^ /template/ ContentPresenter#PART_ContentLeftAddOn">
<Setter Property="Margin" Value="{atom:AddOnDecoratedBoxTokenResource LeftInnerAddOnMargin}" />
</Style>
<Style Selector="^[SizeType=Large]">
<Style Selector="^ /template/ Border#PART_ContentFrame">
<Setter Property="Padding" Value="0" />
@@ -1,7 +1,10 @@
using System.Linq;
using System.Reflection;
using AtomUI.Desktop.Controls.DesignTokens;
using AtomUI.Theme.Styling;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Presenters;
using Avalonia.Threading;
using Avalonia.VisualTree;
using Shouldly;
@@ -118,6 +121,33 @@ public class TextAreaResizeTests
}
}
[Fact]
public void Inner_Left_Content_Uses_Token_Margin_Before_Text_Content()
{
var textArea = new AtomUI.Desktop.Controls.TextArea
{
Width = 240,
Height = 80,
InnerLeftContent = "L",
Text = "content",
IsMotionEnabled = false
};
var window = CreateWindow(textArea);
try
{
var leftAddOn = FindTemplatePart<ContentPresenter>(textArea, "PART_ContentLeftAddOn");
var expectedMargin = GetThemeResource<Thickness>(AddOnDecoratedBoxTokenKind.LeftInnerAddOnMargin);
leftAddOn.Margin.ShouldBe(expectedMargin);
leftAddOn.Margin.Right.ShouldBeGreaterThan(0);
}
finally
{
window.Close();
}
}
private static AvaloniaWindow CreateWindow(Control content)
{
var window = new AvaloniaWindow
@@ -139,6 +169,25 @@ public class TextAreaResizeTests
.Single(item => item.Name == "PART_ResizeHandle");
}
private static T FindTemplatePart<T>(Control control, string name)
where T : Control
{
var part = control.GetVisualDescendants()
.OfType<T>()
.SingleOrDefault(item => item.Name == name);
part.ShouldNotBeNull();
return part!;
}
private static T GetThemeResource<T>(object key)
{
var application = Application.Current;
application.ShouldNotBeNull();
application!.TryGetResource(key, application.ActualThemeVariant, out var value).ShouldBeTrue();
value.ShouldBeAssignableTo<T>();
return (T)value!;
}
private static void InvokeInternal(object instance, string methodName, params object[] args)
{
var method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic);
@@ -1,2 +1,2 @@
count: 9
sha256: 3aaf40a797aa4436a87b2946bd2c88921db4e20d64dadc73e9dbf66b33a4e3c6
count: 10
sha256: eb2a8cb7667be33115350ea32d96696aa3f6edd381812f81d26d93679fb3a91e
@@ -46,13 +46,20 @@ public class MentionsShowCasePageTests
source.ShouldContain("Text=\"{gallery:MentionsShowCaseLangResource PageDescription}\"");
source.ShouldContain("HorizontalAlignment=\"Left\"");
source.ShouldContain("<gallery:ShowCaseItem");
CountOccurrences(source, "IsDeferredContentEnabled=\"True\"").ShouldBe(9);
CountOccurrences(source, "<gallery:ShowCaseItem.DeferredContentTemplate>").ShouldBe(9);
CountOccurrences(source, "DataTemplate x:DataType=\"vm:MentionsViewModel\"").ShouldBe(9);
CountOccurrences(source, "IsDeferredContentEnabled=\"True\"").ShouldBe(10);
CountOccurrences(source, "<gallery:ShowCaseItem.DeferredContentTemplate>").ShouldBe(10);
CountOccurrences(source, "DataTemplate x:DataType=\"vm:MentionsViewModel\"").ShouldBe(10);
source.ShouldContain("MentionsShowCaseLangResource BasicTitle");
source.ShouldContain("MentionsShowCaseLangResource SizeTypeTitle");
source.ShouldContain("MentionsShowCaseLangResource SizeTypeDescription");
source.ShouldContain("MentionsShowCaseLangResource VariantsTitle");
source.ShouldContain("MentionsShowCaseLangResource CustomizeTriggerTokenTitle");
source.ShouldContain("MentionsShowCaseLangResource WithClearIconTitle");
source.ShouldContain("SizeType=\"Large\"");
source.ShouldContain("SizeType=\"Middle\"");
source.ShouldContain("SizeType=\"Small\"");
source.ShouldContain("SizeType=\"Custom\"");
source.ShouldContain("MentionsShowCaseLangResource P2PlaceholderTextCustom");
source.ShouldNotContain("<atom:TabControl");
source.ShouldNotContain("<atom:DataGrid");
source.ShouldNotContain(">Gallery<");
@@ -91,7 +98,7 @@ public class MentionsShowCasePageTests
codeBehindSource.ShouldNotContain("AutoSizeMentions");
codeBehindSource.ShouldNotContain("ClearableMentions1");
codeBehindSource.ShouldNotContain("ClearableMentions2");
CountOccurrences(pageSource, "OptionsSource=\"{Binding BasicMentionOptions}\"").ShouldBe(9);
CountOccurrences(pageSource, "OptionsSource=\"{Binding BasicMentionOptions}\"").ShouldBe(13);
pageSource.ShouldContain("OptionsAsyncLoader=\"{Binding MentionOptionAsyncLoader}\"");
pageSource.ShouldContain("TriggerPrefix=\"{Binding MentionTriggers}\"");
pageSource.ShouldContain("CandidateTriggered=\"HandleCandidateTriggered\"");
@@ -145,6 +152,12 @@ public class MentionsShowCasePageTests
source.ShouldContain("ScenarioExamples");
source.ShouldContain("ScenarioApi");
source.ShouldContain("ScenarioDesignToken");
source.ShouldContain("SizeTypeTitle");
source.ShouldContain("SizeTypeDescription");
source.ShouldContain("P2PlaceholderTextLarge");
source.ShouldContain("P2PlaceholderTextMiddle");
source.ShouldContain("P2PlaceholderTextSmall");
source.ShouldContain("P2PlaceholderTextCustom");
source.ShouldContain("PageSubtitle");
source.ShouldContain("InfoNamespaceLabel");
source.ShouldContain("ApiPropertyOptionsSource");