From 55b5b97807f40a1d2b502e6ec4e0ee89f0196ddd Mon Sep 17 00:00:00 2001 From: chinboy Date: Sun, 4 Oct 2026 15:07:13 +0800 Subject: [PATCH] fix(DatePicker): align range panel anchor and endpoint highlights --- .../data-entry/date-picker/changelog.md | 1 + .../data-entry/date-picker/implementation.md | 3 +- .../date-viewer/shared-panel-design.md | 2 + .../DatePicker/RangeDatePickerPresenter.cs | 16 +------ .../Internal/DatePanelAlgorithms.cs | 8 +++- .../DatePickerViewerBehaviorTests.cs | 33 ++++++++++++- .../DateViewer/DateViewerTests.cs | 48 +++++++++++++++++++ 7 files changed, 92 insertions(+), 19 deletions(-) diff --git a/docs/controls/desktop/data-entry/date-picker/changelog.md b/docs/controls/desktop/data-entry/date-picker/changelog.md index 9ae8a2cfb..a4f4ef0c8 100644 --- a/docs/controls/desktop/data-entry/date-picker/changelog.md +++ b/docs/controls/desktop/data-entry/date-picker/changelog.md @@ -13,6 +13,7 @@ - Align RangeDatePicker preview precedence with the shared range model so reverse start editing renders only the normalized live endpoints instead of retaining a third committed endpoint. - Keep range Hover state atomic across shared panels and preserve the established endpoint corner geometry. - Preserve the opposite endpoint value, input text and DateViewer selected range state when confirming one RangeDatePicker endpoint and rotating the active endpoint. + - Keep complete range popups anchored to the start period and suppress selected/range endpoint projection on duplicate outside cells in dual-panel range viewers. ## 2026-10-03 diff --git a/docs/controls/desktop/data-entry/date-picker/implementation.md b/docs/controls/desktop/data-entry/date-picker/implementation.md index 4f89ca65a..2ebafb26c 100644 --- a/docs/controls/desktop/data-entry/date-picker/implementation.md +++ b/docs/controls/desktop/data-entry/date-picker/implementation.md @@ -77,9 +77,10 @@ Pointer、键盘、滚轮与 Automation 共用面板算法,事件消费由 Pic ## 8. 算法与关键流程 单位归一、范围视觉与导航由共享内核提供;Formatting 保留输入格式和宽度。 -终点打开按该端点与面板周期定位,起点不能通过同步改写浏览区域。 +仅有终点值时,打开按该端点与面板周期定位;起点不能通过同步改写浏览区域。 已有完整范围重新打开时,本次确认进度从当前活动端开始重建;选择当前端点后保持弹层打开并转移到另一端, 只有另一端也在本次编辑中确认后才执行最终确认和关闭。 +完整范围双面板打开时以 start 所在周期作为左面板锚点;active end 不能倒推一个周期来改变面板锚点。 Today/Now/Confirm 共用有效约束,但时间来源和确认行为按原规则。 视觉顺序与候选端点分离,范围预览不能改写真实值,反向 hover 只改变预览文本和预览高亮的显示顺序。 选择/确认一个端点时必须结束旧 Hover 预览,避免旧 hover 在活动端翻转后被解释成新活动端的预览并覆盖 viewer 已提交选中态。 diff --git a/docs/controls/desktop/data-entry/date-viewer/shared-panel-design.md b/docs/controls/desktop/data-entry/date-viewer/shared-panel-design.md index 447793ef1..403ebce28 100644 --- a/docs/controls/desktop/data-entry/date-viewer/shared-panel-design.md +++ b/docs/controls/desktop/data-entry/date-viewer/shared-panel-design.md @@ -222,6 +222,7 @@ Picker、Calendar 以及 DateViewer 分别拥有自己的 Semantic owner。组 提交前重新验证有效性,避免模型生成后约束发生变化使过期 Cell 被提交。 范围真实端点、候选端点和 hover preview 分别推导;预览中间区域与端点不污染真实选中标记。 激活有效 Cell 时必须先清理旧 Hover preview,再让宿主提交和切换活动端,防止旧 hover 在新活动端上下文中覆盖已提交范围选中态。 +双面板范围选择中,非本面板周期的 outside cell 只作为浏览填充,不投影 selected、visual endpoint、range endpoint、range middle 或 preview 状态;同一个真实端点只能在所属面板周期内高亮一次。 周模式按整行输出连续视觉,周号与七个日期参与同一范围;命中区域包含日期格间隙和两个面板,不只依赖 Cell PointerEntered。 ### 7.2 Picker 编辑会话 @@ -241,6 +242,7 @@ Picker、Calendar 以及 DateViewer 分别拥有自己的 Semantic owner。组 范围活动端点的显示定位使用该端点与当前面板周期,不允许开始值同步将结束面板拉回开始区域。 RangeDatePicker 每次打开都重建本次端点确认进度:空范围从 start 到 end;单端点范围把已有端点作为固定端; 完整范围保留提交值作为初始显示,但本次编辑仍要求从当前活动端开始重新确认两个端点。 +完整范围使用 start 所在周期作为双面板左锚点;active end 只表达当前编辑端点,不能通过倒推一个周期改变左面板月份。 设置当前端点后,活动端、输入焦点、Popup 箭头和 DatePanel active part 必须同步翻转到另一端。 端点翻转不得清理对向端点的值或本端刚确认值;清理行为只允许发生在显式 Clear、表单清空或既有关闭路径要求的未完整范围回滚中。 范围日期时间的有效确认行为与用户原始 IsNeedConfirm 请求分开表示,不需要覆盖 public 属性再保存 backup 来表示内部派生状态; diff --git a/src/AtomUI.Desktop.Controls/DatePicker/RangeDatePickerPresenter.cs b/src/AtomUI.Desktop.Controls/DatePicker/RangeDatePickerPresenter.cs index 86bf27c75..e33e3e732 100644 --- a/src/AtomUI.Desktop.Controls/DatePicker/RangeDatePickerPresenter.cs +++ b/src/AtomUI.Desktop.Controls/DatePicker/RangeDatePickerPresenter.cs @@ -55,21 +55,9 @@ internal class RangeDatePickerPresenter : DatePickerPresenter protected override DateTime? ResolveOpenDisplayAnchor() { var active = EditSession.OpenAnchor; - if (active is null || PanelCount == 1 || ActiveRangePart == DateRangeActivePart.Start) + if (active is null || PanelCount == 1) return active; - try - { - return PickerMode switch - { - DatePickerMode.Month or DatePickerMode.Quarter => active.Value.AddYears(-1), - DatePickerMode.Year => new DateTime(Math.Max(1, active.Value.Year / 10 * 10 - 10), 1, 1), - _ => active.Value.AddMonths(-1) - }; - } - catch (ArgumentOutOfRangeException) - { - return active; - } + return EditSession.Draft.Start ?? active; } protected override void ApplyDraft(DateViewerRange draft) diff --git a/src/AtomUI.Desktop.Controls/DateViewer/Internal/DatePanelAlgorithms.cs b/src/AtomUI.Desktop.Controls/DateViewer/Internal/DatePanelAlgorithms.cs index c261bee23..a913e0bbd 100644 --- a/src/AtomUI.Desktop.Controls/DateViewer/Internal/DatePanelAlgorithms.cs +++ b/src/AtomUI.Desktop.Controls/DateViewer/Internal/DatePanelAlgorithms.cs @@ -237,12 +237,13 @@ internal static class DatePanelAlgorithms bool Matches(DateTime? target) => value is { } candidate && target is { } selection && Normalize(candidate, comparisonUnit, firstDay) == Normalize(selection, comparisonUnit, firstDay); var selectableWeek = kind != DateViewerCellType.Week || input.SelectionUnit == DateViewerSelectionUnit.Week; - var rangeGrid = input.SelectionUnit == comparisonUnit && input.Range is not null; + var rangeVisualEligible = IsRangeVisualEligible(input, inView); + var rangeGrid = rangeVisualEligible && input.SelectionUnit == comparisonUnit && input.Range is not null; var state = rangeGrid && value is { } rangeValue ? GetRangeState(input, rangeValue, firstDay) : default; var selected = !state.HasPreview && !disabled && selectableWeek && (Matches(input.SelectedDate) || - input.Range is { } range && (Matches(range.Start) || Matches(range.End))); + rangeVisualEligible && input.Range is { } range && (Matches(range.Start) || Matches(range.End))); var hovered = !disabled && selectableWeek && Matches(input.HoveredValue); var visualEndpoint = state.HasPreview ? state.PreviewStart || state.PreviewEnd : selected; var weekVisual = input.SelectionUnit == DateViewerSelectionUnit.Week && input.PanelKind == DateViewerPanelKind.Date && @@ -266,6 +267,9 @@ internal static class DatePanelAlgorithms }; } + private static bool IsRangeVisualEligible(DatePanelInput input, bool isInView) => + input is not { IsRangeSelection: true, PanelCount: > 1 } || isInView; + private static DateViewerSelectionUnit GetCellUnit(DateViewerCellType kind) => kind switch { DateViewerCellType.Date => DateViewerSelectionUnit.Date, diff --git a/tests/AtomUI.Desktop.Controls.Tests/DatePicker/DatePickerViewerBehaviorTests.cs b/tests/AtomUI.Desktop.Controls.Tests/DatePicker/DatePickerViewerBehaviorTests.cs index 9fcd55da7..fb8e11cea 100644 --- a/tests/AtomUI.Desktop.Controls.Tests/DatePicker/DatePickerViewerBehaviorTests.cs +++ b/tests/AtomUI.Desktop.Controls.Tests/DatePicker/DatePickerViewerBehaviorTests.cs @@ -144,7 +144,7 @@ public class DatePickerViewerBehaviorTests } [Fact] - public void Range_End_Open_Uses_The_Previous_Dual_Panel_Period_And_Keeps_The_Host_As_Value_Owner() + public void Range_End_Open_Uses_The_Start_Dual_Panel_Period_And_Keeps_The_Host_As_Value_Owner() { var start = new DateTime(2026, 1, 12); var end = new DateTime(2026, 2, 11); @@ -164,7 +164,7 @@ public class DatePickerViewerBehaviorTests var presenter = picker.PickerPresenter.ShouldBeAssignableTo()!; var viewer = presenter.GetVisualDescendants().OfType().Single(); - viewer.PanelSession.Input.DisplayDate.ShouldBe(end.AddMonths(-1)); + viewer.PanelSession.Input.DisplayDate.ShouldBe(start); viewer.PanelSession.Input.Range.ShouldBe(new DateViewerRange(start, end)); viewer.PanelSession.Input.PanelCount.ShouldBe(2); viewer.Bounds.Width.ShouldBe(540, 0.5); @@ -178,6 +178,35 @@ public class DatePickerViewerBehaviorTests }); } + [Fact] + public void Range_End_Open_Keeps_The_Start_Period_As_The_Left_Dual_Panel_When_The_Range_Is_In_One_Month() + { + var start = new DateTime(2026, 7, 6); + var end = new DateTime(2026, 7, 12); + var picker = new RangeDatePicker + { + PickerDisplayDate = new DateTime(2027, 9, 23), + RangeStartSelectedDate = start, + RangeEndSelectedDate = end + }; + picker.RangeActivatedPart = RangeActivatedPart.End; + + ShowPicker(picker, () => + { + picker.IsPickerOpen = true; + Drain(); + + var presenter = picker.PickerPresenter.ShouldBeAssignableTo()!; + var viewer = presenter.GetVisualDescendants().OfType().Single(); + + viewer.PanelSession.Input.DisplayDate.ShouldBe(start); + viewer.PanelSession.Models[0].Anchor.ShouldBe(new DateTime(2026, 7, 1)); + viewer.PanelSession.Models[1].Anchor.ShouldBe(new DateTime(2026, 8, 1)); + viewer.PanelSession.Input.Range.ShouldBe(new DateViewerRange(start, end)); + viewer.PanelSession.Input.ActiveRangePart.ShouldBe(DateRangeActivePart.End); + }); + } + [Fact] public void Range_Partial_Confirmation_Stays_Open_And_Final_Confirmation_Orders_Dates() { diff --git a/tests/AtomUI.Desktop.Controls.Tests/DateViewer/DateViewerTests.cs b/tests/AtomUI.Desktop.Controls.Tests/DateViewer/DateViewerTests.cs index fd83e3cee..cfbb1ab0e 100644 --- a/tests/AtomUI.Desktop.Controls.Tests/DateViewer/DateViewerTests.cs +++ b/tests/AtomUI.Desktop.Controls.Tests/DateViewer/DateViewerTests.cs @@ -84,6 +84,54 @@ public class DateViewerTests finally { window.Close(); Dispatcher.UIThread.RunJobs(); } } + [Fact] + public void Range_Dual_Panel_Does_Not_Select_Duplicate_Outside_Endpoint_Cells() + { + var start = new DateTime(2026, 9, 28); + var end = new DateTime(2026, 10, 4); + var viewer = new RangeDateViewer + { + DisplayDate = new DateTime(2026, 9, 1), + Value = new DateViewerRange(start, end) + }; + var window = new Avalonia.Controls.Window { Width = 600, Height = 360, Content = viewer }; + + try + { + window.Show(); + Dispatcher.UIThread.RunJobs(); + + var cells = viewer.GetVisualDescendants().OfType().ToArray(); + var visibleStart = cells.Single(cell => cell.Model?.Value == start && cell.Model.IsInView); + var visibleEnd = cells.Single(cell => cell.Model?.Value == end && cell.Model.IsInView); + var duplicateStart = cells.Single(cell => cell.Model?.Value == start && cell.Model.IsInView == false); + var duplicateEnd = cells.Single(cell => cell.Model?.Value == end && cell.Model.IsInView == false); + + visibleStart.Model!.IsSelected.ShouldBeTrue(); + visibleStart.Model.IsVisualEndpoint.ShouldBeTrue(); + visibleStart.Model.IsRangeStart.ShouldBeTrue(); + visibleEnd.Model!.IsSelected.ShouldBeTrue(); + visibleEnd.Model.IsVisualEndpoint.ShouldBeTrue(); + visibleEnd.Model.IsRangeEnd.ShouldBeTrue(); + + duplicateStart.Model!.IsSelected.ShouldBeFalse(); + duplicateStart.Model.IsVisualEndpoint.ShouldBeFalse(); + duplicateStart.Model.IsRangeStart.ShouldBeFalse(); + duplicateStart.Model.IsRangeMiddle.ShouldBeFalse(); + duplicateStart.Model.IsRangeEnd.ShouldBeFalse(); + duplicateEnd.Model!.IsSelected.ShouldBeFalse(); + duplicateEnd.Model.IsVisualEndpoint.ShouldBeFalse(); + duplicateEnd.Model.IsRangeStart.ShouldBeFalse(); + duplicateEnd.Model.IsRangeMiddle.ShouldBeFalse(); + duplicateEnd.Model.IsRangeEnd.ShouldBeFalse(); + } + finally + { + window.Close(); + Dispatcher.UIThread.RunJobs(); + } + } + [Fact] public void Header_Commands_Browse_And_Same_Value_Selection_Notifies_Once_Per_Activation() {