Browse Source

wpf: Added new CalendarControl

Kenric Nugteren 1 tháng trước cách đây
mục cha
commit
278b36e2de
2 tập tin đã thay đổi với 1183 bổ sung2 xóa
  1. 1162 0
      inabox.wpf/Forms/CalendarControl/CalendarControl.cs
  2. 21 2
      inabox.wpf/WPFUtils.cs

+ 1162 - 0
inabox.wpf/Forms/CalendarControl/CalendarControl.cs

@@ -0,0 +1,1162 @@
+using InABox.Core;
+using InABox.Wpf;
+using InABox.WPF;
+using Microsoft.CodeAnalysis.VisualBasic.Syntax;
+using NPOI.OpenXmlFormats.Spreadsheet;
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Collections.Specialized;
+using System.ComponentModel;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using System.Reactive.Linq;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Controls.Primitives;
+using System.Windows.Data;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Windows.Media.Animation;
+using System.Windows.Shapes;
+
+namespace InABox.WPF;
+
+public class CalendarBlockEventArgs(object? value, object column, DateTime date, TimeSpan start, TimeSpan end) : EventArgs
+{
+    public object? Value { get; set; } = value;
+    public DateTime Date { get; set; } = date;
+    public object Column { get; set; } = column;
+    public TimeSpan Start { get; set; } = start;
+    public TimeSpan End { get; set; } = end;
+}
+
+public class CalendarControl : ContentControl
+{
+    public static readonly DependencyProperty RowHeightProperty = 
+        DependencyProperty.Register(nameof(RowHeight), typeof(double), typeof(CalendarControl), new(100.0, Render_Changed));
+
+    public static readonly DependencyProperty MinimumColumnWidthProperty = 
+        DependencyProperty.Register(nameof(MinimumColumnWidth), typeof(double), typeof(CalendarControl), new(50.0, Render_Changed));
+
+    public static readonly DependencyProperty RowIntervalProperty = 
+        DependencyProperty.Register(nameof(RowInterval), typeof(TimeSpan), typeof(CalendarControl), new(TimeSpan.FromHours(1), Render_Changed));
+
+    public static readonly DependencyProperty ItemsSourceProperty = 
+        DependencyProperty.Register(nameof(ItemsSource), typeof(IEnumerable), typeof(CalendarControl), new(ItemsSource_Changed));
+
+    public static readonly DependencyProperty ItemTemplateProperty = 
+        DependencyProperty.Register(nameof(ItemTemplate), typeof(DataTemplate), typeof(CalendarControl));
+
+    public static readonly DependencyProperty DateTemplateProperty = 
+        DependencyProperty.Register(nameof(DateTemplate), typeof(DataTemplate), typeof(CalendarControl));
+
+    public static readonly DependencyProperty HeaderTemplateProperty = 
+        DependencyProperty.Register(nameof(HeaderTemplate), typeof(DataTemplate), typeof(CalendarControl));
+
+    public static readonly DependencyProperty ColumnsProperty = 
+        DependencyProperty.Register(nameof(Columns), typeof(IEnumerable), typeof(CalendarControl), new(Columns_Changed));
+
+    public double MinimumColumnWidth
+    {
+        get => (double)GetValue(MinimumColumnWidthProperty);
+        set => SetValue(MinimumColumnWidthProperty, value);
+    }
+
+    public double RowHeight
+    {
+        get => (double)GetValue(RowHeightProperty);
+        set => SetValue(RowHeightProperty, value);
+    }
+
+    public TimeSpan RowInterval
+    {
+        get => (TimeSpan)GetValue(RowIntervalProperty);
+        set => SetValue(RowIntervalProperty, value);
+    }
+
+    public BindingBase? DateMapping { get; set; }
+
+    public BindingBase? ColumnMapping { get; set; }
+
+    public BindingBase? StartTimeMapping { get; set; }
+
+    public BindingBase? EndTimeMapping { get; set; }
+
+    public IEnumerable? ItemsSource
+    {
+        get => GetValue(ItemsSourceProperty) as IEnumerable;
+        set => SetValue(ItemsSourceProperty, value);
+    }
+
+    public DataTemplate? ItemTemplate
+    {
+        get => GetValue(ItemTemplateProperty) as DataTemplate;
+        set => SetValue(ItemTemplateProperty, value);
+    }
+
+    public DataTemplate? DateTemplate
+    {
+        get => GetValue(DateTemplateProperty) as DataTemplate;
+        set => SetValue(DateTemplateProperty, value);
+    }
+
+    public DataTemplate? HeaderTemplate
+    {
+        get => GetValue(HeaderTemplateProperty) as DataTemplate;
+        set => SetValue(HeaderTemplateProperty, value);
+    }
+
+    public IEnumerable? Columns
+    {
+        get => GetValue(ColumnsProperty) as IEnumerable;
+        set => SetValue(ColumnsProperty, value);
+    }
+
+    public event EventHandler<CalendarBlockEventArgs>? BlockClicked;
+    public event EventHandler<CalendarBlockEventArgs>? BlockHeld;
+
+    private static void Columns_Changed(DependencyObject d, DependencyPropertyChangedEventArgs e)
+    {
+        if (d is not CalendarControl calendar) return;
+
+        if(e.OldValue is INotifyCollectionChanged oldNotify)
+        {
+            oldNotify.CollectionChanged -= calendar.ColumnsCollection_Changed;
+        }
+        calendar.Render(columnsChanged: true);
+        if(e.NewValue is INotifyCollectionChanged notify)
+        {
+            notify.CollectionChanged += calendar.ColumnsCollection_Changed;
+        }
+    }
+
+    private void ColumnsCollection_Changed(object? sender, NotifyCollectionChangedEventArgs e)
+    {
+        Render(columnsChanged: true);
+    }
+
+    private static void Render_Changed(DependencyObject d, DependencyPropertyChangedEventArgs e)
+    {
+        if (d is not CalendarControl calendar) return;
+
+        calendar.Render();
+    }
+
+    private ScrollViewer DateScroll;
+    private ScrollViewer HeaderScroll;
+    private ScrollViewer LabelScroll;
+    private ScrollViewer MainScroll;
+
+    private ScrollBar VerticalScroll;
+    private ScrollBar HorizontalScroll;
+
+    private Canvas DateCanvas;
+    private Canvas HeaderCanvas;
+    private Canvas LabelCanvas;
+    private Canvas MainCanvas;
+
+    private Border HeaderBorder;
+
+    public CalendarControl()
+    {
+        var grid = new Grid();
+        grid.AddRow(GridUnitType.Auto); // Date
+        grid.AddRow(GridUnitType.Auto); // Column
+        grid.AddRow(GridUnitType.Star);
+        grid.AddRow(GridUnitType.Auto); // Scroll Bar
+
+        grid.AddColumn(GridUnitType.Auto); // Times
+        grid.AddColumn(GridUnitType.Star);
+        grid.AddColumn(GridUnitType.Auto); // ScrollBar
+
+        DateScroll = new ScrollViewer
+        {
+            HorizontalScrollBarVisibility = ScrollBarVisibility.Hidden,
+            VerticalScrollBarVisibility = ScrollBarVisibility.Disabled
+        };
+        DateCanvas = new Canvas
+        {
+
+        };
+        DateScroll.Content = DateCanvas;
+
+        HeaderScroll = new ScrollViewer
+        {
+            HorizontalScrollBarVisibility = ScrollBarVisibility.Hidden,
+            VerticalScrollBarVisibility = ScrollBarVisibility.Disabled
+        };
+        HeaderCanvas = new Canvas
+        {
+
+        };
+        HeaderScroll.Content = HeaderCanvas;
+
+        LabelScroll = new ScrollViewer
+        {
+            VerticalScrollBarVisibility = ScrollBarVisibility.Hidden,
+            HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled
+        };
+        LabelCanvas = new Canvas
+        {
+            Margin = new(2, 0, 2, 0)
+        };
+        LabelScroll.Content = LabelCanvas;
+
+        MainScroll = new ScrollViewer
+        {
+            HorizontalScrollBarVisibility = ScrollBarVisibility.Hidden,
+            VerticalScrollBarVisibility = ScrollBarVisibility.Hidden
+        };
+        MainCanvas = new Canvas
+        {
+
+        };
+        MainScroll.Content = MainCanvas;
+        MainScroll.SizeChanged += MainScroll_SizeChanged;
+        MainScroll.ScrollChanged += MainScroll_ScrollChanged;
+        MainScroll.PreviewMouseWheel += MainScroll_PreviewMouseWheel;
+
+        grid.AddChild(
+            new Border
+            {
+                BorderBrush = Colors.LightGray.ToBrush(),
+                BorderThickness = new(0, 0, 1, 0)
+            },
+            row: 0, rowSpan: 2,
+            column: 0);
+        grid.AddChild(
+            new Border
+            {
+                BorderBrush = Colors.LightGray.ToBrush(),
+                BorderThickness = new(0, 0, 0, 1),
+                Child = DateScroll
+            },
+            row: 0,
+            column: 1, colSpan: 2);
+        HeaderBorder = new Border
+        {
+            BorderBrush = Colors.LightGray.ToBrush(),
+            BorderThickness = new(0, 0, 0, 1),
+            Child = HeaderScroll
+        };
+        grid.AddChild(
+            HeaderBorder,
+            row: 1,
+            column: 1, colSpan: 2);
+        grid.AddChild(
+            new Border
+            {
+                BorderBrush = Colors.LightGray.ToBrush(),
+                BorderThickness = new(0, 0, 1, 0),
+                Child = LabelScroll
+            },
+            row: 2, rowSpan: 2,
+            column: 0);
+        grid.AddChild(
+            new Border
+            {
+                Child = MainScroll
+            },
+            row: 2, rowSpan: 2,
+            column: 1, colSpan: 2);
+
+        VerticalScroll = new ScrollBar();
+        VerticalScroll.Scroll += VerticalScroll_Scroll;
+        VerticalScroll.Opacity = 0.5;
+        VerticalScroll.MouseEnter += Scroll_MouseEnter;
+        VerticalScroll.MouseLeave += Scroll_MouseLeave;
+
+        HorizontalScroll = new ScrollBar
+        {
+            Orientation = Orientation.Horizontal
+        };
+        HorizontalScroll.Scroll += HorizontalScroll_Scroll;
+        HorizontalScroll.Opacity = 0.5;
+        HorizontalScroll.MouseEnter += Scroll_MouseEnter;
+        HorizontalScroll.MouseLeave += Scroll_MouseLeave;
+
+        grid.AddChild(VerticalScroll, 2, 2);
+        grid.AddChild(HorizontalScroll, 3, 1);
+
+        Content = grid;
+    }
+
+    private (double x, double y) _currentScroll;
+
+    private void Scroll_MouseLeave(object sender, MouseEventArgs e)
+    {
+        if(sender is ScrollBar bar)
+        {
+            bar.Opacity = 0.5;
+        }
+    }
+
+    private void Scroll_MouseEnter(object sender, MouseEventArgs e)
+    {
+        if(sender is ScrollBar bar)
+        {
+            bar.Opacity = 1;
+        }
+    }
+
+    private void MainScroll_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
+    {
+        if (Keyboard.Modifiers.HasFlag(ModifierKeys.Shift))
+        {
+            MainScroll.ScrollToHorizontalOffset(MainScroll.HorizontalOffset - e.Delta);
+            e.Handled = true;
+        }
+    }
+
+    private void HorizontalScroll_Scroll(object sender, ScrollEventArgs e)
+    {
+        MainScroll.ScrollToHorizontalOffset(e.NewValue);
+    }
+
+    private void VerticalScroll_Scroll(object sender, ScrollEventArgs e)
+    {
+        MainScroll.ScrollToVerticalOffset(e.NewValue);
+    }
+
+    private void MainScroll_ScrollChanged(object sender, ScrollChangedEventArgs e)
+    {
+        LabelScroll.ScrollToVerticalOffset(e.VerticalOffset);
+        HeaderScroll.ScrollToHorizontalOffset(e.HorizontalOffset);
+        DateScroll.ScrollToHorizontalOffset(e.HorizontalOffset);
+        HorizontalScroll.Value = e.HorizontalOffset;
+        VerticalScroll.Value = e.VerticalOffset;
+        _currentScroll = (e.HorizontalOffset, e.VerticalOffset);
+    }
+
+    private static void ItemsSource_Changed(DependencyObject d, DependencyPropertyChangedEventArgs e)
+    {
+        if (d is not CalendarControl calendar) return;
+
+        if(e.OldValue is INotifyCollectionChanged oldNotify)
+        {
+            oldNotify.CollectionChanged -= calendar.Collection_Changed;
+        }
+        calendar.Render(itemsChanged: true);
+        if(calendar.ItemsSource is INotifyCollectionChanged notify)
+        {
+            notify.CollectionChanged += calendar.Collection_Changed;
+        }
+    }
+
+    private void Collection_Changed(object? sender, NotifyCollectionChangedEventArgs e)
+    {
+        Render(itemsChanged: true);
+    }
+
+    private void MainScroll_SizeChanged(object sender, SizeChangedEventArgs e)
+    {
+        Render();
+    }
+
+    private class Block : Border, INotifyPropertyChanged
+    {
+        public static readonly DependencyProperty ColumnProperty =
+            DependencyProperty.Register(nameof(Column), typeof(object), typeof(Block), new(OnPropertyChangedHandler));
+
+        public static readonly DependencyProperty DateProperty =
+            DependencyProperty.Register(nameof(Date), typeof(DateTime), typeof(Block), new(OnPropertyChangedHandler));
+
+        public static readonly DependencyProperty StartTimeProperty =
+            DependencyProperty.Register(nameof(StartTime), typeof(TimeSpan), typeof(Block));
+        public static readonly DependencyProperty EndTimeProperty =
+            DependencyProperty.Register(nameof(EndTime), typeof(TimeSpan), typeof(Block));
+
+        public int ColumnIndex { get; set; } = -1;
+
+        public int NColumns { get; set; } = -1;
+
+        public object Column
+        {
+            get => GetValue(ColumnProperty);
+            set => SetValue(ColumnProperty, value);
+        }
+
+        public DateTime Date
+        {
+            get => (DateTime)GetValue(DateProperty);
+            set => SetValue(DateProperty, value);
+        }
+
+        public TimeSpan StartTime
+        {
+            get => (TimeSpan)GetValue(StartTimeProperty);
+            set => SetValue(StartTimeProperty, value);
+        }
+
+        public TimeSpan EndTime
+        {
+            get => (TimeSpan)GetValue(EndTimeProperty);
+            set => SetValue(EndTimeProperty, value);
+        }
+
+        private ContentControl _contentControl;
+        public ContentControl ContentControl => _contentControl;
+
+        public object? Content => _contentControl.Content;
+
+        public override string ToString()
+        {
+            return $"Block({Column}: {StartTime:hh\\:mm} - {EndTime:hh\\:mm})";
+        }
+
+        public Block(CalendarControl parent, object content)
+        {
+            _contentControl = new ContentControl
+            {
+                Content = content
+            };
+            _contentControl.Bind(ContentControl.ContentTemplateProperty, parent, x => x.ItemTemplate);
+            Child = _contentControl;
+        }
+
+        public event PropertyChangedEventHandler? PropertyChanged;
+
+        private static void OnPropertyChangedHandler(DependencyObject d, DependencyPropertyChangedEventArgs e)
+        {
+            if (d is not Block block) return;
+            block.PropertyChanged?.Invoke(block, new(e.Property.Name));
+        }
+    }
+
+    private class Column
+    {
+        public List<Block> Blocks { get; set; } = new();
+
+        public List<List<Block>>? Columns { get; set; } = null;
+    }
+
+    private List<Block> _blockList = new();
+    private Dictionary<DateTime, Dictionary<object, Column>> _blocks = new();
+
+    private List<IDisposable> _oldSubscriptions = new();
+
+    private List<object> _columns = new();
+    private List<(DateTime, object)> _columnList = new();
+
+    private IEnumerable<KeyValuePair<(DateTime, object), Column>> _allColumns =>
+        _blocks.SelectMany(x => x.Value.Select(y => new KeyValuePair<(DateTime, object), Column>((x.Key, y.Key), y.Value)));
+
+    private class ActionDisposable(Action onDispose) : IDisposable
+    {
+        public void Dispose()
+        {
+            onDispose();
+        }
+    }
+
+    private bool RecreateBlocksList()
+    {
+        if (ItemsSource is null) return false;
+
+        var columnBinding = ColumnMapping;
+        var dateBinding = DateMapping;
+        var startBinding = StartTimeMapping;
+        var endBinding = EndTimeMapping;
+        if(columnBinding is null || dateBinding is null || startBinding is null || endBinding is null)
+        {
+            return false;
+        }
+
+        foreach(var subscription in _oldSubscriptions)
+        {
+            subscription.Dispose();
+        }
+        _oldSubscriptions.Clear();
+        _blockList.Clear();
+        foreach(var item in ItemsSource)
+        {
+            if (item is null) continue;
+            var block = new Block(this, item);
+            block.SetBinding(Block.ColumnProperty, columnBinding);
+            block.SetBinding(Block.StartTimeProperty, startBinding);
+            block.SetBinding(Block.EndTimeProperty, endBinding);
+            block.SetBinding(Block.DateProperty, dateBinding);
+            block.DataContext = item;
+            block.Background = Colors.Transparent.ToBrush();
+
+            block.PropertyChanged += Block_PropertyChanged;
+            _oldSubscriptions.Add(new ActionDisposable(() => block.PropertyChanged -= Block_PropertyChanged));
+
+            block.ContentControl.MouseLeftButtonDown += (o, e) =>
+            {
+                Block_MouseLeftButtonDown(block, e);
+            };
+            block.ContentControl.MouseLeftButtonUp += (o, e) =>
+            {
+                Block_MouseLeftButtonUp(block, e);
+            };
+
+            _blockList.Add(block);
+        }
+        return true;
+    }
+
+    private void RefreshColumns(bool itemsChanged = false)
+    {
+        if (itemsChanged)
+        {
+            if (!RecreateBlocksList()) return;
+        }
+
+        _blocks.Clear();
+
+        _columns.Clear();
+        var autoGenerateColumns = true;
+        if(Columns is not null)
+        {
+            autoGenerateColumns = false;
+            foreach(var column in Columns)
+            {
+                if(column is null) continue;
+                _columns.Add(column);
+            }
+        }
+        foreach(var block in _blockList)
+        {
+            var column = block.Column;
+            var date = block.Date;
+            if(column is null)
+            {
+                continue;
+            }
+            if(!_blocks.TryGetValue(date, out var dateBlocks))
+            {
+                dateBlocks = new();
+                _blocks.Add(date, dateBlocks);
+                if (!autoGenerateColumns)
+                {
+                    foreach(var col in _columns)
+                    {
+                        if(!dateBlocks.TryAdd(col, new()))
+                        {
+                            throw new Exception($"Duplicate column {column} in Calendar");
+                        }
+                    }
+                }
+            }
+            if(!dateBlocks.TryGetValue(column, out var columnBlocks))
+            {
+                if (!autoGenerateColumns) continue;
+
+                columnBlocks = new();
+                dateBlocks.Add(column, columnBlocks);
+                _columns.Add(column);
+            }
+
+            columnBlocks.Blocks.Add(block);
+        }
+        HeaderBorder.Visibility = _columns.Count <= 1 ? Visibility.Collapsed : Visibility.Visible;
+    }
+
+    private void Block_PropertyChanged(object? sender, PropertyChangedEventArgs e)
+    {
+        if (sender is not Block block) return;
+
+        if(e.PropertyName == nameof(Block.Column))
+        {
+            Render(columnsChanged: true);
+        }
+        else if(e.PropertyName == nameof(Block.StartTime)
+            || e.PropertyName == nameof(Block.EndTime))
+        {
+            UpdateBlock(block);
+        }
+    }
+
+    private double _colWidth;
+    private double _colSpace;
+    private double _rowHeight;
+
+    private bool _columnsChanged = false;
+    private bool _itemsChanged = false;
+    private bool _recalculatePositions = false;
+    private bool _rerendering = false;
+
+    private void Render(bool columnsChanged = false, bool itemsChanged = false, bool recalculatePositions = false)
+    {
+        _columnsChanged = _columnsChanged || columnsChanged;
+        _itemsChanged = _itemsChanged || itemsChanged;
+        _recalculatePositions = _recalculatePositions || recalculatePositions;
+        if (!_rerendering)
+        {
+            _rerendering = true;
+            Dispatcher.BeginInvoke(DoRender);
+        }
+    }
+
+    private double _lastColWidth;
+
+    private void DoRender()
+    {
+        _rerendering = false;
+
+        var itemsChanged = _itemsChanged;
+        var columnsChanged = _columnsChanged;
+        var recalculatePositions = _recalculatePositions;
+        _columnsChanged = false;
+        _itemsChanged = false;
+        _recalculatePositions = false;
+        if (itemsChanged || columnsChanged)
+        {
+            RefreshColumns(itemsChanged: itemsChanged);
+        }
+        if (recalculatePositions)
+        {
+            foreach(var (column, columnBlocks) in _allColumns)
+            {
+                columnBlocks.Columns = null;
+            }
+        }
+
+        var nRows = (24 / RowInterval.TotalHours);
+        var rowHeight = Math.Max(RowHeight, MainScroll.ActualHeight / nRows);
+
+        MainCanvas.Children.Clear();
+        MainCanvas.Height = rowHeight * nRows;
+
+        var minColWidth = MinimumColumnWidth;
+        var colSpace = 1;
+
+        var nColumns = 0;
+        foreach (var (column, columnBlocks) in _allColumns)
+        {
+            columnBlocks.Columns ??= RecalculateBlockPositionsForDay(columnBlocks.Blocks);
+            // columnsPerDay = Math.Max(columnsPerDay, columnBlocks.Columns.Count);
+            nColumns += columnBlocks.Columns.Count;
+        }
+        // nColumns = columnsPerDay * _blocks.Count
+
+        var colWidth = (Math.Max((MainScroll.ActualWidth - colSpace * (_blocks.Sum(x => x.Value.Count) - 1)) / nColumns, minColWidth));
+        var lastColWidth = _lastColWidth;
+        _lastColWidth = colWidth;
+
+        var updateHeaders = colWidth != lastColWidth || columnsChanged || itemsChanged;
+
+        if (updateHeaders)
+        {
+            HeaderCanvas.Children.Clear();
+            DateCanvas.Children.Clear();
+        }
+
+        ClearHeldSelection();
+
+        _rowHeight = rowHeight;
+        _colWidth = colWidth;
+        _colSpace = colSpace;
+
+        var minY = double.MaxValue;
+
+        var colX = 0.0;
+
+        var dates = _blocks.Keys.ToArray();
+        Array.Sort(dates);
+
+        _columnList.Clear();
+        var columnIdx = 0;
+        foreach(var date in dates)
+        {
+            if (!_blocks.TryGetValue(date, out var dateBlocks)) continue;
+
+            if (updateHeaders)
+            {
+                var nDateColumns = dateBlocks.Sum(x => x.Value.Columns!.Count);
+                var dateHeader = new ContentControl
+                {
+                    Content = date,
+                    Width = colWidth * nDateColumns + colSpace * (nDateColumns - 1)
+                };
+                dateHeader.Bind(ContentControl.ContentTemplateProperty, this, x => x.DateTemplate);
+                Canvas.SetLeft(dateHeader, colX);
+                DateCanvas.Children.Add(dateHeader);
+                dateHeader.SizeChanged += DateHeader_SizeChanged;
+            }
+
+            var dateColumnIndex = 0;
+            foreach(var columnKey in _columns)
+            {
+                if(!dateBlocks.TryGetValue(columnKey, out var columnBlocks))
+                {
+                    continue;
+                }
+                _columnList.Add((date, columnKey));
+
+                if (updateHeaders)
+                {
+                    var columnHeader = new ContentControl
+                    {
+                        Content = columnKey,
+                        Width = colWidth * columnBlocks.Columns!.Count,
+                    };
+                    columnHeader.Bind(ContentControl.ContentTemplateProperty, this, x => x.HeaderTemplate);
+                    Canvas.SetLeft(columnHeader, colX);
+                    HeaderCanvas.Children.Add(columnHeader);
+                    columnHeader.SizeChanged += ColumnHeader_SizeChanged;
+                }
+
+                // Add cell placeholders
+                var rowIdx = 0;
+                for(var time = TimeSpan.Zero; time < TimeSpan.FromHours(24); time += RowInterval)
+                {
+                    var rectangle = new Rectangle
+                    {
+                        Width = colWidth * columnBlocks.Columns!.Count,
+                        Height = RowHeight,
+                        Fill = new SolidColorBrush(Colors.Transparent),
+                    };
+                    rectangle.MouseEnter += (o, e) =>
+                    {
+                        rectangle.Fill = Colors.LightBlue.ToBrush();
+                    };
+                    rectangle.MouseLeave += (o, e) =>
+                    {
+                        rectangle.Fill = Colors.Transparent.ToBrush();
+                    };
+                    rectangle.MouseLeftButtonDown += Rectangle_MouseLeftButtonDown;
+                    rectangle.MouseLeftButtonUp += Rectangle_MouseLeftButtonUp;
+                    Canvas.SetLeft(rectangle, colX);
+                    Canvas.SetTop(rectangle, rowIdx * rowHeight);
+                    MainCanvas.Children.Add(rectangle);
+
+                    ++rowIdx;
+                }
+
+                foreach(var column in columnBlocks.Columns!)
+                {
+                    foreach(var block in column)
+                    {
+                        var blockY = GetRow(block.StartTime) * rowHeight;
+                        minY = Math.Min(blockY, minY);
+                        Canvas.SetTop(block, blockY);
+                        Canvas.SetLeft(block, colX);
+                        block.Height = Math.Max((GetRow(block.EndTime) - GetRow(block.StartTime)) * rowHeight, 5);
+                        block.Width = colWidth * block.NColumns;
+                        MainCanvas.Children.Add(block);
+                    }
+
+                    colX += colWidth;
+                }
+
+                // Add Header separators
+                if(columnIdx < nColumns - 1)
+                {
+                    var rectangle = new Rectangle
+                    {
+                        Width = 0.75,
+                        Height = MainCanvas.Height,
+                        Fill = new SolidColorBrush(Colors.LightGray)
+                    };
+                    Canvas.SetLeft(rectangle, colX);
+                    MainCanvas.Children.Add(rectangle);
+
+                    if (updateHeaders)
+                    {
+                        var headRectangle = new Rectangle
+                        {
+                            Width = 0.75,
+                            Fill = new SolidColorBrush(Colors.LightGray)
+                        };
+                        headRectangle.Bind(Rectangle.HeightProperty, HeaderCanvas, x => x.ActualHeight);
+                        Canvas.SetLeft(headRectangle, colX);
+                        HeaderCanvas.Children.Add(headRectangle);
+
+                        if(dateColumnIndex == dateBlocks.Count - 1)
+                        {
+                            var dateRectangle = new Rectangle
+                            {
+                                Width = 0.75,
+                                Fill = new SolidColorBrush(Colors.LightGray)
+                            };
+                            dateRectangle.Bind(Rectangle.HeightProperty, DateCanvas, x => x.ActualHeight);
+                            Canvas.SetLeft(dateRectangle, colX);
+                            DateCanvas.Children.Add(dateRectangle);
+                        }
+                    }
+
+                    colX += colSpace;
+                }
+                ++dateColumnIndex;
+                ++columnIdx;
+            }
+        }
+
+        MainCanvas.Width = Math.Floor(colX);
+        HeaderCanvas.Width = Math.Floor(colX);
+        DateCanvas.Width = Math.Floor(colX);
+
+        VerticalScroll.Minimum = 0;
+        VerticalScroll.Maximum = MainCanvas.Height - MainScroll.ActualHeight;
+        VerticalScroll.ViewportSize = MainScroll.ActualHeight;
+        VerticalScroll.Visibility = VerticalScroll.Maximum < 1 ? Visibility.Collapsed : Visibility.Visible;
+
+        HorizontalScroll.Minimum = 0;
+        HorizontalScroll.Maximum = MainCanvas.Width - MainScroll.ActualWidth;
+        HorizontalScroll.ViewportSize = MainScroll.ActualWidth;
+        HorizontalScroll.Visibility = HorizontalScroll.Maximum < 1 ? Visibility.Collapsed : Visibility.Visible;
+
+        if(minY == double.MaxValue)
+        {
+            MainScroll.ScrollToHorizontalOffset(_currentScroll.x);
+            MainScroll.ScrollToVerticalOffset(_currentScroll.y);
+        }
+        else
+        {
+            MainScroll.ScrollToHorizontalOffset(_currentScroll.x);
+            MainScroll.ScrollToVerticalOffset(Math.Max(minY - RowHeight / 2, _currentScroll.y));
+        }
+
+        var lines = new List<FrameworkElement>();
+
+        LabelCanvas.Children.Clear();
+        LabelCanvas.Height = MainCanvas.Height;
+
+        var y = rowHeight;
+        for(var time = RowInterval; time < TimeSpan.FromHours(24); time += RowInterval)
+        {
+            var rectangle = new Rectangle
+            {
+                Width = MainCanvas.Width,
+                Height = 0.75,
+                Fill = new SolidColorBrush(Colors.LightGray)
+            };
+            Canvas.SetLeft(rectangle, 0);
+            Canvas.SetTop(rectangle, y);
+            lines.Add(rectangle);
+
+            var block = new TextBlock
+            {
+                Text = time.ToString("hh\\:mm"),
+                Margin = new(0, -5, 0, 0),
+                FontSize = 10
+            };
+            block.SizeChanged += Block_SizeChanged;
+            Canvas.SetTop(block, y);
+            LabelCanvas.Children.Add(block);
+
+            y += rowHeight;
+        }
+
+        for(var i = 0; i < lines.Count; ++i)
+        {
+            MainCanvas.Children.Insert(i, lines[i]);
+        }
+    }
+
+    private bool TryGetBlockFromPosition(MouseEventArgs e, out DateTime blockDate, [NotNullWhen(true)] out object? column, out TimeSpan start, out TimeSpan end, out int index)
+    {
+        var point = e.GetPosition(MainCanvas);
+        var rowIdx = (int)Math.Floor(point.Y / _rowHeight);
+
+        start = RowInterval * rowIdx;
+        end = RowInterval * (rowIdx + 1);
+        if(start.TotalHours < 0)
+        {
+            start = TimeSpan.Zero;
+        }
+        if(end.TotalHours >= 24)
+        {
+            end = TimeSpan.FromHours(24).Subtract(TimeSpan.FromTicks(1));
+        }
+        column = null;
+        index = -1;
+        blockDate = DateTime.MinValue;
+        var x = point.X;
+        foreach(var (date, columnKey) in _columnList)
+        {
+            if (!_blocks.TryGetValue(date, out var dateBlocks)
+                || !dateBlocks.TryGetValue(columnKey, out var columnBlocks)) continue;
+
+            var colWidth = columnBlocks.Columns!.Count * _colWidth + _colSpace;
+
+            if(x < colWidth)
+            {
+                column = columnKey;
+                index = Math.Min((int)Math.Floor(x / _colWidth), columnBlocks.Columns!.Count - 1);
+                blockDate = date;
+                break;
+            }
+            else
+            {
+                x -= colWidth;
+            }
+        }
+        return column is not null;
+    }
+
+    private CancellationTokenSource? cts = null;
+
+    private void PressedAction(Action onHeld)
+    {
+        cts?.Cancel();
+        cts = new();
+        Task.Delay(1000).ContinueWith(task =>
+        {
+            cts = null;
+            onHeld();
+        }, cts.Token, TaskContinuationOptions.None, TaskScheduler.FromCurrentSynchronizationContext());
+    }
+    private void ReleasedAction(Action onRelease)
+    {
+        if(cts is not null)
+        {
+            cts.Cancel();
+            onRelease();
+        }
+    }
+
+    private void Block_MouseLeftButtonDown(object sender, System.Windows.Input.MouseButtonEventArgs e)
+    {
+        if (sender is not Block block) return;
+        e.Handled = true;
+
+        PressedAction(() => BlockHeld?.Invoke(this, new(block.Content, block.Column, block.Date, block.StartTime, block.EndTime)));
+    }
+
+    private void Block_MouseLeftButtonUp(object sender, System.Windows.Input.MouseButtonEventArgs e)
+    {
+        if (sender is not Block block) return;
+        e.Handled = true;
+
+        ReleasedAction(() => BlockClicked?.Invoke(this, new(block.Content, block.Column, block.Date, block.StartTime, block.EndTime)));
+    }
+
+    private Border? _heldSelection;
+
+    public void ClearHeldSelection()
+    {
+        MainCanvas.Children.Remove(_heldSelection);
+        _heldSelection = null;
+    }
+
+    private void Rectangle_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
+    {
+        if (sender is not UIElement element) return;
+        if (!TryGetBlockFromPosition(e, out var date, out var column, out var start, out var end, out var index)) return;
+
+        var pos = e.GetPosition(MainCanvas);
+         ClearHeldSelection();
+
+        PressedAction(() =>
+        {
+            if(_heldSelection is null)
+            {
+                _heldSelection = new Border
+                {
+                    Background = Colors.Black.ToBrush(0.2)
+                };
+            }
+            else
+            {
+                MainCanvas.Children.Remove(_heldSelection);
+            }
+
+            var element = MainCanvas.Children.OfType<Block>().FirstOrDefault(x => x.Date == date && x.Column == column);
+            if(element is not null)
+            {
+                var idx = MainCanvas.Children.IndexOf(element);
+                MainCanvas.Children.Insert(idx + 1, _heldSelection);
+            }
+            else
+            {
+                MainCanvas.Children.Add(_heldSelection);
+            }
+
+            var blockStart = start;
+            var blockEnd = TimeSpan.MinValue;
+
+            var x = 0.0;
+            foreach(var (columnDate, columnKey) in _columnList)
+            {
+                if (!_blocks.TryGetValue(columnDate, out var dateBlocks)
+                    || !dateBlocks.TryGetValue(columnKey, out var columnBlocks)) continue;
+
+                var colWidth = columnBlocks.Columns!.Count * _colWidth + _colSpace;
+                if(date == columnDate && column == columnKey)
+                {
+                    x += index * _colWidth;
+
+                    var list = columnBlocks.Blocks.Where(x =>
+                    {
+                        return x.ColumnIndex <= index && index < x.ColumnIndex + x.NColumns;
+                    }).ToList();
+                    list.SortBy(x => x.StartTime);
+                    for(int i = 0; i < list.Count; ++i)
+                    {
+                        if(start < list[i].StartTime)
+                        {
+                            blockEnd = list[i].StartTime;
+                            break;
+                        }
+                        else
+                        {
+                            blockStart = list[i].EndTime;
+                            if(i == list.Count - 1)
+                            {
+                                blockEnd = TimeSpan.FromHours(24).Subtract(TimeSpan.FromTicks(1));
+                            }
+                        }
+                    }
+
+                    break;
+                }
+                x += colWidth;
+            }
+
+            if(blockEnd == TimeSpan.MinValue)
+            {
+                blockStart = start;
+                blockEnd = end;
+            }
+            var top = (blockStart.TotalHours / RowInterval.TotalHours) * RowHeight;
+            var height = ((blockEnd - blockStart).TotalHours / RowInterval.TotalHours) * RowHeight;
+
+            var width = _colWidth;
+
+            var duration = TimeSpan.FromSeconds(0.2);
+
+            var leftAnimation = new DoubleAnimation
+            {
+                From = pos.X,
+                To = x,
+                Duration = duration
+            };
+            Storyboard.SetTarget(leftAnimation, _heldSelection);
+            Storyboard.SetTargetProperty(leftAnimation, new PropertyPath("(Canvas.Left)"));
+            var widthAnimation = new DoubleAnimation
+            {
+                From = 0,
+                To = width,
+                Duration = duration
+            };
+            Storyboard.SetTarget(widthAnimation, _heldSelection);
+            Storyboard.SetTargetProperty(widthAnimation, new PropertyPath("Width"));
+            var topAnimation = new DoubleAnimation
+            {
+                From = pos.Y,
+                To = top,
+                Duration = duration
+            };
+            Storyboard.SetTarget(topAnimation, _heldSelection);
+            Storyboard.SetTargetProperty(topAnimation, new PropertyPath("(Canvas.Top)"));
+            var heightAnimation = new DoubleAnimation
+            {
+                From = 0,
+                To = height,
+                Duration = duration
+            };
+            Storyboard.SetTarget(heightAnimation, _heldSelection);
+            Storyboard.SetTargetProperty(heightAnimation, new PropertyPath("Height"));
+            var storyBoard = new Storyboard();
+            storyBoard.Children.Add(leftAnimation);
+            storyBoard.Children.Add(topAnimation);
+            storyBoard.Children.Add(widthAnimation);
+            storyBoard.Children.Add(heightAnimation);
+            storyBoard.Completed += (o, e) =>
+            {
+                BlockHeld?.Invoke(this, new(null, column, date, blockStart, blockEnd));
+            };
+            storyBoard.Begin();
+        });
+    }
+
+    private void Rectangle_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
+    {
+         if (!TryGetBlockFromPosition(e, out var date, out var column, out var start, out var end, out var index)) return;
+         ReleasedAction(() =>
+         {
+             BlockClicked?.Invoke(this, new(null, column, date, start, end));
+         });
+    }
+
+    private void ColumnHeader_SizeChanged(object? sender, SizeChangedEventArgs e)
+    {
+        HeaderCanvas.Height = HeaderCanvas.Children.OfType<FrameworkElement>().Select(x => x.ActualHeight).Max();
+    }
+    private void DateHeader_SizeChanged(object sender, SizeChangedEventArgs e)
+    {
+        DateCanvas.Height = DateCanvas.Children.OfType<FrameworkElement>().Select(x => x.ActualHeight).Max();
+    }
+
+    private void Block_SizeChanged(object? sender, SizeChangedEventArgs e)
+    {
+        LabelCanvas.Width = LabelCanvas.Children.OfType<FrameworkElement>().Select(x => x.ActualWidth).Max();
+    }
+
+    private double GetRow(TimeSpan time)
+    {
+        return time.TotalHours / RowInterval.TotalHours;
+    }
+
+    private static List<List<Block>> RecalculateBlockPositionsForDay(List<Block> dayBlocks)
+    {
+        dayBlocks.SortBy(x => x.StartTime);
+        var columns = new List<List<Block>>();
+
+        var remainingBlocks = dayBlocks;
+        while(remainingBlocks.Count > 0)
+        {
+            // At least one block will be moved, so we can use 1 less than the remaining as capacity.
+            var tempRemainingBlocks = new List<Block>(remainingBlocks.Count - 1);
+            var newBlocks = new List<Block>(remainingBlocks.Count);
+            var curTime = TimeSpan.MinValue;
+            Block? curBlock = null;
+            foreach(var block in remainingBlocks)
+            {
+                if(curBlock is not null && block.StartTime < curTime)
+                {
+                    tempRemainingBlocks.Add(block);
+                }
+                else
+                {
+                    newBlocks.Add(block);
+                    curTime = block.EndTime;
+                    curBlock = block;
+                }
+            }
+            columns.Add(newBlocks);
+            remainingBlocks = tempRemainingBlocks;
+        }
+        for(int i = 0; i < columns.Count; ++i)
+        {
+            foreach(var block in columns[i])
+            {
+                var nColumns = -1;
+                for(int j = i + 1; j < columns.Count; ++j)
+                {
+                    foreach(var block2 in columns[j])
+                    {
+                        if(block.StartTime < block2.EndTime && block.EndTime > block2.StartTime)
+                        {
+                            nColumns = j - i;
+                            break;
+                        }
+                    }
+                    if(nColumns > -1)
+                    {
+                        break;
+                    }
+                }
+                block.NColumns = nColumns > -1 ? nColumns : columns.Count - i;
+                block.ColumnIndex = i;
+            }
+        }
+        if(columns.Count == 0)
+        {
+            columns.Add(new());
+        }
+        return columns;
+    }
+
+    private void UpdateBlock(Block block)
+    {
+        Render(recalculatePositions: true);
+    }
+}

