From 306c315638002ea0bde79904bffdb4ee58d4cfcb Mon Sep 17 00:00:00 2001 From: chinboy Date: Mon, 3 Aug 2026 15:59:49 +0800 Subject: [PATCH] feat(OverflowTip): implement internal text viewport metrics for TextBox and TextArea to enhance tooltip visibility handling --- .../desktop/data-display/tooltip/changelog.md | 6 + .../data-display/tooltip/implementation.md | 29 +++ .../desktop/data-entry/line-edit/changelog.md | 6 + .../data-entry/line-edit/implementation.md | 36 +++- src/AtomUI.Desktop.Controls/Input/TextArea.cs | 26 ++- .../Input/TextAreaDecoratedBox.cs | 23 ++- src/AtomUI.Desktop.Controls/Input/TextBox.cs | 18 ++ .../Input/TextViewportMetrics.cs | 60 ++++++ .../Tooltip/OverflowTip.cs | 10 +- .../Tooltip/OverflowTipTests.cs | 181 ++++++++++++++++++ 10 files changed, 383 insertions(+), 12 deletions(-) create mode 100644 src/AtomUI.Desktop.Controls/Input/TextViewportMetrics.cs diff --git a/docs/controls/desktop/data-display/tooltip/changelog.md b/docs/controls/desktop/data-display/tooltip/changelog.md index 2a9bac055..a4c4d24fb 100644 --- a/docs/controls/desktop/data-display/tooltip/changelog.md +++ b/docs/controls/desktop/data-display/tooltip/changelog.md @@ -2,6 +2,12 @@ 本文档记录 Tooltip 控件级设计、API、主题契约、Token 和实现结构的变化。它不替代仓库根目录 `CHANGELOG.md`,也不作为正式版本发布说明。 +## 2026-08-03 + +- Architecture + - Replace OverflowTip's dependency on TextBox visual descendants with an internal text viewport metric published by the input owner. + - Define reactive viewport updates, template reapply cleanup and the compatibility fallback for third-party Avalonia TextBox templates. + ## 2026-06-26 - Docs diff --git a/docs/controls/desktop/data-display/tooltip/implementation.md b/docs/controls/desktop/data-display/tooltip/implementation.md index 5fc54a1d5..35960340a 100644 --- a/docs/controls/desktop/data-display/tooltip/implementation.md +++ b/docs/controls/desktop/data-display/tooltip/implementation.md @@ -15,6 +15,7 @@ - `src/AtomUI.Desktop.Controls/Tooltip/ToolTipPseudoClass.cs` - `src/AtomUI.Desktop.Controls/Tooltip/ToolTipService.cs` - `src/AtomUI.Desktop.Controls/Tooltip/ToolTipToken.cs` +- `src/AtomUI.Desktop.Controls/Tooltip/OverflowTip.cs` 职责边界: @@ -28,6 +29,7 @@ - `ToolTip`:控件核心或内部协作类型,维护 public surface 与主题可观察行为。 - `ToolTipPseudoClass`:控件核心或内部协作类型,维护 public surface 与主题可观察行为。 - `ToolTipToken`:控件 Token scope,负责从全局 token 派生控件语义变量。 +- `OverflowTip`:附加到文本展示节点的共享溢出提示 behavior,只管理自己写入的 tooltip,并在文本实际超出可见宽度时启用提示。 核心协作规则: @@ -60,6 +62,17 @@ Public API / ItemsSource / Command / Event - 伪类和 internal state 必须从单一 owner 推导,避免双向同步导致循环更新。 - overview.md 的 API 契约说明应与源码实际状态流一致。 +`OverflowTip` 的状态流: + +```text +attached properties + target text/font + -> owner bounds or published text viewport metric + -> text width comparison + -> owned ToolTip.Tip / placement / delay +``` + +用户显式设置的 `ToolTip.Tip` 始终优先。`OverflowTip` 只清理由自己写入的 tip,不参与目标控件的选择、输入或模板状态。 + ## 5. 生命周期与模板接入 生命周期规则: @@ -69,6 +82,8 @@ Public API / ItemsSource / Command / Event - 控件卸载、弹层关闭、窗口关闭、集合替换或 container recycle 时释放事件订阅和资源宿主。 - DynamicResource、TokenResourceBinder 或 C# binding 必须有明确 owner 和释放点。 - Browser 和 Desktop 宿主下的主题加载顺序不得影响 public API 语义。 +- `OverflowTipState` 对 owner bounds、文本、字体、tooltip 和内部 text viewport metric 的订阅必须由同一个 disposable owner 管理;禁用 behavior 时统一释放。 +- TextBox/TextArea 的 template part 由输入控件自己获取和管理。`OverflowTip` 不得调用 `GetVisualDescendants()`、查找 `PART_TextPresenter` 或持有输入控件的 presenter/scroller。 稳定 template part 接入点: @@ -98,11 +113,23 @@ Tooltip 的交互事件应从输入源收敛到控件级语义事件: 实现文档不逐行解释私有方法。若某个私有算法成为稳定维护入口,应在本节补充算法不变量,而不是把代码复述为说明书。 +### 7.1 OverflowTip 宽度解析 + +宽度解析按稳定能力而不是模板类型判断: + +1. AtomUI TextBox/TextArea 已发布内部 text viewport metric 时,使用该有效宽度并订阅后续变化。 +2. metric 为 `NaN` 时表示 viewport 已接入但布局尚未完成,此时不显示 tip,等待度量更新,不使用瞬时 owner fallback。 +3. 原生或第三方 Avalonia TextBox 没有发布 metric 时,使用 `Bounds.Width - horizontal Padding` 作为兼容降级路径。 +4. TextBlock 使用自身 bounds 扣除 horizontal padding;其他受支持展示节点使用 owner bounds。 + +内部 metric 只输出宽度,不暴露输入模板对象。输入控件模板改变时,由输入控件在自己的所有权边界内更新计算方式,`OverflowTip` 算法不随模板结构修改。 + ## 8. 资源、性能与 AOT 边界 资源和 AOT 约束: - 不通过运行时反射扫描 public API、Token 或 Gallery 示例数据。 +- 不通过 VisualTree 遍历或反射发现其他控件的 template part;输入文本可视宽度只消费输入控件发布的内部 metric。 - 不把可静态声明的模板结构迁移到 C# 动态创建。 - 异步加载、上传、弹层和窗口生命周期必须能取消或释放。 - 缓存对象必须与控件、窗口、弹层或数据 owner 生命周期一致。 @@ -123,6 +150,7 @@ Tooltip 的交互事件应从输入源收敛到控件级语义事件: - 旧 template part、事件订阅、Popup/Flyout/Window host 和 collection view 的释放路径。 - Light/Dark、Browser/Desktop 和不同 SizeType 下的主题一致性。 - 控件文档、源码 public surface、Token 类型或生成数据与源码契约的一致性。 +- TextBox 内部按钮、feedback、padding 或模板重套用改变有效 viewport 时,OverflowTip 必须由度量通知重新判断,不能依赖 owner Bounds 恰好变化。 ## 10. 测试与验证 @@ -133,3 +161,4 @@ Tooltip 的交互事件应从输入源收敛到控件级语义事件: - DataGrid 相关变更运行 `tests/AtomUI.Desktop.Controls.DataGrid.Tests`。 - Gallery 示例或源码片段变更运行 `tests/AtomUIGallery.Tests`。 - AOT、生成器或动态数据路径变更按 Gallery NativeAOT 发布流程验证。 +- `OverflowTip` 回归测试覆盖 TextBox 精确 viewport、内部 viewport 独立变化、模板重套用、第三方 TextBox fallback 和禁止外部 `PART_TextPresenter` 查询。 diff --git a/docs/controls/desktop/data-entry/line-edit/changelog.md b/docs/controls/desktop/data-entry/line-edit/changelog.md index 3bf3c2778..a77afb465 100644 --- a/docs/controls/desktop/data-entry/line-edit/changelog.md +++ b/docs/controls/desktop/data-entry/line-edit/changelog.md @@ -8,6 +8,12 @@ - 不记录临时讨论、纯格式化或没有长期价值的实现细节。 - 架构文档始终描述最新设计状态;历史变化记录在本文档。 +## 2026-08-03 + +- Architecture + - Define an internal text viewport metric owned by TextBox/TextArea so consumers can react to effective text width without traversing input templates. + - Define viewport, padding, presenter margin and template reapply lifecycle as the single source of truth for input text width. + ## 2026-06-26 - Docs diff --git a/docs/controls/desktop/data-entry/line-edit/implementation.md b/docs/controls/desktop/data-entry/line-edit/implementation.md index 9c2be75d3..c08f4f4f0 100644 --- a/docs/controls/desktop/data-entry/line-edit/implementation.md +++ b/docs/controls/desktop/data-entry/line-edit/implementation.md @@ -17,6 +17,7 @@ LineEdit 家族的实现以 Avalonia `TextBox` 为文本编辑内核,AtomUI - `src/AtomUI.Desktop.Controls/Input/InputTextPresenter.cs`:输入文本 presenter,处理 Avalonia 12 selection foreground 缓存刷新。 - `src/AtomUI.Desktop.Controls/Input/SearchEditDecoratedBox.cs`:SearchEdit 输入壳体与搜索按钮协作。 - `src/AtomUI.Desktop.Controls/Input/TextAreaDecoratedBox.cs`:TextArea 输入壳体、scroll viewer 和 resize 相关协作。 +- `src/AtomUI.Desktop.Controls/Input/TextViewportMetrics.cs`:输入控件向同程序集消费方发布有效文本 viewport 宽度的内部度量契约。 - `src/AtomUI.Desktop.Controls/Input/ResizeHandle.cs`:TextArea resize 拖拽入口。 - `src/AtomUI.Desktop.Controls/Input/TextBoxToken.cs`:基础 TextBox 边框、padding、hover/focus 和 shadow Token。 - `src/AtomUI.Desktop.Controls/Input/LineEditToken.cs`:单行输入字号 Token。 @@ -33,6 +34,8 @@ LineEdit 家族的实现以 Avalonia `TextBox` 为文本编辑内核,AtomUI `TextArea` 独立继承 Avalonia `TextBox`,因为多行输入需要不同模板、scroll viewer 接入、固定行数测量和 resize 流程。它复用 LineEdit 家族的状态、尺寸、清除、字数统计和 Form 模型。 +`TextViewportMetrics` 是不对用户公开的响应式度量通道。TextBox/TextArea 仍是自身模板结构和文本可视区域的唯一 owner;消费方只能读取发布后的有效宽度,不能获得 `TextPresenter`、`ScrollViewer` 或 NameScope,也不能反向遍历输入控件的 VisualTree。 + ## 4. 状态与数据流 文本值流: @@ -71,6 +74,18 @@ ValidateStatus → Warning/Success/Validating extension state Form feedback control → FormFeedback + IsFormFeedbackVisible ``` +文本 viewport 度量流: + +```text +TextBox/TextArea own template parts + ↓ +ScrollViewer.Viewport / Padding + TextPresenter.Margin + ↓ +TextViewportMetrics.ViewportWidth + ↓ +OverflowTip and other internal consumers +``` + 状态伪类: - native validation error 设置 Avalonia `:error`。 @@ -91,6 +106,8 @@ Form feedback control → FormFeedback + IsFormFeedbackVisible - `LineEdit` / `TextArea` 的 `_contentRightAddOnBindings` 在重新套用模板时先 dispose,再绑定 clear/reveal/form/inner-right/count presenter。 - `SearchEdit` 获取 `SearchEditDecoratedBox` 后设置 `OwningSearchEdit`,由 decorated box 回调搜索事件。 - `TextArea` 获取 `TextAreaDecoratedBox` 后设置 `Owner`,获取 `ResizeHandle` 后设置 `Owner`。 +- `TextBox` / `TextArea` 每次套用模板时先释放旧文本 viewport source 订阅,再由当前模板中的 scroll viewer 和 text presenter 发布新的有效宽度;旧 part 后续变化不得再影响控件度量。 +- `TextAreaDecoratedBox` 只在自己的 `OnApplyTemplate` 中获取 `PART_ScrollViewer`,再通过直接 owner 协作把 source 交给 `TextArea`。`TextArea` 和外部 behavior 都不得进入 decorated box 的模板查找该 part。 - Form feedback 订阅在 `FormFeedback` 变化时替换,在 detach 时释放。 模板 part 属于主题契约。需要调整内部视觉时,应优先在 AXAML 中维护静态模板和 selector,不把 clear/reveal/search/resize 视觉搬到 C# 动态创建。 @@ -142,6 +159,20 @@ TextArea resize 以控件当前 `Bounds.Height` 为起点。拖拽时高度被 `InputTextPresenter` 在 `SelectionStart`、`SelectionEnd`、`SelectionForegroundBrush` 和 `ShowSelectionHighlight` 变化时调用 `InvalidateTextLayout()`,用于避免 Avalonia 12 文本 run 缓存导致选中文本前景色滞留。 +### 7.6 文本 viewport 宽度 + +文本是否在输入表面内可见,必须以 scroll viewer 的 viewport 为起点,不能使用会随文本内容扩展的 `TextPresenter.Bounds.Width`,也不能由消费方使用 `GetVisualDescendants()` 查找 `PART_TextPresenter`。 + +当前模板的有效宽度按以下语义计算: + +```text +effective width = ScrollViewer.Viewport.Width + - ScrollViewer horizontal padding + - TextPresenter horizontal margin +``` + +外壳 padding、border、左右 AddOn、clear/reveal/feedback/count 等布局占用应由 scroll viewer 的实际 viewport 或 padding 反映,不能在 `OverflowTip` 中复制控件模板补偿。内部度量使用三态:`null` 表示当前模板没有精确 source、`NaN` 表示 source 已接入但布局尚未产生有效宽度、正数表示可消费的有效宽度。 + ## 8. 资源、性能与 AOT 边界 LineEdit 家族不依赖运行时反射发现模板结构。跨模板协作通过稳定 part、`TemplateBinding`、`BindUtils.RelayBind`、接口和 owner 引用完成。 @@ -151,6 +182,7 @@ LineEdit 家族不依赖运行时反射发现模板结构。跨模板协作通 - `_contentRightAddOnBindings` 必须在重新套用模板前 dispose。 - `_feedbackStatusSubscription` 必须在 `FormFeedback` 变化和 detach 时释放。 - clear button click 订阅必须在新模板接入前解绑旧按钮。 +- 文本 viewport source 的 `Viewport`、`Padding` 和 presenter `Margin` 订阅必须由 TextBox/TextArea 持有,并在模板重套用时成组替换。 - TextArea resize 不创建全局订阅;拖拽状态保存在控件实例字段中。 - Token 只表达尺寸、字体、padding 和 resize 视觉语义,不承载文本值、清除状态、Form 状态或搜索运行状态。 @@ -171,7 +203,8 @@ AOT 边界: - `SearchEdit.IsOperating=true` 必须阻止重复搜索事件。 - `TextArea` 的 fixed lines、auto-size 和 resize 不互相覆盖高度状态。 - 重新套用模板不能泄漏旧按钮 click、旧 binding 或旧 Form feedback 订阅。 -- TextPresenter margin 是输入模板视觉契约;宽度计算问题应在使用方模板或测量源头解决,不在业务控件中加入隐藏补偿。 +- TextPresenter margin 是输入模板视觉契约;文本有效宽度由输入控件在模板所有权边界内统一计算并发布,不在业务控件或消费 behavior 中加入隐藏补偿。 +- 输入控件不得向内部消费方暴露 `TextPresenter` / `ScrollViewer` 实例;模板结构变化只能影响输入控件自己的度量实现。 ## 10. 测试与验证 @@ -184,4 +217,5 @@ AOT 边界: - AddOn:外部 left/right AddOn、内部 left/right content、Form feedback、字数统计顺序。 - SearchEdit:Default/Primary 搜索按钮、loading、搜索事件和 disabled。 - TextArea:Lines、MinLines、MaxLines、IsAutoSize、IsResizable、resize 边界和字数统计。 +- 文本 viewport:clear/feedback/addon 改变内部可用宽度但 owner Bounds 不变时发布新值;模板重套用后旧 source 不再发布;TextArea scroller padding 和 presenter margin 被正确扣除。 - 文档改动运行 `git diff --check`,并检查相对链接存在。 diff --git a/src/AtomUI.Desktop.Controls/Input/TextArea.cs b/src/AtomUI.Desktop.Controls/Input/TextArea.cs index 482bc354c..05b35bf46 100644 --- a/src/AtomUI.Desktop.Controls/Input/TextArea.cs +++ b/src/AtomUI.Desktop.Controls/Input/TextArea.cs @@ -207,12 +207,14 @@ public class TextArea : AvaloniaTextBox, #endregion private ScrollViewer? _scrollViewer; + private TextAreaDecoratedBox? _textAreaDecoratedBox; private IconButton? _clearButton; private ResizeHandle? _resizeHandle; private CompositeDisposable? _contentRightAddOnBindings; private IDisposable? _feedbackStatusSubscription; private TextPresenter? _textPresenter; private IDisposable? _preeditTextSubscription; + private IDisposable? _textViewportSubscription; private double? _originHeight; // 拖动改变高度的初始值 private double _minResizeHeight; // 拖动改变高度时允许的最小 TextArea.Height private double _maxResizeHeight; // 拖动改变高度时允许的最大 TextArea.Height @@ -289,10 +291,20 @@ public class TextArea : AvaloniaTextBox, protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { base.OnApplyTemplate(e); - var decorator = e.NameScope.Find(AddOnDecoratedBox.AddOnDecoratedBoxPart); - if (decorator != null) + + _textViewportSubscription?.Dispose(); + _textViewportSubscription = null; + TextViewportMetrics.SetViewportWidth(this, null); + if (_textAreaDecoratedBox is not null) { - decorator.Owner = this; + _textAreaDecoratedBox.Owner = null; + } + + SetupTextPresenterPreeditSubscription(e); + _textAreaDecoratedBox = e.NameScope.Find(AddOnDecoratedBox.AddOnDecoratedBoxPart); + if (_textAreaDecoratedBox is not null) + { + _textAreaDecoratedBox.Owner = this; } if (_clearButton != null) @@ -314,7 +326,6 @@ public class TextArea : AvaloniaTextBox, UpdatePseudoClasses(); ConfigureEffectiveShowClearButton(); - SetupTextPresenterPreeditSubscription(e); ConfigurePlaceholderTextVisibility(); HandleInputChanged(Text); SetupContentRightAddOnBindings(e); @@ -381,6 +392,13 @@ public class TextArea : AvaloniaTextBox, { _scrollViewer = scrollViewer; this.SetScrollViewer(scrollViewer); + SetupTextViewportMetrics(scrollViewer); + } + + private void SetupTextViewportMetrics(ScrollViewer scrollViewer) + { + _textViewportSubscription?.Dispose(); + _textViewportSubscription = TextViewportMetrics.PublishViewportWidth(this, scrollViewer, _textPresenter); } private void ValidateLinesValue(int lines) diff --git a/src/AtomUI.Desktop.Controls/Input/TextAreaDecoratedBox.cs b/src/AtomUI.Desktop.Controls/Input/TextAreaDecoratedBox.cs index 9d19dbf4b..96d66924f 100644 --- a/src/AtomUI.Desktop.Controls/Input/TextAreaDecoratedBox.cs +++ b/src/AtomUI.Desktop.Controls/Input/TextAreaDecoratedBox.cs @@ -102,8 +102,27 @@ internal class TextAreaDecoratedBox : AddOnDecoratedBox AffectsRender(ResizeIndicatorLineBrushProperty, IsResizableProperty); } - internal ScrollViewer? ScrollViewer { get; set; } - internal TextArea? Owner { get; set; } + internal ScrollViewer? ScrollViewer { get; private set; } + + private TextArea? _owner; + + internal TextArea? Owner + { + get => _owner; + set + { + if (ReferenceEquals(_owner, value)) + { + return; + } + + _owner = value; + if (_owner is not null && ScrollViewer is not null) + { + _owner.NotifyScrollViewerCreated(ScrollViewer); + } + } + } private Control? _rightContentAddOn; private IPen? _resizeIndicatorPen; diff --git a/src/AtomUI.Desktop.Controls/Input/TextBox.cs b/src/AtomUI.Desktop.Controls/Input/TextBox.cs index 010585706..5659797ae 100644 --- a/src/AtomUI.Desktop.Controls/Input/TextBox.cs +++ b/src/AtomUI.Desktop.Controls/Input/TextBox.cs @@ -198,6 +198,7 @@ public class TextBox : AvaloniaTextBox, private IDisposable? _feedbackStatusSubscription; private TextPresenter? _textPresenter; private IDisposable? _preeditTextSubscription; + private IDisposable? _textViewportSubscription; static TextBox() { @@ -311,6 +312,7 @@ public class TextBox : AvaloniaTextBox, _clearButton.Click += HandleClearButtonClicked; } SetupTextPresenterPreeditSubscription(e); + SetupTextViewportMetrics(e); ConfigureEffectiveShowClearButton(); ConfigurePlaceholderTextVisibility(); HandleInputChanged(Text); @@ -329,6 +331,22 @@ public class TextBox : AvaloniaTextBox, } } + private void SetupTextViewportMetrics(TemplateAppliedEventArgs e) + { + _textViewportSubscription?.Dispose(); + _textViewportSubscription = null; + + var scrollViewer = e.NameScope.Find("PART_ScrollViewer") ?? + e.NameScope.Find("ScrollViewer"); + if (scrollViewer is null) + { + TextViewportMetrics.SetViewportWidth(this, null); + return; + } + + _textViewportSubscription = TextViewportMetrics.PublishViewportWidth(this, scrollViewer, _textPresenter); + } + private void ConfigurePlaceholderTextVisibility() { SetCurrentValue(IsPlaceholderTextVisibleProperty, diff --git a/src/AtomUI.Desktop.Controls/Input/TextViewportMetrics.cs b/src/AtomUI.Desktop.Controls/Input/TextViewportMetrics.cs new file mode 100644 index 000000000..32f7d654a --- /dev/null +++ b/src/AtomUI.Desktop.Controls/Input/TextViewportMetrics.cs @@ -0,0 +1,60 @@ +using System.Reactive.Disposables; +using Avalonia; +using Avalonia.Controls.Presenters; +using Avalonia.Layout; + +namespace AtomUI.Desktop.Controls; + +internal static class TextViewportMetrics +{ + internal static readonly AttachedProperty ViewportWidthProperty = + AvaloniaProperty.RegisterAttached( + "ViewportWidth", + typeof(TextViewportMetrics)); + + internal static double? GetViewportWidth(Avalonia.Controls.TextBox textBox) + { + return textBox.GetValue(ViewportWidthProperty); + } + + internal static void SetViewportWidth(Avalonia.Controls.TextBox textBox, double? value) + { + textBox.SetCurrentValue(ViewportWidthProperty, value); + } + + internal static IDisposable PublishViewportWidth( + Avalonia.Controls.TextBox textBox, + ScrollViewer scrollViewer, + TextPresenter? textPresenter) + { + SetViewportWidth(textBox, double.NaN); + var subscriptions = new CompositeDisposable(); + void UpdateViewportWidth() + { + SetViewportWidth(textBox, CalculateViewportWidth(scrollViewer, textPresenter)); + } + + subscriptions.Add(scrollViewer.GetObservable(ScrollViewer.ViewportProperty) + .Subscribe(_ => UpdateViewportWidth())); + subscriptions.Add(scrollViewer.GetObservable(ScrollViewer.PaddingProperty) + .Subscribe(_ => UpdateViewportWidth())); + if (textPresenter is not null) + { + subscriptions.Add(textPresenter.GetObservable(Layoutable.MarginProperty) + .Subscribe(_ => UpdateViewportWidth())); + } + + return subscriptions; + } + + private static double CalculateViewportWidth(ScrollViewer scrollViewer, TextPresenter? textPresenter) + { + var padding = scrollViewer.Padding; + var margin = textPresenter?.Margin ?? default; + var width = scrollViewer.Viewport.Width - + padding.Left - padding.Right - + margin.Left - margin.Right; + + return double.IsFinite(width) && width > 0 ? width : double.NaN; + } +} diff --git a/src/AtomUI.Desktop.Controls/Tooltip/OverflowTip.cs b/src/AtomUI.Desktop.Controls/Tooltip/OverflowTip.cs index 661c41706..1c55c4561 100644 --- a/src/AtomUI.Desktop.Controls/Tooltip/OverflowTip.cs +++ b/src/AtomUI.Desktop.Controls/Tooltip/OverflowTip.cs @@ -224,6 +224,8 @@ public sealed class OverflowTip : AvaloniaObject else if (_owner is AvaloniaTextBox textBox) { _subscriptions.Add(textBox.GetObservable(AvaloniaTextBox.TextProperty).Subscribe(_ => Update())); + _subscriptions.Add(textBox.GetObservable(TextViewportMetrics.ViewportWidthProperty) + .Subscribe(_ => Update())); } else if (_owner is ContentPresenter contentPresenter) { @@ -384,12 +386,10 @@ public sealed class OverflowTip : AvaloniaObject { if (_owner is AvaloniaTextBox textBox) { - var presenter = textBox.GetVisualDescendants() - .OfType() - .FirstOrDefault(part => part.Name == "PART_TextPresenter"); - if (presenter is not null && IsUsableWidth(presenter.Bounds.Width)) + var viewportWidth = TextViewportMetrics.GetViewportWidth(textBox); + if (viewportWidth.HasValue) { - return presenter.Bounds.Width; + return viewportWidth.Value; } return textBox.Bounds.Width - textBox.Padding.Left - textBox.Padding.Right; diff --git a/tests/AtomUI.Desktop.Controls.Tests/Tooltip/OverflowTipTests.cs b/tests/AtomUI.Desktop.Controls.Tests/Tooltip/OverflowTipTests.cs index 7d39691d0..906517f2c 100644 --- a/tests/AtomUI.Desktop.Controls.Tests/Tooltip/OverflowTipTests.cs +++ b/tests/AtomUI.Desktop.Controls.Tests/Tooltip/OverflowTipTests.cs @@ -7,6 +7,10 @@ using Avalonia.VisualTree; using Shouldly; using Xunit; using AvaloniaWindow = Avalonia.Controls.Window; +using AvaloniaTextBox = Avalonia.Controls.TextBox; +using AtomUIScrollViewer = AtomUI.Desktop.Controls.ScrollViewer; +using AtomUITextArea = AtomUI.Desktop.Controls.TextArea; +using AtomUITextBox = AtomUI.Desktop.Controls.TextBox; namespace AtomUI.Desktop.Controls.Tests.Tooltip; @@ -51,6 +55,183 @@ public class OverflowTipTests }); } + [Fact] + public void AtomUI_TextBox_Uses_Visible_Text_Viewport_For_Overflow() + { + const string value = "AtomUI text input overflow must use the visible text viewport"; + var textBox = new AtomUITextBox + { + Width = 100, + Text = value + }; + + OverflowTip.SetIsEnabled(textBox, true); + + ShowInWindow(textBox, () => ToolTip.GetTip(textBox).ShouldBe(value)); + } + + [Fact] + public void AtomUI_TextArea_Publishes_Visible_Text_Viewport_Width() + { + var textArea = new AtomUITextArea + { + Width = 160, + Text = "AtomUI text area viewport" + }; + + ShowInWindow(textArea, () => + { + var scrollViewer = textArea.GetVisualDescendants() + .OfType() + .Single(item => item.Name == "PART_ScrollViewer"); + var textPresenter = textArea.GetVisualDescendants() + .OfType() + .Single(item => item.Name == "PART_TextPresenter"); + + scrollViewer.Padding = new Thickness(3, 0, 7, 0); + textPresenter.Margin = new Thickness(5, 0, 11, 0); + Dispatcher.UIThread.RunJobs(); + + var viewportWidth = TextViewportMetrics.GetViewportWidth(textArea); + var expectedWidth = scrollViewer.Viewport.Width - + scrollViewer.Padding.Left - scrollViewer.Padding.Right - + textPresenter.Margin.Left - textPresenter.Margin.Right; + + viewportWidth.ShouldNotBeNull(); + viewportWidth.Value.ShouldBe(expectedWidth, 0.001); + }); + } + + [Fact] + public void AtomUI_TextBox_Reevaluates_When_Viewport_Padding_Changes_Without_Owner_Resize() + { + const string value = "AtomUI"; + var textBox = new AtomUITextBox + { + Width = 160, + Text = value + }; + + OverflowTip.SetIsEnabled(textBox, true); + + ShowInWindow(textBox, () => + { + ToolTip.GetTip(textBox).ShouldBeNull(); + var ownerBounds = textBox.Bounds; + var scrollViewer = textBox.GetVisualDescendants() + .OfType() + .Single(item => item.Name == "ScrollViewer"); + var viewportWidth = scrollViewer.Viewport.Width; + + scrollViewer.Padding = new Thickness(0, 0, viewportWidth - 12, 0); + Dispatcher.UIThread.RunJobs(); + + textBox.Bounds.ShouldBe(ownerBounds); + ToolTip.GetTip(textBox).ShouldBe(value); + }); + } + + [Fact] + public void AtomUI_TextBox_Releases_Previous_Viewport_Source_On_Template_Reapply() + { + var textBox = new AtomUITextBox + { + Width = 160, + Text = "AtomUI" + }; + + OverflowTip.SetIsEnabled(textBox, true); + + ShowInWindow(textBox, () => + { + var oldScrollViewer = textBox.GetVisualDescendants() + .OfType() + .Single(item => item.Name == "ScrollViewer"); + var template = textBox.Template; + template.ShouldNotBeNull(); + + textBox.Template = null; + Dispatcher.UIThread.RunJobs(); + textBox.Template = template; + Dispatcher.UIThread.RunJobs(); + + var newScrollViewer = textBox.GetVisualDescendants() + .OfType() + .Single(item => item.Name == "ScrollViewer"); + ReferenceEquals(oldScrollViewer, newScrollViewer).ShouldBeFalse(); + var currentWidth = TextViewportMetrics.GetViewportWidth(textBox); + + oldScrollViewer.Padding = new Thickness(0, 0, oldScrollViewer.Viewport.Width - 4, 0); + Dispatcher.UIThread.RunJobs(); + + TextViewportMetrics.GetViewportWidth(textBox).ShouldBe(currentWidth); + ToolTip.GetTip(textBox).ShouldBeNull(); + }); + } + + [Fact] + public void AtomUI_TextArea_Releases_Previous_Viewport_Source_On_Template_Reapply() + { + var textArea = new AtomUITextArea + { + Width = 160, + Text = "AtomUI" + }; + + ShowInWindow(textArea, () => + { + var oldScrollViewer = textArea.GetVisualDescendants() + .OfType() + .Single(item => item.Name == "PART_ScrollViewer"); + var template = textArea.Template; + template.ShouldNotBeNull(); + + textArea.Template = null; + Dispatcher.UIThread.RunJobs(); + textArea.Template = template; + Dispatcher.UIThread.RunJobs(); + + var newScrollViewer = textArea.GetVisualDescendants() + .OfType() + .Single(item => item.Name == "PART_ScrollViewer"); + ReferenceEquals(oldScrollViewer, newScrollViewer).ShouldBeFalse(); + var currentWidth = TextViewportMetrics.GetViewportWidth(textArea); + + oldScrollViewer.Padding = new Thickness(0, 0, oldScrollViewer.Viewport.Width - 4, 0); + Dispatcher.UIThread.RunJobs(); + + TextViewportMetrics.GetViewportWidth(textArea).ShouldBe(currentWidth); + }); + } + + [Fact] + public void Avalonia_TextBox_Uses_Geometry_Fallback_When_No_Viewport_Metric_Is_Published() + { + const string value = "Third-party Avalonia TextBox fallback must remain available"; + var textBox = new AvaloniaTextBox + { + Width = 80, + Text = value + }; + + OverflowTip.SetIsEnabled(textBox, true); + + ShowInWindow(textBox, () => + { + TextViewportMetrics.GetViewportWidth(textBox).ShouldBeNull(); + ToolTip.GetTip(textBox).ShouldBe(value); + }); + } + + [Fact] + public void OverflowTip_Does_Not_Query_TextBox_Template_Internals() + { + var source = ReadRepoFile("src/AtomUI.Desktop.Controls/Tooltip/OverflowTip.cs"); + + source.ShouldNotContain("GetVisualDescendants()"); + source.ShouldNotContain("PART_TextPresenter"); + } + [Fact] public void User_Defined_ToolTip_Is_Not_Overwritten_Or_Cleared() {