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