Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 74 additions & 1 deletion src/MaterialDesignThemes.Wpf/Clock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));

/// <summary>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.</summary>
public static readonly DependencyProperty IsTimeAllowedProperty = DependencyProperty.Register(
nameof(IsTimeAllowed), typeof(Predicate<DateTime>), typeof(Clock), new PropertyMetadata(null, SelectionRulesChanged));

public Predicate<DateTime>? IsTimeAllowed
{
get => (Predicate<DateTime>?)GetValue(IsTimeAllowedProperty);
set => SetValue(IsTimeAllowedProperty, value);
}

/// <summary>Refresh after data captured by IsTimeAllowed changes. Call on the UI thread.</summary>
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);
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
{
Expand All @@ -428,6 +464,8 @@ private void GenerateButtons(
canvas.Children.Add(button);
}

UpdateButtonAvailability();

BindingBase GetBinding(
string propertyName,
object? owner = null,
Expand All @@ -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<ClockItemButton>())
{
if (button.Tag is not Tuple<ClockDisplayMode, int> 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);
Expand Down
41 changes: 36 additions & 5 deletions src/MaterialDesignThemes.Wpf/TimePicker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));

/// <summary>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.</summary>
public static readonly DependencyProperty IsTimeAllowedProperty = Clock.IsTimeAllowedProperty.AddOwner(
typeof(TimePicker), new PropertyMetadata(null, SelectionRulesChanged));

public Predicate<DateTime>? IsTimeAllowed
{
get => (Predicate<DateTime>?)GetValue(IsTimeAllowedProperty);
set => SetValue(IsTimeAllowedProperty, value);
}

/// <summary>Refresh after data captured by IsTimeAllowed changes. Call on the UI thread.</summary>
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)
{
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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));
}

Expand Down
38 changes: 38 additions & 0 deletions src/MaterialDesignThemes.Wpf/TimeSelectionRules.cs
Original file line number Diff line number Diff line change
@@ -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<DateTime>? predicate) => predicate == null || predicate(time.Date.AddHours(time.Hour).AddMinutes(time.Minute));

internal static DateTime? Nearest(DateTime time, Predicate<DateTime>? 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;
}
}
135 changes: 135 additions & 0 deletions tests/MaterialDesignThemes.Wpf.Tests/TimeSelectionRulesTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
namespace MaterialDesignThemes.Wpf.Tests;

[TestExecutor<STAThreadExecutor>]
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<Canvas>(Clock.HoursCanvasPartName).GetVisualChildren<ClockItemButton>().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<Canvas>(Clock.MinutesCanvasPartName).GetVisualChildren<ClockItemButton>().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<TextBox>(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<TextBox>(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<TimeSpan>();
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<Canvas>(Clock.HoursCanvasPartName).GetVisualChildren<ClockItemButton>();
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<Canvas>(Clock.MinutesCanvasPartName).GetVisualChildren<ClockItemButton>();
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<Canvas>(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();
}
}
Loading