+ 21 - 2
inabox.wpf/WPFUtils.cs

@@ -275,18 +275,20 @@ public static class WPFUtils
         return trigger;
     }
 
-    public static void Bind<T, TProperty>(
-        this FrameworkElement element,
+    public static TElement Bind<TElement, T, TProperty>(
+        this TElement element,
         DependencyProperty property,
         T source,
         Expression<Func<T, TProperty>> expression,
         IValueConverter? converter = null,
         string? format = null)
+        where TElement : FrameworkElement
     {
         element.SetBinding(
             property,
             CreateBinding(source, expression, converter, format)
         );
+        return element;
     }
 
     public static void Bind<T, TProperty>(
@@ -304,6 +306,23 @@ public static class WPFUtils
         );
     }
 
+    public static TElement Bind<TElement, T, TProperty>(
+        this TElement element, 
+        DependencyProperty property, 
+        Expression<Func<T, TProperty>> expression, 
+        IValueConverter? converter = null,
+        BindingMode mode = BindingMode.Default,
+        string? format = null,
+        RelativeSource? relativeSource = null)
+        where TElement : FrameworkElement
+    {
+        element.SetBinding(
+            property,
+            CreateBinding(expression, converter, mode, format, relativeSource: relativeSource)
+        );
+        return element;
+    }
+
     public static FrameworkElementFactory Bind<T, TProperty>(
         this FrameworkElementFactory element,
         DependencyProperty property,