diff --git a/src/MaterialDesignThemes.Wpf/Clock.cs b/src/MaterialDesignThemes.Wpf/Clock.cs index ce0cf2ca3f..f4f0f5d683 100644 --- a/src/MaterialDesignThemes.Wpf/Clock.cs +++ b/src/MaterialDesignThemes.Wpf/Clock.cs @@ -58,12 +58,44 @@ public Clock() } public static readonly DependencyProperty TimeProperty = DependencyProperty.Register( - nameof(Time), typeof(DateTime), typeof(Clock), new FrameworkPropertyMetadata(default(DateTime), FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, TimePropertyChangedCallback)); + nameof(Time), typeof(DateTime), typeof(Clock), new FrameworkPropertyMetadata(default(DateTime), FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, TimePropertyChangedCallback, CoerceTime)); + + /// Optional synchronous rule for minute slots, used for all selection paths. + /// Receives a date and time with seconds cleared. Keep the rule fast and free of side effects. + public static readonly DependencyProperty IsTimeAllowedProperty = DependencyProperty.Register( + nameof(IsTimeAllowed), typeof(Predicate), typeof(Clock), new PropertyMetadata(null, SelectionRulesChanged)); + + public Predicate? IsTimeAllowed + { + get => (Predicate?)GetValue(IsTimeAllowedProperty); + set => SetValue(IsTimeAllowedProperty, value); + } + + /// Refresh after data captured by IsTimeAllowed changes. Call on the UI thread. + public void RefreshTimeAvailability() + { + CoerceValue(TimeProperty); + UpdateButtonAvailability(); + } + + private static object CoerceTime(DependencyObject d, object value) + { + var clock = (Clock)d; + return TimeSelectionRules.Nearest((DateTime)value, clock.IsTimeAllowed) ?? clock.Time; + } + + private static void SelectionRulesChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var clock = (Clock)d; + clock.CoerceValue(TimeProperty); + clock.UpdateButtonAvailability(); + } private static void TimePropertyChangedCallback(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs e) { var clock = (Clock)dependencyObject; SetFlags(clock); + clock.UpdateButtonAvailability(); TimeChangedEventArgs args = new(TimeChangedEvent, (DateTime)e.OldValue, (DateTime)e.NewValue); clock.OnTimeChanged(args); } @@ -126,6 +158,9 @@ private static void IsPostMeridiemPropertyChangedCallback(DependencyObject depen clock.Time = new DateTime(clock.Time.Year, clock.Time.Month, clock.Time.Day, clock.Time.Hour + 12, clock.Time.Minute, clock.Time.Second); else if (!clock.IsPostMeridiem && clock.Time.Hour >= 12) clock.Time = new DateTime(clock.Time.Year, clock.Time.Month, clock.Time.Day, clock.Time.Hour - 12, clock.Time.Minute, clock.Time.Second); + + SetFlags(clock); + clock.UpdateButtonAvailability(); } public bool IsPostMeridiem @@ -419,6 +454,7 @@ private void GenerateButtons( button.SetBinding(Canvas.TopProperty, GetBinding("Y", button)); button.Content = (i == 60 ? 0 : (i == 24 && clockDisplayMode == ClockDisplayMode.Hours ? 0 : i)).ToString(format); + button.Tag = Tuple.Create(clockDisplayMode, i); if (isEnabledSelector is not null) { @@ -428,6 +464,8 @@ private void GenerateButtons( canvas.Children.Add(button); } + UpdateButtonAvailability(); + BindingBase GetBinding( string propertyName, object? owner = null, @@ -444,6 +482,41 @@ BindingBase GetBinding( } + private void UpdateButtonAvailability() + { + foreach (string part in new[] { HoursCanvasPartName, MinutesCanvasPartName, SecondsCanvasPartName }) + { + if (GetTemplateChild(part) is not Canvas canvas) + continue; + + foreach (ClockItemButton button in canvas.Children.OfType()) + { + if (button.Tag is not Tuple item) + continue; + + ClockDisplayMode mode = item.Item1; + int value = item.Item2; + bool enabled; + if (mode == ClockDisplayMode.Hours) + { + int hour = Is24Hours ? value % 24 : value % 12 + (IsPostMeridiem ? 12 : 0); + enabled = TimeSelectionRules.Nearest(Time, IsTimeAllowed, hour).HasValue; + } + else + { + var candidate = mode == ClockDisplayMode.Minutes + ? Time.Date.AddHours(Time.Hour).AddMinutes(value % 60).AddTicks(Time.TimeOfDay.Ticks % TimeSpan.TicksPerMinute) + : Time.Date.AddHours(Time.Hour).AddMinutes(Time.Minute).AddSeconds(value % 60); + enabled = TimeSelectionRules.IsAllowed(candidate, IsTimeAllowed); + if (mode == ClockDisplayMode.Minutes) + enabled &= value % 60 % MinuteSelectionStep == 0; + } + button.SetCurrentValue(IsEnabledProperty, enabled); + button.SetCurrentValue(OpacityProperty, enabled ? 1.0 : 0.25); + } + } + } + private void ClockItemDragCompletedHandler(object sender, DragCompletedEventArgs e) { OnClockChoiceMade(this, DisplayMode); diff --git a/src/MaterialDesignThemes.Wpf/TimePicker.cs b/src/MaterialDesignThemes.Wpf/TimePicker.cs index 731cb355e8..9f96660864 100644 --- a/src/MaterialDesignThemes.Wpf/TimePicker.cs +++ b/src/MaterialDesignThemes.Wpf/TimePicker.cs @@ -88,7 +88,33 @@ public string? Text } public static readonly DependencyProperty SelectedTimeProperty = DependencyProperty.Register( - nameof(SelectedTime), typeof(DateTime?), typeof(TimePicker), new FrameworkPropertyMetadata(default(DateTime?), FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, SelectedTimePropertyChangedCallback)); + nameof(SelectedTime), typeof(DateTime?), typeof(TimePicker), new FrameworkPropertyMetadata(default(DateTime?), FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, SelectedTimePropertyChangedCallback, CoerceSelectedTime)); + + /// Optional rule for minute slots, used for all selection paths. + /// The argument retains its date, with seconds cleared. The rule must be fast and side-effect free. + public static readonly DependencyProperty IsTimeAllowedProperty = Clock.IsTimeAllowedProperty.AddOwner( + typeof(TimePicker), new PropertyMetadata(null, SelectionRulesChanged)); + + public Predicate? IsTimeAllowed + { + get => (Predicate?)GetValue(IsTimeAllowedProperty); + set => SetValue(IsTimeAllowedProperty, value); + } + + /// Refresh after data captured by IsTimeAllowed changes. Call on the UI thread. + public void RefreshTimeAvailability() + { + CoerceValue(SelectedTimeProperty); + _clock.RefreshTimeAvailability(); + } + + private static object? CoerceSelectedTime(DependencyObject d, object value) + { + var picker = (TimePicker)d; + return value is DateTime time ? TimeSelectionRules.Nearest(time, picker.IsTimeAllowed) : null; + } + + private static void SelectionRulesChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) => d.CoerceValue(SelectedTimeProperty); private static void SelectedTimePropertyChangedCallback(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs dependencyPropertyChangedEventArgs) { @@ -481,10 +507,14 @@ private bool IsTimeValid(string s, out DateTime time) { CultureInfo culture = Language.GetSpecificCulture(); - return DateTime.TryParse(s, - culture, - DateTimeStyles.AssumeLocal | DateTimeStyles.AllowWhiteSpaces | DateTimeStyles.NoCurrentDateDefault, - out time); + if (!DateTime.TryParse( + s, + culture, + DateTimeStyles.AssumeLocal | DateTimeStyles.AllowWhiteSpaces | DateTimeStyles.NoCurrentDateDefault, + out time)) + return false; + + return TimeSelectionRules.IsAllowed((SelectedTime?.Date ?? DateTime.Today).Add(time.TimeOfDay), IsTimeAllowed); } private string? DateTimeToString(DateTime? d) @@ -576,6 +606,7 @@ private void InitializeClock() _clock.SetBinding(StyleProperty, GetBinding(ClockStyleProperty)); _clock.SetBinding(Clock.TimeProperty, GetBinding(SelectedTimeProperty, new NullableDateTimeToCurrentDateConverter())); _clock.SetBinding(Clock.Is24HoursProperty, GetBinding(Is24HoursProperty)); + _clock.SetBinding(Clock.IsTimeAllowedProperty, GetBinding(IsTimeAllowedProperty)); _clockHostContentControl.SetBinding(StyleProperty, GetBinding(ClockHostContentControlStyleProperty)); } diff --git a/src/MaterialDesignThemes.Wpf/TimeSelectionRules.cs b/src/MaterialDesignThemes.Wpf/TimeSelectionRules.cs new file mode 100644 index 0000000000..4b10942390 --- /dev/null +++ b/src/MaterialDesignThemes.Wpf/TimeSelectionRules.cs @@ -0,0 +1,38 @@ +namespace MaterialDesignThemes.Wpf; + +internal static class TimeSelectionRules +{ + // Rules describe minute slots; seconds remain available through WithSeconds. + internal static bool IsAllowed(DateTime time, Predicate? predicate) => predicate == null || predicate(time.Date.AddHours(time.Hour).AddMinutes(time.Minute)); + + internal static DateTime? Nearest(DateTime time, Predicate? predicate, int? hour = null) + { + if ((!hour.HasValue || time.Hour == hour) && IsAllowed(time, predicate)) + return time; + + DateTime? nearest = null; + long distance = long.MaxValue; + long fraction = time.TimeOfDay.Ticks % TimeSpan.TicksPerMinute; + + for (int h = hour ?? 0; h <= (hour ?? 23); h++) + { + for (int m = 0; m < 60; m++) + { + var candidate = time.Date.AddHours(h).AddMinutes(m).AddTicks(fraction); + + if (!IsAllowed(candidate, predicate)) + continue; + + long delta = Math.Abs(candidate.Ticks - time.Ticks); + + if (delta < distance) + { + nearest = candidate; + distance = delta; + } + } + } + + return nearest; + } +} diff --git a/tests/MaterialDesignThemes.Wpf.Tests/TimeSelectionRulesTests.cs b/tests/MaterialDesignThemes.Wpf.Tests/TimeSelectionRulesTests.cs new file mode 100644 index 0000000000..cc1d3628ff --- /dev/null +++ b/tests/MaterialDesignThemes.Wpf.Tests/TimeSelectionRulesTests.cs @@ -0,0 +1,135 @@ +namespace MaterialDesignThemes.Wpf.Tests; + +[TestExecutor] +public class TimeSelectionRulesTests +{ + private static readonly DateTime Date = new(2026, 10, 25); + + private static bool BusinessHours(DateTime time) => time.TimeOfDay >= TimeSpan.FromHours(6) + && time.TimeOfDay <= TimeSpan.FromHours(19) && time.Hour != 13 && time.Minute % 10 == 0; + + [Test] + public async Task NullRulePreservesOriginalTimeIncludingSeconds() + { + var time = Date.AddHours(3).AddMinutes(17).AddSeconds(23); + await Assert.That(new Clock { Time = time }.Time).IsEqualTo(time); + await Assert.That(new TimePicker { SelectedTime = time }.SelectedTime).IsEqualTo(time); + } + + [Test] + [Arguments(3, 17, 6, 0)] + [Arguments(9, 17, 9, 20)] + [Arguments(13, 20, 12, 50)] + [Arguments(19, 10, 19, 0)] + [Arguments(6, 0, 6, 0)] + [Arguments(19, 0, 19, 0)] + public async Task SelectionIsCoercedToNearestAllowedMinute(int hour, int minute, int expectedHour, int expectedMinute) + { + var requested = Date.AddHours(hour).AddMinutes(minute); + var expected = Date.AddHours(expectedHour).AddMinutes(expectedMinute); + var clock = new Clock { IsTimeAllowed = BusinessHours, Time = requested }; + var picker = new TimePicker { IsTimeAllowed = BusinessHours, SelectedTime = requested }; + await Assert.That(clock.Time).IsEqualTo(expected); + await Assert.That(picker.SelectedTime).IsEqualTo(expected); + } + + [Test] + public async Task NativeButtonsDisableUnavailableHoursAndMinutes() + { + var clock = new Clock { Is24Hours = true, IsTimeAllowed = BusinessHours, Time = Date.AddHours(9) }; + clock.ApplyDefaultStyle(); + var hours = clock.FindVisualChild(Clock.HoursCanvasPartName).GetVisualChildren().ToList(); + await Assert.That(hours.Count).IsEqualTo(24); + await Assert.That(hours.Count(b => b.IsEnabled)).IsEqualTo(13); + await Assert.That(hours.Single(b => b.Content.ToString() == "13").IsEnabled).IsFalse(); + var minutes = clock.FindVisualChild(Clock.MinutesCanvasPartName).GetVisualChildren().ToList(); + await Assert.That(minutes.Count(b => b.IsEnabled)).IsEqualTo(6); + clock.Time = Date.AddHours(19); + await Assert.That(minutes.Count(b => b.IsEnabled)).IsEqualTo(1); + } + + [Test] + [Arguments("3:17", false)] + [Arguments("9:17", false)] + [Arguments("13:20", false)] + [Arguments("19:10", false)] + [Arguments("9:20", true)] + [Arguments("19:00", true)] + public async Task ManualInputUsesTheSameRule(string text, bool accepted) + { + var picker = new TimePicker { Is24Hours = true, IsTimeAllowed = BusinessHours, SelectedTime = Date.AddHours(9) }; + picker.ApplyDefaultStyle(); + var input = picker.FindVisualChild(TimePicker.TextBoxPartName); + input.Text = text; + input.RaiseEvent(new RoutedEventArgs(UIElement.LostFocusEvent)); + await Assert.That(picker.SelectedTime).IsEqualTo(accepted ? Date.Add(TimeSpan.Parse(text)) : Date.AddHours(9)); + } + + [Test] + public async Task ManualInputRuleReceivesTheSelectedDate() + { + var picker = new TimePicker { Is24Hours = true, SelectedTime = Date.AddHours(9), IsTimeAllowed = t => t.Date == Date }; + picker.ApplyDefaultStyle(); + var input = picker.FindVisualChild(TimePicker.TextBoxPartName); + input.Text = "10:20"; + input.RaiseEvent(new RoutedEventArgs(UIElement.LostFocusEvent)); + await Assert.That(picker.SelectedTime).IsEqualTo(Date.AddHours(10).AddMinutes(20)); + } + + [Test] + public async Task RefreshReevaluatesCapturedBookings() + { + var booked = new HashSet(); + var clock = new Clock { Time = Date.AddHours(9), IsTimeAllowed = t => BusinessHours(t) && !booked.Contains(t.TimeOfDay) }; + clock.ApplyDefaultStyle(); + booked.Add(TimeSpan.FromHours(9)); + clock.RefreshTimeAvailability(); + await Assert.That(clock.Time).IsEqualTo(Date.AddHours(8).AddMinutes(50)); + } + + [Test] + public async Task EmptyAvailabilityClearsPickerAndDisablesClockButtons() + { + var picker = new TimePicker { SelectedTime = Date.AddHours(9), IsTimeAllowed = _ => false }; + await Assert.That(picker.SelectedTime).IsNull(); + var clock = new Clock { IsTimeAllowed = _ => false }; + clock.ApplyDefaultStyle(); + var hours = clock.FindVisualChild(Clock.HoursCanvasPartName).GetVisualChildren(); + await Assert.That(hours.Any(b => b.IsEnabled)).IsFalse(); + } + + [Test] + public async Task RemovingRuleRestoresAvailabilityAndPreservesExistingMinuteStep() + { + var clock = new Clock { Time = Date.AddHours(9), MinuteSelectionStep = 15, IsTimeAllowed = _ => false }; + clock.ApplyDefaultStyle(); + clock.IsTimeAllowed = null; + var minutes = clock.FindVisualChild(Clock.MinutesCanvasPartName).GetVisualChildren(); + await Assert.That(minutes.Count(b => b.IsEnabled)).IsEqualTo(4); + } + + [Test] + public async Task DraggingCannotSelectAnUnavailableMinute() + { + var clock = new Clock { IsTimeAllowed = BusinessHours, Time = Date.AddHours(9), DisplayMode = ClockDisplayMode.Minutes }; + clock.ApplyDefaultStyle(); + var canvas = clock.FindVisualChild(Clock.MinutesCanvasPartName); + double center = canvas.Width / 2; + double angle = 17 * Math.PI / 30; + clock.RaiseEvent(new DragStartedEventArgs(center + Math.Sin(angle) * center * .8, center - Math.Cos(angle) * center * .8) + { + RoutedEvent = ClockItemButton.DragStartedEvent + }); + clock.RaiseEvent(new DragDeltaEventArgs(0, 0) { RoutedEvent = ClockItemButton.DragDeltaEvent }); + await Assert.That(clock.Time).IsEqualTo(Date.AddHours(9).AddMinutes(20)); + } + + [Test] + public async Task RejectedMeridiemChangeKeepsHeaderConsistentWithTime() + { + var clock = new Clock { IsTimeAllowed = t => t.Hour >= 14 && t.Hour <= 18, Time = Date.AddHours(14) }; + clock.IsPostMeridiem = false; + await Assert.That(clock.Time).IsEqualTo(Date.AddHours(14)); + await Assert.That(clock.IsPostMeridiem).IsTrue(); + } +}