Calendar rendering implementation.

This commit is contained in:
Burak Kaan Köse
2026-03-23 14:56:36 +01:00
parent 8586d0ef54
commit 1adba271e2
32 changed files with 11146 additions and 846 deletions
@@ -0,0 +1,19 @@
using System;
using Microsoft.UI.Xaml;
using Windows.Foundation;
namespace Wino.Calendar.Controls;
public sealed class CalendarEmptySlotTappedEventArgs : EventArgs
{
public CalendarEmptySlotTappedEventArgs(DateTime clickedDate, Point positionerPoint, Size cellSize)
{
ClickedDate = clickedDate;
PositionerPoint = positionerPoint;
CellSize = cellSize;
}
public DateTime ClickedDate { get; }
public Point PositionerPoint { get; }
public Size CellSize { get; }
}
@@ -9,8 +9,6 @@ using Microsoft.UI.Xaml.Media;
using Wino.Calendar.ViewModels.Data;
using Wino.Calendar.ViewModels.Messages;
using Wino.Core.Domain;
using Wino.Core.Domain.Models.Calendar;
namespace Wino.Calendar.Controls;
public sealed partial class CalendarItemControl : UserControl
@@ -96,7 +94,7 @@ public sealed partial class CalendarItemControl : UserControl
if (isSingleTap && CalendarItem != null)
{
WeakReferenceMessenger.Default.Send(new CalendarItemTappedMessage(CalendarItem, null));
WeakReferenceMessenger.Default.Send(new CalendarItemTappedMessage(CalendarItem));
}
}
@@ -4,6 +4,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:helpers="using:Wino.Helpers"
xmlns:local="using:Wino.Calendar.Controls"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:skia="using:SkiaSharp.Views.Windows"
@@ -53,14 +54,34 @@
<Grid>
<Grid x:Name="TimedRoot" Visibility="Collapsed">
<Grid.RowDefinitions>
<RowDefinition Height="44" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="64" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid x:Name="TimedHeaderHost">
<Border
x:Name="TimedHourHeaderHost"
Grid.Row="0"
Grid.Column="0"
Height="44"
Background="{ThemeResource LayerFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="0,0,1,1" />
<Grid
x:Name="TimedHeaderHost"
Grid.Row="0"
Grid.ColumnSpan="2"
Margin="64,0,0,0"
Height="44"
Background="{ThemeResource LayerFillColorDefaultBrush}">
<skia:SKXamlCanvas x:Name="TimedHeaderCanvas" PaintSurface="TimedHeaderCanvasPaintSurface" />
<ItemsControl
x:Name="TimedHeadersItemsControl"
HorizontalContentAlignment="Stretch"
ItemTemplate="{StaticResource TimedHeaderTemplate}"
ItemsSource="{x:Bind TimedHeaderTexts, Mode=OneWay}">
<ItemsControl.ItemsPanel>
@@ -69,17 +90,42 @@
</ItemsControl>
</Grid>
<ScrollViewer
x:Name="TimedScrollViewer"
<Grid
Grid.Row="1"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto"
VerticalScrollMode="Enabled">
<Grid x:Name="TimedViewport" Height="{x:Bind TimelineHeight, Mode=OneWay}">
<skia:SKXamlCanvas x:Name="TimedStructureCanvas" PaintSurface="TimedStructureCanvasPaintSurface" />
<Canvas x:Name="TimedItemsCanvas" />
</Grid>
</ScrollViewer>
Grid.ColumnSpan="2"
Background="Transparent">
<ScrollViewer
x:Name="TimedScrollViewer"
Background="Transparent"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto"
VerticalScrollMode="Enabled">
<Grid x:Name="TimedScrollContentGrid" Height="{x:Bind TimelineHeight, Mode=OneWay}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="64" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Canvas
x:Name="HourLabelsCanvas"
Width="64"
IsHitTestVisible="False" />
<Grid
x:Name="TimedViewport"
Grid.Column="1"
Height="{x:Bind TimelineHeight, Mode=OneWay}">
<skia:SKXamlCanvas x:Name="TimedStructureCanvas" PaintSurface="TimedStructureCanvasPaintSurface" />
<Border
x:Name="TimedInteractionLayer"
Background="Transparent"
Tapped="TimedInteractionLayerTapped" />
<Canvas x:Name="TimedItemsCanvas" />
</Grid>
</Grid>
</ScrollViewer>
</Grid>
</Grid>
<Grid x:Name="MonthRoot" Visibility="Collapsed">
@@ -106,7 +152,11 @@
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<skia:SKXamlCanvas x:Name="MonthStructureCanvas" PaintSurface="MonthStructureCanvasPaintSurface" />
<Canvas x:Name="MonthCellLabelsCanvas" />
<Border
x:Name="MonthInteractionLayer"
Background="Transparent"
Tapped="MonthInteractionLayerTapped" />
<Canvas x:Name="MonthCellLabelsCanvas" IsHitTestVisible="False" />
<Canvas x:Name="MonthItemsCanvas" />
</Grid>
</Grid>
@@ -12,9 +12,11 @@ using Microsoft.UI.Composition;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Hosting;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
using SkiaSharp;
using SkiaSharp.Views.Windows;
using Windows.Foundation;
using Windows.UI;
using Wino.Calendar.ViewModels.Data;
using Wino.Core.Domain.Enums;
@@ -25,6 +27,9 @@ namespace Wino.Calendar.Controls;
public sealed partial class CalendarPeriodControl : UserControl, INotifyPropertyChanged
{
private const double TimedHourColumnWidth = 64d;
private const double TimedGridIntervalMinutes = 30d;
private const double TimedSelectionIntervalMinutes = 30d;
private VisibleDateRange _currentRange = new(
CalendarDisplayType.Month,
DateOnly.FromDateTime(DateTime.Today),
@@ -62,6 +67,7 @@ public sealed partial class CalendarPeriodControl : UserControl, INotifyProperty
public CalendarPeriodControl() => InitializeComponent();
public event PropertyChangedEventHandler? PropertyChanged;
public event EventHandler<CalendarEmptySlotTappedEventArgs>? EmptySlotTapped;
private ObservableCollection<HeaderTextLayout> TimedHeaderTextsCollection { get; } = [];
private ObservableCollection<HeaderTextLayout> MonthHeaderTextsCollection { get; } = [];
@@ -209,20 +215,22 @@ public sealed partial class CalendarPeriodControl : UserControl, INotifyProperty
{
TimedRoot.Visibility = Visibility.Visible;
MonthRoot.Visibility = Visibility.Collapsed;
ResetTimedVisualState();
TimedDayWidth = _currentRange.Dates.Count == 0 ? 0d : ActualWidth / _currentRange.Dates.Count;
TimedViewport.Width = ActualWidth;
var timedSurfaceWidth = GetTimedSurfaceWidth();
TimedDayWidth = _currentRange.Dates.Count == 0 ? 0d : timedSurfaceWidth / _currentRange.Dates.Count;
TimedScrollContentGrid.Width = ActualWidth;
TimedViewport.Width = timedSurfaceWidth;
TimedViewport.Height = TimelineHeight;
_timedLayout = TimedCalendarLayoutCalculator.Calculate(_currentRange, CurrentItems, ActualWidth, GetHourHeight());
_timedLayout = TimedCalendarLayoutCalculator.Calculate(_currentRange, CurrentItems, timedSurfaceWidth, GetHourHeight());
ReplaceCollection(
TimedHeaderTextsCollection,
_timedLayout.VisibleDates.Select(date =>
new HeaderTextLayout(
date.ToDateTime(TimeOnly.MinValue).ToString(
string.IsNullOrWhiteSpace(TimedHeaderDateFormat) ? "ddd dd" : TimedHeaderDateFormat,
CalendarSettings!.CultureInfo),
GetTimedHeaderText(date),
TimedDayWidth)));
var eventTemplate = (DataTemplate)Resources["CalendarEventTemplate"];
@@ -232,6 +240,7 @@ public sealed partial class CalendarPeriodControl : UserControl, INotifyProperty
item.Template = eventTemplate;
return item;
}));
RenderHourLabels();
RenderTimedItems();
TimedHeaderCanvas.Invalidate();
@@ -352,12 +361,14 @@ public sealed partial class CalendarPeriodControl : UserControl, INotifyProperty
var canvas = e.Surface.Canvas;
canvas.Clear(SKColors.Transparent);
if (_timedLayout.VisibleDates.Count == 0 || ActualWidth <= 0)
var timedSurfaceWidth = GetTimedSurfaceWidth();
if (_timedLayout.VisibleDates.Count == 0 || timedSurfaceWidth <= 0)
{
return;
}
var scaleX = (float)(e.Info.Width / ActualWidth);
var scaleX = (float)(e.Info.Width / timedSurfaceWidth);
var height = e.Info.Height;
var dayWidth = (float)(_timedLayout.DayWidth * scaleX);
@@ -373,41 +384,50 @@ public sealed partial class CalendarPeriodControl : UserControl, INotifyProperty
private void TimedStructureCanvasPaintSurface(object? sender, SKPaintSurfaceEventArgs e)
{
using var linePaint = CreateLinePaint();
using var minorLinePaint = CreateMinorLinePaint();
using var defaultFillPaint = CreateFillPaint(GetDefaultHourBackground());
using var workFillPaint = CreateFillPaint(GetWorkHourBackground());
var canvas = e.Surface.Canvas;
canvas.Clear(SKColors.Transparent);
if (_timedLayout.VisibleDates.Count == 0 || ActualWidth <= 0)
var timedSurfaceWidth = GetTimedSurfaceWidth();
if (_timedLayout.VisibleDates.Count == 0 || timedSurfaceWidth <= 0)
{
return;
}
var hourHeight = GetHourHeight();
var timelineHeight = TimedCalendarLayoutCalculator.GetTimelineHeight(hourHeight);
var scaleX = (float)(e.Info.Width / ActualWidth);
var scaleX = (float)(e.Info.Width / timedSurfaceWidth);
var scaleY = (float)(e.Info.Height / timelineHeight);
var dayWidth = (float)(_timedLayout.DayWidth * scaleX);
var workDayStartHour = CalendarSettings?.WorkingHourStart.TotalHours ?? 9d;
var workDayEndHour = CalendarSettings?.WorkingHourEnd.TotalHours ?? 17d;
var intervalHeight = (float)(GetTimedGridIntervalHeight() * scaleY);
var intervalCount = (int)(24d * 60d / TimedGridIntervalMinutes);
for (var dayIndex = 0; dayIndex < _timedLayout.VisibleDates.Count; dayIndex++)
{
var x = dayIndex * dayWidth;
var isWorkingDay = CalendarSettings?.WorkingDays.Contains(_timedLayout.VisibleDates[dayIndex].DayOfWeek) == true;
for (var hour = 0; hour < 24; hour++)
for (var intervalIndex = 0; intervalIndex < intervalCount; intervalIndex++)
{
var y = (float)(hour * hourHeight * scaleY);
var scaledHourHeight = (float)(hourHeight * scaleY);
var fillPaint = hour >= workDayStartHour && hour < workDayEndHour ? workFillPaint : defaultFillPaint;
canvas.DrawRect(x, y, dayWidth, scaledHourHeight, fillPaint);
var intervalStartHour = (intervalIndex * TimedGridIntervalMinutes) / 60d;
var y = intervalIndex * intervalHeight;
var fillPaint = isWorkingDay && intervalStartHour >= workDayStartHour && intervalStartHour < workDayEndHour
? workFillPaint
: defaultFillPaint;
canvas.DrawRect(x, y, dayWidth, intervalHeight, fillPaint);
}
}
for (var hour = 0; hour <= 24; hour++)
for (var intervalIndex = 0; intervalIndex <= intervalCount; intervalIndex++)
{
var y = (float)(hour * hourHeight * scaleY);
canvas.DrawLine(0, y, e.Info.Width, y, linePaint);
var y = intervalIndex * intervalHeight;
var paint = intervalIndex % 2 == 0 ? linePaint : minorLinePaint;
canvas.DrawLine(0, y, e.Info.Width, y, paint);
}
for (var index = 0; index <= _timedLayout.VisibleDates.Count; index++)
@@ -472,6 +492,34 @@ public sealed partial class CalendarPeriodControl : UserControl, INotifyProperty
}
}
private void RenderHourLabels()
{
HourLabelsCanvas.Children.Clear();
HourLabelsCanvas.Height = TimelineHeight;
var hourHeight = GetHourHeight();
var labelWidth = Math.Max(0d, TimedHourColumnWidth - 10d);
for (var hour = 0; hour <= 24; hour++)
{
var textBlock = new TextBlock
{
Width = labelWidth,
Text = GetTimedHourLabelText(hour),
TextAlignment = TextAlignment.Right,
Opacity = 0.72
};
var y = hour == 24
? Math.Max(0d, TimelineHeight - 20d)
: Math.Max(0d, (hour * hourHeight) - 10d);
Canvas.SetLeft(textBlock, 0d);
Canvas.SetTop(textBlock, y);
HourLabelsCanvas.Children.Add(textBlock);
}
}
private void RenderTimedItems()
{
TimedItemsCanvas.Children.Clear();
@@ -532,6 +580,70 @@ public sealed partial class CalendarPeriodControl : UserControl, INotifyProperty
}
}
private void TimedInteractionLayerTapped(object sender, TappedRoutedEventArgs e)
{
if (_timedLayout.VisibleDates.Count == 0 || _timedLayout.DayWidth <= 0)
{
return;
}
var position = e.GetPosition(TimedViewport);
var dayIndex = Math.Clamp((int)(position.X / _timedLayout.DayWidth), 0, _timedLayout.VisibleDates.Count - 1);
var intervalHeight = GetTimedSelectionIntervalHeight();
var slotIndex = Math.Clamp((int)(position.Y / intervalHeight), 0, (int)((24d * 60d / TimedSelectionIntervalMinutes) - 1));
var slotStart = TimeSpan.FromMinutes(slotIndex * TimedSelectionIntervalMinutes);
var clickedDate = _timedLayout.VisibleDates[dayIndex].ToDateTime(TimeOnly.MinValue).Add(slotStart);
EmptySlotTapped?.Invoke(
this,
new CalendarEmptySlotTappedEventArgs(
clickedDate,
new Point(dayIndex * _timedLayout.DayWidth, slotIndex * intervalHeight),
new Size(_timedLayout.DayWidth, intervalHeight)));
}
private void MonthInteractionLayerTapped(object sender, TappedRoutedEventArgs e)
{
if (_monthLayout.Cells.Count == 0 || _monthLayout.CellWidth <= 0 || _monthLayout.CellHeight <= 0)
{
return;
}
var position = e.GetPosition(MonthViewport);
var column = Math.Clamp((int)(position.X / _monthLayout.CellWidth), 0, MonthCalendarLayoutCalculator.ColumnCount - 1);
var row = Math.Clamp((int)(position.Y / _monthLayout.CellHeight), 0, MonthCalendarLayoutCalculator.RowCount - 1);
var cellIndex = Math.Clamp((row * MonthCalendarLayoutCalculator.ColumnCount) + column, 0, _monthLayout.Cells.Count - 1);
var cell = _monthLayout.Cells[cellIndex];
EmptySlotTapped?.Invoke(
this,
new CalendarEmptySlotTappedEventArgs(
cell.Date.ToDateTime(TimeOnly.MinValue),
new Point(cell.Bounds.X, cell.Bounds.Y),
new Size(cell.Bounds.Width, cell.Bounds.Height)));
}
private double GetTimedSurfaceWidth() => Math.Max(0d, ActualWidth - TimedHourColumnWidth);
private string GetTimedHeaderText(DateOnly date)
{
if (!string.IsNullOrWhiteSpace(TimedHeaderDateFormat) && CalendarSettings is not null)
{
try
{
return date.ToDateTime(TimeOnly.MinValue).ToString(TimedHeaderDateFormat, CalendarSettings.CultureInfo);
}
catch (FormatException)
{
}
}
return CalendarSettings?.GetTimedDayHeaderText(date) ?? date.ToDateTime(TimeOnly.MinValue).ToString("ddd dd");
}
private string GetTimedHourLabelText(int hour)
=> CalendarSettings?.GetTimedHourLabelText(hour) ?? $"{hour:00}:00";
private CalendarTransitionInfo GetTransitionInfo()
{
if (!_hasPresentedState || VisibleRange is null || CalendarSettings is null)
@@ -597,11 +709,9 @@ public sealed partial class CalendarPeriodControl : UserControl, INotifyProperty
private void RunTimedTransition(CalendarTransitionInfo transition)
{
var headerVisual = ElementCompositionPreview.GetElementVisual(TimedHeaderHost);
var contentVisual = ElementCompositionPreview.GetElementVisual(TimedScrollViewer);
var compositor = headerVisual.Compositor;
var compositor = contentVisual.Compositor;
PrepareAnimatedVisual(headerVisual, TimedHeaderHost);
PrepareAnimatedVisual(contentVisual, TimedScrollViewer);
switch (transition.Kind)
@@ -618,6 +728,11 @@ public sealed partial class CalendarPeriodControl : UserControl, INotifyProperty
}
}
private void ResetTimedVisualState()
{
ResetAnimatedElement(TimedScrollViewer);
}
private static void StartNavigationTransition(Compositor compositor, Visual visual, int direction, double width)
{
var travel = (float)Math.Max(48d, Math.Min(160d, width * 0.08d));
@@ -644,8 +759,7 @@ public sealed partial class CalendarPeriodControl : UserControl, INotifyProperty
var signedTravel = direction >= 0 ? travel : -travel;
var clipInset = (float)Math.Max(18d, Math.Min(64d, width * 0.05d));
StartTimedElementTransition(compositor, TimedHeaderHost, signedTravel * 0.45f, 0f, 0.78f, TimeSpan.FromMilliseconds(180), direction >= 0 ? 0f : clipInset, direction >= 0 ? clipInset : 0f);
StartTimedElementTransition(compositor, TimedScrollViewer, signedTravel, 0f, 0.68f, TimeSpan.FromMilliseconds(240), direction >= 0 ? 0f : clipInset, direction >= 0 ? clipInset : 0f);
StartTimedElementTransition(compositor, TimedScrollViewer, signedTravel, 0f, 0.68f, TimeSpan.FromMilliseconds(240), direction >= 0 ? 0f : clipInset, direction >= 0 ? clipInset : 0f, animateScale: false);
}
private static void StartModeTransition(Compositor compositor, Visual visual)
@@ -672,8 +786,7 @@ public sealed partial class CalendarPeriodControl : UserControl, INotifyProperty
private void StartTimedModeTransition(Compositor compositor)
{
StartTimedElementTransition(compositor, TimedHeaderHost, 0f, 10f, 0f, TimeSpan.FromMilliseconds(180), 0f, 0f);
StartTimedElementTransition(compositor, TimedScrollViewer, 0f, 18f, 0f, TimeSpan.FromMilliseconds(240), 0f, 0f);
StartTimedElementTransition(compositor, TimedScrollViewer, 0f, 18f, 0f, TimeSpan.FromMilliseconds(240), 0f, 0f, animateScale: false);
}
private static void StartRefreshTransition(Compositor compositor, Visual visual)
@@ -688,7 +801,6 @@ public sealed partial class CalendarPeriodControl : UserControl, INotifyProperty
private void StartTimedRefreshTransition(Compositor compositor)
{
StartOpacityTransition(compositor, ElementCompositionPreview.GetElementVisual(TimedHeaderHost), 0.86f, TimeSpan.FromMilliseconds(140));
StartOpacityTransition(compositor, ElementCompositionPreview.GetElementVisual(TimedScrollViewer), 0.8f, TimeSpan.FromMilliseconds(160));
}
@@ -700,7 +812,25 @@ public sealed partial class CalendarPeriodControl : UserControl, INotifyProperty
visual.StopAnimation(nameof(visual.Scale));
}
private static void StartTimedElementTransition(Compositor compositor, UIElement target, float offsetX, float offsetY, float startingOpacity, TimeSpan duration, float leftInset, float rightInset)
private static void ResetAnimatedElement(UIElement target)
{
var visual = ElementCompositionPreview.GetElementVisual(target);
PrepareAnimatedVisual(visual, target);
visual.Offset = Vector3.Zero;
visual.Opacity = 1f;
visual.Scale = new Vector3(1f, 1f, 1f);
if (visual.Clip is InsetClip clip)
{
clip.StopAnimation(nameof(clip.LeftInset));
clip.StopAnimation(nameof(clip.RightInset));
clip.LeftInset = 0f;
clip.RightInset = 0f;
}
}
private static void StartTimedElementTransition(Compositor compositor, UIElement target, float offsetX, float offsetY, float startingOpacity, TimeSpan duration, float leftInset, float rightInset, bool animateScale = true)
{
var visual = ElementCompositionPreview.GetElementVisual(target);
PrepareAnimatedVisual(visual, target);
@@ -723,11 +853,6 @@ public sealed partial class CalendarPeriodControl : UserControl, INotifyProperty
opacityAnimation.InsertKeyFrame(1f, 1f, fadeEasing);
opacityAnimation.Duration = duration;
var scaleAnimation = compositor.CreateVector3KeyFrameAnimation();
scaleAnimation.InsertKeyFrame(0f, new Vector3(0.996f, 0.996f, 1f));
scaleAnimation.InsertKeyFrame(1f, new Vector3(1f, 1f, 1f), easing);
scaleAnimation.Duration = duration;
var leftInsetAnimation = compositor.CreateScalarKeyFrameAnimation();
leftInsetAnimation.InsertKeyFrame(1f, 0f, easing);
leftInsetAnimation.Duration = duration;
@@ -738,7 +863,20 @@ public sealed partial class CalendarPeriodControl : UserControl, INotifyProperty
visual.StartAnimation(nameof(visual.Offset), offsetAnimation);
visual.StartAnimation(nameof(visual.Opacity), opacityAnimation);
visual.StartAnimation(nameof(visual.Scale), scaleAnimation);
if (animateScale)
{
var scaleAnimation = compositor.CreateVector3KeyFrameAnimation();
scaleAnimation.InsertKeyFrame(0f, new Vector3(0.996f, 0.996f, 1f));
scaleAnimation.InsertKeyFrame(1f, new Vector3(1f, 1f, 1f), easing);
scaleAnimation.Duration = duration;
visual.StartAnimation(nameof(visual.Scale), scaleAnimation);
}
else
{
visual.Scale = new Vector3(1f, 1f, 1f);
}
clip.StartAnimation(nameof(clip.LeftInset), leftInsetAnimation);
clip.StartAnimation(nameof(clip.RightInset), rightInsetAnimation);
}
@@ -768,6 +906,18 @@ public sealed partial class CalendarPeriodControl : UserControl, INotifyProperty
};
}
private static SKPaint CreateMinorLinePaint()
{
var strokeColor = GetStrokeColor();
return new SKPaint
{
Color = new SKColor(strokeColor.R, strokeColor.G, strokeColor.B, (byte)Math.Max(20, strokeColor.A / 4)),
IsAntialias = false,
StrokeWidth = 1
};
}
private static SKPaint CreateFillPaint(Brush brush)
{
return new SKPaint
@@ -805,6 +955,12 @@ public sealed partial class CalendarPeriodControl : UserControl, INotifyProperty
return new SolidColorBrush(Color.FromArgb(255, 34, 40, 52));
}
private static double GetTimedGridIntervalHeight(double hourHeight) => hourHeight * (TimedGridIntervalMinutes / 60d);
private double GetTimedGridIntervalHeight() => GetTimedGridIntervalHeight(GetHourHeight());
private double GetTimedSelectionIntervalHeight() => GetHourHeight() * (TimedSelectionIntervalMinutes / 60d);
private double GetHourHeight() => CalendarSettings?.HourHeight ?? 60d;
private static Color GetStrokeColor()
@@ -1,150 +0,0 @@
using System;
using System.Collections.Specialized;
using System.Linq;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Wino.Core.Domain.Collections;
using Wino.Core.Domain.Models.Calendar;
namespace Wino.Calendar.Controls;
public partial class DayColumnControl : Control
{
private const string PART_HeaderDateDayText = nameof(PART_HeaderDateDayText);
private const string PART_IsTodayBorder = nameof(PART_IsTodayBorder);
private const string PART_ColumnHeaderText = nameof(PART_ColumnHeaderText);
private const string PART_AllDayItemsControl = nameof(PART_AllDayItemsControl);
private const string TodayState = nameof(TodayState);
private const string NotTodayState = nameof(NotTodayState);
private TextBlock? HeaderDateDayText;
private TextBlock? ColumnHeaderText;
private Border? IsTodayBorder;
private ItemsControl? AllDayItemsControl;
private CalendarEventCollection? _boundEventsCollection;
public CalendarDayModel DayModel
{
get { return (CalendarDayModel)GetValue(DayModelProperty); }
set { SetValue(DayModelProperty, value); }
}
public static readonly DependencyProperty DayModelProperty = DependencyProperty.Register(
nameof(DayModel),
typeof(CalendarDayModel),
typeof(DayColumnControl),
new PropertyMetadata(null, new PropertyChangedCallback(OnRenderingPropertiesChanged)));
public DayColumnControl()
{
DefaultStyleKey = typeof(DayColumnControl);
Unloaded += OnUnloaded;
}
protected override void OnApplyTemplate()
{
base.OnApplyTemplate();
HeaderDateDayText = GetTemplateChild(PART_HeaderDateDayText) as TextBlock;
ColumnHeaderText = GetTemplateChild(PART_ColumnHeaderText) as TextBlock;
IsTodayBorder = GetTemplateChild(PART_IsTodayBorder) as Border;
AllDayItemsControl = GetTemplateChild(PART_AllDayItemsControl) as ItemsControl;
RegisterEventsCollectionHandlers();
UpdateValues();
}
private static void OnRenderingPropertiesChanged(DependencyObject control, DependencyPropertyChangedEventArgs e)
{
if (control is DayColumnControl columnControl)
{
columnControl.RegisterEventsCollectionHandlers();
columnControl.UpdateValues();
}
}
private void OnUnloaded(object sender, RoutedEventArgs e)
{
DeregisterEventsCollectionHandlers();
}
private bool IsMonthlyTemplate() => ColumnHeaderText == null;
private void RegisterEventsCollectionHandlers()
{
var nextCollection = DayModel?.EventsCollection;
if (ReferenceEquals(_boundEventsCollection, nextCollection))
return;
DeregisterEventsCollectionHandlers();
_boundEventsCollection = nextCollection;
if (_boundEventsCollection == null)
return;
((INotifyCollectionChanged)_boundEventsCollection.AllDayEvents).CollectionChanged += EventsCollectionChanged;
((INotifyCollectionChanged)_boundEventsCollection.RegularEvents).CollectionChanged += EventsCollectionChanged;
}
private void DeregisterEventsCollectionHandlers()
{
if (_boundEventsCollection == null)
return;
((INotifyCollectionChanged)_boundEventsCollection.AllDayEvents).CollectionChanged -= EventsCollectionChanged;
((INotifyCollectionChanged)_boundEventsCollection.RegularEvents).CollectionChanged -= EventsCollectionChanged;
_boundEventsCollection = null;
}
private void EventsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
UpdateEventItemsSource();
}
private void UpdateEventItemsSource()
{
if (AllDayItemsControl == null || DayModel == null) return;
if (IsMonthlyTemplate())
{
// Month cells should show all events for the day, not only all-day/multi-day.
var monthlyItems = DayModel.EventsCollection.AllDayEvents
.Concat(DayModel.EventsCollection.RegularEvents)
.GroupBy(a => a.Id)
.Select(g => g.First())
.OrderBy(a => a.StartDate)
.ToList();
AllDayItemsControl.ItemsSource = monthlyItems;
return;
}
AllDayItemsControl.ItemsSource = DayModel.EventsCollection.AllDayEvents;
}
private void UpdateValues()
{
if (DayModel == null) return;
if (HeaderDateDayText != null)
{
HeaderDateDayText.Text = DayModel.RepresentingDate.Day.ToString();
}
// Monthly template does not use it.
if (ColumnHeaderText != null)
{
ColumnHeaderText.Text = DayModel.RepresentingDate.ToString("dddd", DayModel.CalendarRenderOptions.CalendarSettings.CultureInfo);
}
UpdateEventItemsSource();
if (IsTodayBorder == null) return;
bool isToday = DayModel.RepresentingDate.Date == DateTime.Now.Date;
VisualStateManager.GoToState(this, isToday ? TodayState : NotTodayState, false);
UpdateLayout();
}
}
@@ -1,56 +0,0 @@
using System;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Wino.Core.Domain.Enums;
namespace Wino.Calendar.Controls;
public partial class DayHeaderControl : Control
{
private const string PART_DayHeaderTextBlock = nameof(PART_DayHeaderTextBlock);
private TextBlock? HeaderTextblock;
public DayHeaderDisplayType DisplayType
{
get { return (DayHeaderDisplayType)GetValue(DisplayTypeProperty); }
set { SetValue(DisplayTypeProperty, value); }
}
public DateTime Date
{
get { return (DateTime)GetValue(DateProperty); }
set { SetValue(DateProperty, value); }
}
public static readonly DependencyProperty DateProperty = DependencyProperty.Register(nameof(Date), typeof(DateTime), typeof(DayHeaderControl), new PropertyMetadata(default(DateTime), new PropertyChangedCallback(OnHeaderPropertyChanged)));
public static readonly DependencyProperty DisplayTypeProperty = DependencyProperty.Register(nameof(DisplayType), typeof(DayHeaderDisplayType), typeof(DayHeaderControl), new PropertyMetadata(DayHeaderDisplayType.TwentyFourHour, new PropertyChangedCallback(OnHeaderPropertyChanged)));
public DayHeaderControl()
{
DefaultStyleKey = typeof(DayHeaderControl);
}
protected override void OnApplyTemplate()
{
base.OnApplyTemplate();
HeaderTextblock = GetTemplateChild(PART_DayHeaderTextBlock) as TextBlock;
UpdateHeaderText();
}
private static void OnHeaderPropertyChanged(DependencyObject control, DependencyPropertyChangedEventArgs e)
{
if (control is DayHeaderControl headerControl)
{
headerControl.UpdateHeaderText();
}
}
private void UpdateHeaderText()
{
if (HeaderTextblock != null)
{
HeaderTextblock.Text = DisplayType == DayHeaderDisplayType.TwelveHour ? Date.ToString("h tt") : Date.ToString("HH:mm");
}
}
}
@@ -1,85 +0,0 @@
using System.Collections.Generic;
using CommunityToolkit.WinUI;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Wino.Calendar.Controls;
using Wino.Core.Domain.Models.Calendar;
namespace Wino.Mail.WinUI.Controls.Calendar;
/// <summary>
/// AOT-Safe ItemsControl for use in UniformGrid panels.
/// </summary>
///
public partial class UniformItemsControl : Grid
{
[GeneratedDependencyProperty]
public partial DayRangeRenderModel? RenderModel { get; set; }
[GeneratedDependencyProperty]
public partial List<CalendarDayModel>? ItemsSource { get; set; }
partial void OnRenderModelChanged(DayRangeRenderModel? newValue)
{
if (newValue == null || ItemsSource == null) return;
AdjustColumns();
}
partial void OnItemsSourceChanged(List<CalendarDayModel>? newValue)
{
if (newValue == null || ItemsSource == null) return;
AdjustColumns();
}
private void AdjustColumns()
{
if (RenderModel == null || ItemsSource == null) return;
Children.Clear();
ColumnDefinitions.Clear();
var columns = RenderModel.TotalDays;
// First divide.
for (int i = 0; i < columns; i++)
{
ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
}
// Then add items.
for (int i = 0; i < columns; i++)
{
var item = ItemsSource[i];
var control = new DayColumnControl()
{
DayModel = item
};
SetColumn(control, i);
Children.Add(control);
}
}
}
//public partial class UniformItemsControl : ItemsControl
//{
// private const string ControlUniformGridName = "PART_UniformGrid";
// [GeneratedDependencyProperty]
// public partial DayRangeRenderModel? RenderModel { get; set; }
// partial void OnRenderModelChanged(DayRangeRenderModel? newValue)
// {
// if (newValue == null) return;
// // Adjust the ItemsPanel based on the RenderModel's columns.
// var uniGrid = WinoVisualTreeHelper.FindDescendants<UniformGrid>(this);
// //if (uniGrid != null)
// //{
// // uniGrid.Columns = newValue.TotalDays;
// //}
// }
//}
@@ -1,293 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CommunityToolkit.WinUI;
using Itenso.TimePeriod;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Windows.Foundation;
using Wino.Calendar.Models;
using Wino.Calendar.ViewModels.Data;
using Wino.Core.Domain.Interfaces;
namespace Wino.Calendar.Controls;
public partial class WinoCalendarPanel : Panel
{
private const double LastItemRightExtraMargin = 12d;
// Store each ICalendarItem measurements by their Id.
private readonly Dictionary<ICalendarItem, CalendarItemMeasurement> _measurements = new Dictionary<ICalendarItem, CalendarItemMeasurement>();
public static readonly DependencyProperty EventItemMarginProperty = DependencyProperty.Register(nameof(EventItemMargin), typeof(Thickness), typeof(WinoCalendarPanel), new PropertyMetadata(new Thickness(0, 0, 0, 0)));
public static readonly DependencyProperty HourHeightProperty = DependencyProperty.Register(nameof(HourHeight), typeof(double), typeof(WinoCalendarPanel), new PropertyMetadata(0d));
public static readonly DependencyProperty PeriodProperty = DependencyProperty.Register(nameof(Period), typeof(ITimePeriod), typeof(WinoCalendarPanel), new PropertyMetadata(null));
public ITimePeriod Period
{
get { return (ITimePeriod)GetValue(PeriodProperty); }
set { SetValue(PeriodProperty, value); }
}
public double HourHeight
{
get { return (double)GetValue(HourHeightProperty); }
set { SetValue(HourHeightProperty, value); }
}
public Thickness EventItemMargin
{
get { return (Thickness)GetValue(EventItemMarginProperty); }
set { SetValue(EventItemMarginProperty, value); }
}
private void ResetMeasurements() => _measurements.Clear();
private double GetChildTopMargin(ICalendarItem calendarItemViewModel, double availableHeight)
{
var childStart = calendarItemViewModel.StartDate;
if (childStart <= Period.Start)
{
// Event started before or exactly at the periods tart. This might be a multi-day event.
// We can simply consider event must not have a top margin.
return 0d;
}
double minutesFromStart = (childStart - Period.Start).TotalMinutes;
return (minutesFromStart / 1440) * availableHeight;
}
private double GetChildWidth(CalendarItemMeasurement calendarItemMeasurement, double availableWidth)
{
return (calendarItemMeasurement.Right - calendarItemMeasurement.Left) * availableWidth;
}
private double GetChildLeftMargin(CalendarItemMeasurement calendarItemMeasurement, double availableWidth)
=> availableWidth * calendarItemMeasurement.Left;
private double GetChildHeight(ICalendarItem child)
{
// All day events are not measured.
if (child.IsAllDayEvent) return 0;
double childDurationInMinutes = 0d;
double availableHeight = HourHeight * 24;
var periodRelation = child.Period.GetRelation(Period);
// Debug.WriteLine($"Render relation of {child.Title} ({child.Period.Start} - {child.Period.End}) is {periodRelation} with {Period.Start.Day}");
if (!child.IsMultiDayEvent)
{
childDurationInMinutes = child.Period.Duration.TotalMinutes;
}
else
{
// Multi-day event.
// Check how many of the event falls into the current period.
childDurationInMinutes = (child.Period.End - Period.Start).TotalMinutes;
}
return (childDurationInMinutes / 1440) * availableHeight;
}
protected override Size MeasureOverride(Size availableSize)
{
ResetMeasurements();
return base.MeasureOverride(availableSize);
}
protected override Size ArrangeOverride(Size finalSize)
{
if (Period == null || HourHeight == 0d) return finalSize;
// Measure/arrange each child height and width.
// This is a vertical calendar. Therefore the height of each child is the duration of the event.
// Children weights for left and right will be saved if they don't exist.
// This is important because we don't want to measure the weights again.
// They don't change until new event is added or removed.
// Width of the each child may depend on the rectangle packing algorithm.
// Children are first categorized into columns. Then each column is shifted to the left until
// no overlap occurs. The width of each child is calculated based on the number of columns it spans.
double availableHeight = finalSize.Height;
double availableWidth = finalSize.Width;
var calendarControls = Children.Cast<ContentPresenter>();
if (!calendarControls.Any()) return base.ArrangeOverride(finalSize);
var events = calendarControls.Select(a => a.Content as CalendarItemViewModel).OfType<ICalendarItem>();
LayoutEvents(events);
foreach (var control in calendarControls)
{
// We can't arrange this child.
if (!(control.Content is ICalendarItem child)) continue;
bool isHorizontallyLastItem = false;
double childWidth = 0,
childHeight = Math.Max(0, GetChildHeight(child)),
childTop = Math.Max(0, GetChildTopMargin(child, availableHeight)),
childLeft = 0;
// No need to measure anything here.
if (childHeight == 0) continue;
if (!_measurements.ContainsKey(child))
{
// Multi-day event.
childLeft = 0;
childWidth = availableWidth;
}
else
{
var childMeasurement = _measurements[child];
childWidth = Math.Max(0, GetChildWidth(childMeasurement, finalSize.Width));
childLeft = Math.Max(0, GetChildLeftMargin(childMeasurement, availableWidth));
isHorizontallyLastItem = childMeasurement.Right == 1;
}
// Add additional right margin to items that falls on the right edge of the panel.
double extraRightMargin = 0;
// Multi-day events don't have any margin and their hit test is disabled.
if (!child.IsMultiDayEvent)
{
// Max of 5% of the width or 20px max.
extraRightMargin = isHorizontallyLastItem ? Math.Max(LastItemRightExtraMargin, finalSize.Width * 5 / 100) : 0;
}
if (childWidth < 0) childWidth = 1;
// Regular events must have 2px margin
if (!child.IsMultiDayEvent && !child.IsAllDayEvent)
{
childLeft += 2;
childTop += 2;
childHeight -= 2;
childWidth -= 2;
}
var arrangementRect = new Rect(childLeft + EventItemMargin.Left, childTop + EventItemMargin.Top, Math.Max(childWidth - extraRightMargin, 1), childHeight);
// Make sure measured size will fit in the arranged box.
var measureSize = arrangementRect.ToSize();
control.Measure(measureSize);
control.Arrange(arrangementRect);
//Debug.WriteLine($"{child.Title}, Measured: {measureSize}, Arranged: {arrangementRect}");
}
return finalSize;
}
#region ColumSpanning and Packing Algorithm
private void AddOrUpdateMeasurement(ICalendarItem calendarItem, CalendarItemMeasurement measurement)
{
if (_measurements.ContainsKey(calendarItem))
{
_measurements[calendarItem] = measurement;
}
else
{
_measurements.Add(calendarItem, measurement);
}
}
// Pick the left and right positions of each event, such that there are no overlap.
private void LayoutEvents(IEnumerable<ICalendarItem> events)
{
var columns = new List<List<ICalendarItem>>();
DateTime? lastEventEnding = null;
foreach (var ev in events.OrderBy(ev => ev.StartDate).ThenBy(ev => ev.EndDate))
{
// Multi-day events are not measured.
if (ev.IsMultiDayEvent) continue;
if (ev.Period.Start >= lastEventEnding)
{
PackEvents(columns);
columns.Clear();
lastEventEnding = null;
}
bool placed = false;
foreach (var col in columns)
{
if (!col.Last().Period.OverlapsWith(ev.Period))
{
col.Add(ev);
placed = true;
break;
}
}
if (!placed)
{
columns.Add(new List<ICalendarItem> { ev });
}
if (lastEventEnding == null || ev.Period.End > lastEventEnding.Value)
{
lastEventEnding = ev.Period.End;
}
}
if (columns.Count > 0)
{
PackEvents(columns);
}
}
// Set the left and right positions for each event in the connected group.
private void PackEvents(List<List<ICalendarItem>> columns)
{
float numColumns = columns.Count;
int iColumn = 0;
foreach (var col in columns)
{
foreach (var ev in col)
{
int colSpan = ExpandEvent(ev, iColumn, columns);
var leftWeight = iColumn / numColumns;
var rightWeight = (iColumn + colSpan) / numColumns;
AddOrUpdateMeasurement(ev, new CalendarItemMeasurement(leftWeight, rightWeight));
}
iColumn++;
}
}
// Checks how many columns the event can expand into, without colliding with other events.
private int ExpandEvent(ICalendarItem ev, int iColumn, List<List<ICalendarItem>> columns)
{
int colSpan = 1;
foreach (var col in columns.Skip(iColumn + 1))
{
foreach (var ev1 in col)
{
if (ev1.Period.OverlapsWith(ev.Period)) return colSpan;
}
colSpan++;
}
return colSpan;
}
#endregion
}
@@ -1,8 +1,9 @@
using System.Windows.Input;
using System.Windows.Input;
using CommunityToolkit.Diagnostics;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Wino.Core.Domain.Enums;
using Wino.Mail.WinUI.Controls;
namespace Wino.Calendar.Controls;
@@ -11,6 +12,7 @@ public partial class WinoCalendarTypeSelectorControl : Control
private const string PART_TodayButton = nameof(PART_TodayButton);
private const string PART_DayToggle = nameof(PART_DayToggle);
private const string PART_WeekToggle = nameof(PART_WeekToggle);
private const string PART_WorkWeekToggle = nameof(PART_WorkWeekToggle);
private const string PART_MonthToggle = nameof(PART_MonthToggle);
public static readonly DependencyProperty SelectedTypeProperty = DependencyProperty.Register(
@@ -42,6 +44,7 @@ public partial class WinoCalendarTypeSelectorControl : Control
private AppBarButton? _todayButton;
private AppBarToggleButton? _dayToggle;
private AppBarToggleButton? _weekToggle;
private AppBarToggleButton? _workWeekToggle;
private AppBarToggleButton? _monthToggle;
public WinoCalendarTypeSelectorControl()
@@ -58,24 +61,34 @@ public partial class WinoCalendarTypeSelectorControl : Control
_todayButton = GetTemplateChild(PART_TodayButton) as AppBarButton;
_dayToggle = GetTemplateChild(PART_DayToggle) as AppBarToggleButton;
_weekToggle = GetTemplateChild(PART_WeekToggle) as AppBarToggleButton;
_workWeekToggle = GetTemplateChild(PART_WorkWeekToggle) as AppBarToggleButton;
_monthToggle = GetTemplateChild(PART_MonthToggle) as AppBarToggleButton;
Guard.IsNotNull(_todayButton, nameof(_todayButton));
Guard.IsNotNull(_dayToggle, nameof(_dayToggle));
Guard.IsNotNull(_weekToggle, nameof(_weekToggle));
Guard.IsNotNull(_workWeekToggle, nameof(_workWeekToggle));
Guard.IsNotNull(_monthToggle, nameof(_monthToggle));
_todayButton!.Click += TodayClicked;
_dayToggle!.Click += (s, e) => { SetSelectedType(CalendarDisplayType.Day); };
_weekToggle!.Click += (s, e) => { SetSelectedType(CalendarDisplayType.Week); };
_monthToggle!.Click += (s, e) => { SetSelectedType(CalendarDisplayType.Month); };
_dayToggle!.Click += DayToggleClicked;
_weekToggle!.Click += WeekToggleClicked;
_workWeekToggle!.Click += WorkWeekToggleClicked;
_monthToggle!.Click += MonthToggleClicked;
UpdateToggleButtonStates();
}
private void TodayClicked(object? sender, RoutedEventArgs e) => TodayClickedCommand?.Execute(null);
private void DayToggleClicked(object sender, RoutedEventArgs e) => SetSelectedType(CalendarDisplayType.Day);
private void WeekToggleClicked(object sender, RoutedEventArgs e) => SetSelectedType(CalendarDisplayType.Week);
private void WorkWeekToggleClicked(object sender, RoutedEventArgs e) => SetSelectedType(CalendarDisplayType.WorkWeek);
private void MonthToggleClicked(object sender, RoutedEventArgs e) => SetSelectedType(CalendarDisplayType.Month);
private void SetSelectedType(CalendarDisplayType type)
{
SelectedType = type;
@@ -84,8 +97,10 @@ public partial class WinoCalendarTypeSelectorControl : Control
private static void OnSelectedTypeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var control = d as WinoCalendarTypeSelectorControl;
control?.UpdateToggleButtonStates();
if (d is WinoCalendarTypeSelectorControl control)
{
control.UpdateToggleButtonStates();
}
}
private void UnregisterHandlers()
@@ -94,17 +109,38 @@ public partial class WinoCalendarTypeSelectorControl : Control
{
_todayButton.Click -= TodayClicked;
}
if (_dayToggle != null)
{
_dayToggle.Click -= DayToggleClicked;
}
if (_weekToggle != null)
{
_weekToggle.Click -= WeekToggleClicked;
}
if (_workWeekToggle != null)
{
_workWeekToggle.Click -= WorkWeekToggleClicked;
}
if (_monthToggle != null)
{
_monthToggle.Click -= MonthToggleClicked;
}
}
private void UpdateToggleButtonStates()
{
if (_dayToggle == null || _weekToggle == null || _monthToggle == null)
if (_dayToggle == null || _weekToggle == null || _workWeekToggle == null || _monthToggle == null)
{
return;
}
_dayToggle.IsChecked = SelectedType == CalendarDisplayType.Day;
_weekToggle.IsChecked = SelectedType == CalendarDisplayType.Week;
_workWeekToggle.IsChecked = SelectedType == CalendarDisplayType.WorkWeek;
_monthToggle.IsChecked = SelectedType == CalendarDisplayType.Month;
}
}
@@ -76,6 +76,7 @@ public static class ControlConstants
{ WinoIconGlyph.CalendarToday, "\uE911" },
{ WinoIconGlyph.CalendarDay, "\uE913" },
{ WinoIconGlyph.CalendarWeek, "\uE914" },
{ WinoIconGlyph.CalendarWorkWeek, "\uE914" },
{ WinoIconGlyph.CalendarMonth, "\uE91c" },
{ WinoIconGlyph.CalendarYear, "\uE917" },
{ WinoIconGlyph.WeatherBlow, "\uE907" },