From 3b317fc3080b183892a4b0c419e7c32d64fe6af5 Mon Sep 17 00:00:00 2001 From: Dan Walmsley Date: Thu, 28 May 2020 15:58:13 -0300 Subject: [PATCH 001/191] initial progressbar port. --- .../Accents/FluentBaseDark.xaml | 5 + .../Accents/FluentBaseLight.xaml | 6 + .../Accents/FluentControlResourcesDark.xaml | 9 ++ .../Accents/FluentControlResourcesLight.xaml | 8 ++ src/Avalonia.Themes.Fluent/ProgressBar.xaml | 108 ++++++++++++++---- 5 files changed, 116 insertions(+), 20 deletions(-) diff --git a/src/Avalonia.Themes.Fluent/Accents/FluentBaseDark.xaml b/src/Avalonia.Themes.Fluent/Accents/FluentBaseDark.xaml index 7a715bbde7..aed5ac1360 100644 --- a/src/Avalonia.Themes.Fluent/Accents/FluentBaseDark.xaml +++ b/src/Avalonia.Themes.Fluent/Accents/FluentBaseDark.xaml @@ -162,6 +162,11 @@ + + + + + diff --git a/src/Avalonia.Themes.Fluent/Accents/FluentBaseLight.xaml b/src/Avalonia.Themes.Fluent/Accents/FluentBaseLight.xaml index 5c6286a0bc..50d29f44ab 100644 --- a/src/Avalonia.Themes.Fluent/Accents/FluentBaseLight.xaml +++ b/src/Avalonia.Themes.Fluent/Accents/FluentBaseLight.xaml @@ -164,6 +164,12 @@ + + + + + + diff --git a/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesDark.xaml b/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesDark.xaml index 7364c339f1..21181bf19d 100644 --- a/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesDark.xaml +++ b/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesDark.xaml @@ -169,6 +169,15 @@ + + 0.6 + 4 + 0 + + + + + diff --git a/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesLight.xaml b/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesLight.xaml index 15e157f573..8ce75862f1 100644 --- a/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesLight.xaml +++ b/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesLight.xaml @@ -168,6 +168,14 @@ + + 0.6 + 4 + 0 + + + + diff --git a/src/Avalonia.Themes.Fluent/ProgressBar.xaml b/src/Avalonia.Themes.Fluent/ProgressBar.xaml index d3c2f0c784..39a573303c 100644 --- a/src/Avalonia.Themes.Fluent/ProgressBar.xaml +++ b/src/Avalonia.Themes.Fluent/ProgressBar.xaml @@ -1,24 +1,92 @@ - - - + + + + + + + + + + + + - + + + + + + + + + + @@ -68,388 +55,44 @@ - - - - - - - - - - - - - - - - - - - - - - - - - + - + - + + + + + + + From 6b1f62777fe1dac330e00f801f2ac7d77c6ef5b7 Mon Sep 17 00:00:00 2001 From: amwx Date: Thu, 11 Jun 2020 20:03:43 -0500 Subject: [PATCH 009/191] Add DateTimeFormatter class --- .../DateTimePickers/DateTimeFormatter.cs | 335 ++++++++++++++++++ 1 file changed, 335 insertions(+) create mode 100644 src/Avalonia.Controls/DateTimePickers/DateTimeFormatter.cs diff --git a/src/Avalonia.Controls/DateTimePickers/DateTimeFormatter.cs b/src/Avalonia.Controls/DateTimePickers/DateTimeFormatter.cs new file mode 100644 index 0000000000..46b1c07ebc --- /dev/null +++ b/src/Avalonia.Controls/DateTimePickers/DateTimeFormatter.cs @@ -0,0 +1,335 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Avalonia.Controls +{ + /// + /// Formats a DateTimeOffset by the specified pattern + /// Based on, but not replica, the Windows.Globalization.DateTimeFormatting.DateTimeFormatter + /// https://docs.microsoft.com/en-us/uwp/api/windows.globalization.datetimeformatting.datetimeformatter?view=winrt-19041 + /// + /// Currently only the Gregorian Calendar is supported, and regions and languages are not supported. + /// Formatting timezones is also not supported, that's a large task + /// + /// + /// /// Most formats used in UWP/WinUI are compatible here, but not all + /// This DateTimeFormatter will also work with TimeSpans (only patterns though), + /// in addition to the default DateTime/DateTimeOffset + /// + /// + /// Formats are broken down into Patterns and Templates, which are "complete" patterns + /// If a Template is used, only 1 may be provided and cannot be mixed with anything else + /// Multiple patterns can be specified, and can be mixed with other text. All patterns + /// must be enclosed in curly braces {}, e.g. {dayofweek}, or in xaml "{}{dayofweek}" + /// NOTE: Formats and Templates are case sensitive + /// + /// + public sealed class DateTimeFormatter + { + public DateTimeFormatter(string formatString) + { + _format = formatString; + var reg = new Regex("{([^{}]*)}"); + var results = reg.Matches(formatString).Cast().Select(m => m.Groups[1].Value).Distinct().ToList(); + Formats = results; + } + + public string Clock + { + get + { + if (_clock == null) + { + var timePattern = CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern; + if (timePattern.IndexOf("H") != -1) + return "24HourClock"; + return "12HourClock"; + } + return _clock; + } + set + { + _clock = value; + } + } + public List Formats { get; } + + /// + /// Formats a DateTimeOffset object by the format of the + /// + public string Format(DateTimeOffset toFormat) + { + string ret = _format; + + if (Formats.Count == 0) + { + return GetFormatTemplate(toFormat); + } + + foreach (var item in Formats) + { + var p = $"{{{item}}}"; + var ns = Regex.Replace(ret, $"{{{Regex.Escape(item)}}}", GetFormatValue(item, toFormat)); + ret = ns; + } + + return ret; + } + + /// + /// Formats a TimeSpan object by the format of the + /// + public string Format(TimeSpan toFormat) + { + string ret = _format; + + foreach (var item in Formats) + { + var p = $"{{{item}}}"; + var ns = Regex.Replace(ret, $"{{{Regex.Escape(item)}}}", GetFormatValue(item, toFormat)); + ret = ns; + } + + return ret; + } + + private string GetFormatValue(string pattern, DateTimeOffset dt) + { + var sp = pattern.Split(new[] { "." }, StringSplitOptions.None); + var type = sp[0].Trim(); + var desc = sp[1].Trim(); + var len = desc.Contains("(") ? int.Parse(desc.Substring(desc.IndexOf("(") + 1, desc.Length - desc.IndexOf(")"))) : -1; + if (type.Equals("era")) + { + return ""; + } + else if (type.Equals("year")) + { + var yr = dt.Year; + if (len == -1) + return yr.ToString(); + else if (len <= 2) + return yr.ToString().Substring(2); + else + return yr.ToString(); + } + else if (type.Equals("month")) + { + var mon = dt.Month; + var fmt = CultureInfo.CurrentCulture.DateTimeFormat; + if (len == -1) + return desc == "full" ? fmt.GetMonthName(dt.Month) : fmt.GetAbbreviatedMonthName(dt.Month); + var nm = desc == "full" ? fmt.GetMonthName(dt.Month) : fmt.GetAbbreviatedMonthName(dt.Month); + len = Math.Min(nm.Length, Math.Max(0, len)); + return nm.Substring(0, len); + } + else if (type.Equals("dayofweek")) + { + var dow = dt.DayOfWeek; + var fmt = CultureInfo.CurrentCulture.DateTimeFormat; + if (len == -1) + return desc == "full" ? fmt.GetDayName(dt.DayOfWeek) : fmt.GetAbbreviatedDayName(dt.DayOfWeek); + var nm = desc == "full" ? fmt.GetDayName(dt.DayOfWeek) : fmt.GetAbbreviatedDayName(dt.DayOfWeek); + len = Math.Min(nm.Length, Math.Max(0, len)); + return nm.Substring(0, len); + } + else if (type.Equals("day")) + { + var dy = dt.Day; + if (len == -1) + return dy.ToString(); + if (len < 1 || len > 2) + len = 2; + return dy.ToString($"D{len}"); + } + else if (type.Equals("period")) + { + if (Clock == "24HourClock") + return ""; + + var hr = dt.Hour; + if (hr >= 12) + return CultureInfo.CurrentCulture.DateTimeFormat.PMDesignator; + else + return CultureInfo.CurrentCulture.DateTimeFormat.AMDesignator; + } + else if (type.Equals("hour")) + { + var shortTimePattern = CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern.ToLower(); + var resolvedLength = 1; + //valid Hours: h, hh + if (shortTimePattern.Contains("hh")) + { + resolvedLength = 2; + } + var hr = dt.Hour; + + if (Clock == "12HourClock") + hr = hr >= 13 ? hr - 12 : hr; + + if (len == -1) + return hr.ToString($"D{resolvedLength}"); + if (len < 1 || len > 2) + len = resolvedLength; + + return hr.ToString($"D{len}"); + } + else if (type.Equals("minute")) + { + var shortTimePattern = CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern.ToLower(); + var resolvedLength = 1; + //valid mintues: m, mm + if (shortTimePattern.Contains("hh")) + { + resolvedLength = 2; + } + + var dy = dt.Minute; + if (len == -1) + return dy.ToString($"D{resolvedLength}"); + if (len < 1 || len > 2) + len = resolvedLength; + + return dy.ToString($"D{len}"); + } + else if (type.Equals("second")) + { + var shortTimePattern = CultureInfo.CurrentCulture.DateTimeFormat.LongTimePattern.ToLower(); + var resolvedLength = 1; + //valid seconds: s, ss + if (shortTimePattern.Contains("ss")) + { + resolvedLength = 2; + } + + var dy = dt.Second; + if (len == -1) + return dy.ToString($"D{resolvedLength}"); + if (len < 1 || len > 2) + len = resolvedLength; + + return dy.ToString($"D{len}"); + } + else if (type.Equals("timezone")) + { + //Timezones aren't supported yet, that's a HUGE task + //Multiple timezones can exist for a given offset from UTC, + //So info about region is necessary to obtain it + //It'd be nice if MS would move stuff from WinRT globalization namespace to .net + throw new NotSupportedException("Timezones aren't supported yet"); + } + + throw new ArgumentException("Invalid format"); + + } + + private string GetFormatValue(string pattern, TimeSpan ts) + { + var sp = pattern.Split(new[] { "." }, StringSplitOptions.None); + var type = sp[0].Trim(); + var desc = sp.Count() > 1 ? sp[1].Trim() : ""; + var len = desc.Contains("(") ? int.Parse(desc.Substring(desc.IndexOf("(") + 1, desc.Length - desc.IndexOf(")"))) : -1; + + if (type.Equals("hour")) + { + var shortTimePattern = CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern.ToLower(); + var resolvedLength = 1; + //valid Hours: h, hh + if (shortTimePattern.Contains("hh")) + { + resolvedLength = 2; + } + var hr = ts.Hours; + + if (Clock == "12HourClock") + hr = hr >= 13 ? hr - 12 : hr; + + if (len == -1) + return hr.ToString($"D{resolvedLength}"); + if (len < 1 || len > 2) + len = resolvedLength; + + return hr.ToString($"D{len}"); + } + else if (type.Equals("minute")) + { + var shortTimePattern = CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern.ToLower(); + var resolvedLength = 1; + //valid mintues: m, mm + if (shortTimePattern.Contains("hh")) + { + resolvedLength = 2; + } + + var dy = ts.Minutes; + if (len == -1) + return dy.ToString($"D{resolvedLength}"); + if (len < 1 || len > 2) + len = resolvedLength; + + return dy.ToString($"D{len}"); + } + else if (type.Equals("second")) + { + var shortTimePattern = CultureInfo.CurrentCulture.DateTimeFormat.LongTimePattern.ToLower(); + var resolvedLength = 1; + //valid seconds: s, ss + if (shortTimePattern.Contains("ss")) + { + resolvedLength = 2; + } + + var dy = ts.Seconds; + if (len == -1) + return dy.ToString($"D{resolvedLength}"); + if (len < 1 || len > 2) + len = resolvedLength; + + return dy.ToString($"D{len}"); + } + else if (type.Equals("period")) + { + if (Clock == "24HourClock") + return ""; + + var hr = ts.Hours; + if (hr >= 12) + return CultureInfo.CurrentCulture.DateTimeFormat.PMDesignator; + else + return CultureInfo.CurrentCulture.DateTimeFormat.AMDesignator; + } + + throw new ArgumentException("Invalid format"); + + } + + private string GetFormatTemplate(DateTimeOffset dt) + { + switch (_format) + { + case "longdate": + return dt.ToString(CultureInfo.CurrentCulture.DateTimeFormat.LongDatePattern); + case "shortdate": + return dt.ToString(CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern); + case "longtime": + return dt.ToString(CultureInfo.CurrentCulture.DateTimeFormat.LongTimePattern); + case "shorttime": + return dt.ToString(CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern); + case "iso8601": + case "sortable": + return dt.ToString(CultureInfo.CurrentCulture.DateTimeFormat.SortableDateTimePattern); + case "universalsortable": + return dt.ToString(CultureInfo.CurrentCulture.DateTimeFormat.UniversalSortableDateTimePattern); + case "rfc1123": + return dt.ToUniversalTime().ToString(CultureInfo.CurrentCulture.DateTimeFormat.RFC1123Pattern); + } + throw new ArgumentException("Invalid template"); + } + + private string _format; + private string _clock; + } +} From 427e1f62bab81c78b347780aaea0a7b6ab7f9729 Mon Sep 17 00:00:00 2001 From: amwx Date: Thu, 11 Jun 2020 20:43:03 -0500 Subject: [PATCH 010/191] Add LoopingSelector control --- .../DateTimePickers/LoopingPanel.cs | 282 +++++ .../DateTimePickers/LoopingSelector.cs | 641 +++++++++++ .../DateTimePickers/LoopingSelectorItem.cs | 98 ++ .../DateTimePickers/DateTimeFormatter.cs | 335 ++++++ .../DateTimePickers/LoopingSelector.cs | 10 + .../Templates/NumericUpDown/NumericUpDown.cs | 1020 +++++++++++++++++ .../NumericUpDownValueChangedEventArgs.cs | 16 + 7 files changed, 2402 insertions(+) create mode 100644 src/Avalonia.Controls/DateTimePickers/LoopingPanel.cs create mode 100644 src/Avalonia.Controls/DateTimePickers/LoopingSelector.cs create mode 100644 src/Avalonia.Controls/DateTimePickers/LoopingSelectorItem.cs create mode 100644 src/Avalonia.Controls/Templates/DateTimePickers/DateTimeFormatter.cs create mode 100644 src/Avalonia.Controls/Templates/DateTimePickers/LoopingSelector.cs create mode 100644 src/Avalonia.Controls/Templates/NumericUpDown/NumericUpDown.cs create mode 100644 src/Avalonia.Controls/Templates/NumericUpDown/NumericUpDownValueChangedEventArgs.cs diff --git a/src/Avalonia.Controls/DateTimePickers/LoopingPanel.cs b/src/Avalonia.Controls/DateTimePickers/LoopingPanel.cs new file mode 100644 index 0000000000..0709df7cf1 --- /dev/null +++ b/src/Avalonia.Controls/DateTimePickers/LoopingPanel.cs @@ -0,0 +1,282 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using System; + +namespace Avalonia.Controls.Primitives +{ + /// + /// Defines the Panel used by the + /// + public sealed class LoopingPanel : Panel, ILogicalScrollable + { + public LoopingPanel(LoopingSelector owner) + { + _owner = owner; + } + + protected override Size MeasureOverride(Size availableSize) + { + //It's assumed here that availableSize will have finite values + //If used in the DatePickerPresenter or TimePickerPresenter, this + //is met and works fine. If used elsewhere, ensure this is met + //Width is required for the Items & height is required for the viewportsize + if (double.IsInfinity(availableSize.Width) || double.IsInfinity(availableSize.Height)) + throw new InvalidOperationException("LoopingPanel needs finite bounds"); + + //For the measure pass, remember we only have a subset of the total items + //available. So we need to ask the LoopingSelector for it's Item count + //return a size based on the extent of all items + + var itmHgt = _owner.ItemHeight; + var itemCt = _owner.ItemCount; + var children = Children; + + var itemWid = availableSize.Width; + for (int i = 0; i < children.Count; i++) + { + //Ensure we have a proper size when measuring + (children[i] as LoopingSelectorItem).Width = itemWid; + (children[i] as LoopingSelectorItem).Height = itmHgt; + children[i].Measure(availableSize); + } + + var hei = itmHgt * itemCt; + + if (_owner.ShouldLoop) + { + //WinUI preps for somewhere around 1000 items? in loop mode, then positions the offset in the middle + //based on the SelectedItem index, that way scrolling is enabled in both directions + //Here we prep for 10 * ItemCount & position in middle to start + _extent = new Size(0, 10 * (itmHgt * itemCt) + (availableSize.Height - itmHgt)); + _viewport = new Size(0, availableSize.Height); + + if (!_hasInitLoop) + { + var selIndex = _owner.SelectedIndex; + selIndex = selIndex == -1 ? 0 : selIndex; + + //We know we are measuring for 10x items, + //so our init index is the selecteditems' index * 5 + if (double.IsNaN(initOffset)) + { + _offset = new Vector(0, (selIndex * itmHgt) * 5); + } + else + { + _offset = new Vector(0, initOffset); + initOffset = double.NaN; + } + _hasInitLoop = true; + } + } + else + { + //SelectedItem is in the middle of the LoopingSelector, so we need to account for that + //so all items can end up in this position + _extent = new Size(0, hei + (availableSize.Height - itmHgt)); + _viewport = new Size(0, availableSize.Height); + + if (!double.IsNaN(initOffset)) + { + _offset = new Vector(0, initOffset); + initOffset = double.NaN; + } + } + + //Total items visible, whether fully or partially visible + _totalItemsInViewport = (int)Math.Ceiling(_viewport.Height / itmHgt); + + RaiseScrollInvalidated(null); + return _extent; + } + + protected override Size ArrangeOverride(Size finalSize) + { + if (_owner == null || Children.Count == 0) + return base.ArrangeOverride(finalSize); + + var itemWid = finalSize.Width; + var itemHgt = _owner.ItemHeight; + var initY = (finalSize.Height / 2.0) - (itemHgt / 2.0); + var children = Children; + var offY = Offset.Y; + + //Item arranging: SelectedItem is placed in the middle of the viewport + //There are _totalItemsInViewport above & below + //In default behavior of Date/Time Picker, 9 items are visible in viewport, + //so 19 items are arrange, 9 above, 9 below, and the selected item inbetween + //The exception is if we are NOT looping & we're near the ends of the available + //items, then we only have what's left before/after selecteditem visible + if (_owner.ShouldLoop) + { + //Correct our starting Y for not starting in the middle when looping + initY -= (itemHgt * _totalItemsInViewport); + + //Sort of limitation when looping, we just place the containers & + //swap the content. With logical scrolling enabled, and the way its + //handled in Avalonia currently (both pointer wheel & gestures) + //you won't notice a difference. Though this may need updating later + //if this changes + Rect rc; + for (int i = 0; i < children.Count; i++) + { + rc = new Rect(0, initY, itemWid, itemHgt); + children[i].Arrange(rc); + initY += itemHgt; + } + } + else + { + int firstIndex = Math.Max(0, _owner.SelectedIndex - _totalItemsInViewport); + Rect rc; + for (int i = 0; i < children.Count; i++) + { + rc = new Rect(0, initY - offY + firstIndex * itemHgt, itemWid, itemHgt); + children[i].Arrange(rc); + initY += itemHgt; + } + } + + return new Size(itemWid, _extent.Height); + } + + public bool CanHorizontallyScroll { get => false; set => _ = value; } + + public bool CanVerticallyScroll { get => true; set => _ = value; } + + public bool IsLogicalScrollEnabled => true; + + public Size ScrollSize => new Size(0, _owner?.ItemHeight ?? 32); + + //4 items + public Size PageScrollSize => new Size(0, _owner?.ItemHeight * 4 ?? 128); + + public Size Extent => _extent; + + public Vector Offset + { + get => _offset; + set + { + //If we try setting the offset before we've intialized + //store the value now & set it when MeasureOverride is called + if (Extent.Height == 0) + { + initOffset = value.Y; + return; + } + + var old = _offset; + _offset = value; + + if (Children.Count == 0) + return; + + _owner.SetSelectedIndexFromOffset(value.Y); + + var itemHgt = _owner.ItemHeight; + var totalItemCount = _owner.ItemCount; + var initY = (Bounds.Height / 2) - (itemHgt / 2); + + if (_owner.ShouldLoop) + { + //If we're looping, we need to detect when we're approaching + //the min/max bounds of the scrollviewer & reset to the otherside + //to make sure we always have scrolling + //To do this, since we plan for 10x total items, we move if we're + //in the first or last "block" of items & return it to somewhere near the middle + if (value.Y > old.Y) //Scrolling Down + { + var extentOne = totalItemCount * itemHgt; + var scrollableHeight = (_extent.Height - _viewport.Height); + if (value.Y >= scrollableHeight - extentOne) + _offset = new Vector(0, value.Y - (extentOne * 5)); + } + else if (value.Y < old.Y) //Scrolling Up + { + var extentOne = totalItemCount * itemHgt; + var scrollableHeight = (_extent.Height - _viewport.Height); + if (value.Y < extentOne) + _offset = new Vector(0, value.Y + (extentOne * 5)); + } + + firstIndex = _owner.SelectedIndex - _totalItemsInViewport; + } + else + { + + var numItemsAboveSelected = (int)Math.Ceiling(initY / itemHgt); + int logicalOffset = (int)(_offset.Y / itemHgt); + + firstIndex = Math.Max(0, Math.Min(logicalOffset - numItemsAboveSelected, totalItemCount)); + + //When not looping, we actually move the containers, so if we get one + //out of bounds, recycle it to the other side + if (_offset.Y > old.Y) + { + var recycleThreshold = initY - (_totalItemsInViewport * itemHgt); + + //ScrollDown + var ct = Children.Count; + for (int i = ct - 1; i >= 0; i--) + { + if (Children[i].Bounds.Bottom <= recycleThreshold) + { + Children.Move(i, ct - 1); + } + } + } + else if (_offset.Y < old.Y) + { + var recycleThreshold = (initY + itemHgt) + (_totalItemsInViewport * itemHgt); + //ScrollUp + var ct = Children.Count; + var bottom = Bounds.Height; + for (int i = ct - 1; i >= 0; i--) + { + if (Children[i].Bounds.Top >= recycleThreshold) + { + Children.Move(i, 0); + } + } + } + } + + RaiseScrollInvalidated(EventArgs.Empty); + InvalidateArrange(); + } + } + + public Size Viewport => _viewport; + + //Not used + public bool BringIntoView(IControl target, Rect targetRect) + { + return false; + } + + //Not used + public IControl GetControlInDirection(NavigationDirection direction, IControl from) + { + return null; + } + + public void RaiseScrollInvalidated(EventArgs e) + { + ScrollInvalidated?.Invoke(this, e); + } + + private double initOffset = double.NaN; + int firstIndex = 0; + private LoopingSelector _owner; + private Size _extent; + private Size _viewport; + private Vector _offset; + private bool _hasInitLoop; + private int _totalItemsInViewport; + + public event EventHandler ScrollInvalidated; + } +} diff --git a/src/Avalonia.Controls/DateTimePickers/LoopingSelector.cs b/src/Avalonia.Controls/DateTimePickers/LoopingSelector.cs new file mode 100644 index 0000000000..283edf9df7 --- /dev/null +++ b/src/Avalonia.Controls/DateTimePickers/LoopingSelector.cs @@ -0,0 +1,641 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; +using Avalonia.Controls.Utils; +using Avalonia.Input; +using Avalonia.Interactivity; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; + +namespace Avalonia.Controls.Primitives +{ + + public delegate void SelectionChangedEventHandler(object sender, SelectionChangedEventArgs e); + + /// + /// An items control with the ability for infinite looping + /// + /// Supports UI virtualization, though doesn't inherit from ItemsControl so we manage containers and + /// realized items ourselves + /// + /// + /// In WinUI, this control isn't usable outside of the Date/Time Pickers, but here it technically can be + /// Note, it's default behavior is for the pickers though. + /// + /// + public class LoopingSelector : TemplatedControl + { + public LoopingSelector() + { + _panel = new LoopingPanel(this); + LogicalChildren.Add(_panel); + AddHandler(LoopingSelectorItem.SelectedEvent, OnItemSelected); + this.GetObservable(BoundsProperty).Subscribe(x => OnBoundsChanged(x)); + } + + static LoopingSelector() + { + FocusableProperty.OverrideDefaultValue(true); + ItemsProperty.Changed.AddClassHandler((x, v) => x.OnItemsChanged(v)); + } + + /// + /// Defines the Property + /// + public static readonly DirectProperty ItemsProperty = + AvaloniaProperty.RegisterDirect("Items", + x => x.Items, (x, v) => x.Items = v); + + /// + /// Defines the Property + /// + public static readonly DirectProperty ItemCountProperty = + AvaloniaProperty.RegisterDirect("ItemCount", + x => x.ItemCount); + + /// + /// Defines the Property + /// + public static readonly DirectProperty SelectedItemProperty = + AvaloniaProperty.RegisterDirect("SelectedItem", + x => x.SelectedItem, (x, v) => x.SelectedItem = v); + + /// + /// Defines the Property + /// + public static readonly DirectProperty SelectedIndexProperty = + AvaloniaProperty.RegisterDirect("SelectedIndex", + x => x.SelectedIndex, (x, v) => x.SelectedIndex = v); + + //UWP/WinUI has ItemWidth, ignoring, will have items just fill the width of the container + + /// + /// Defines the Property + /// + public static readonly DirectProperty ItemHeightProperty = + AvaloniaProperty.RegisterDirect("ItemHeight", + x => x.ItemHeight, (x, v) => x.ItemHeight = v); + + /// + /// Defines the Property + /// + public static readonly StyledProperty ItemTemplateProperty = + AvaloniaProperty.Register("ItemTemplate"); + + /// + /// Defines the Property + /// + public static readonly DirectProperty ShouldLoopProperty = + AvaloniaProperty.RegisterDirect("ShouldLoop", + x => x.ShouldLoop, (x, v) => x.ShouldLoop = v); + + /// + /// Gets or sets the Items + /// + public IEnumerable Items + { + get => _items; + set => SetAndRaise(ItemsProperty, ref _items, value); + } + + /// + /// Gets the number of items + /// + public int ItemCount + { + get => _itemCount; + private set => SetAndRaise(ItemCountProperty, ref _itemCount, value); + } + + /// + /// Gets or sets the SelectedIndex + /// + public int SelectedIndex + { + get => _selectedIndex; + set + { + if (Items == null || ItemCount == 0) + return; + var old = _selectedIndex; + SetAndRaise(SelectedIndexProperty, ref _selectedIndex, value); + + var oldItem = _selectedItem; + if (value == -1) + { + _selectedItem = null; + } + else + { + if (Items is IList l) + _selectedItem = l[value]; + else + _selectedItem = Items.ElementAt(value); + } + RaisePropertyChanged(SelectedItemProperty, oldItem, _selectedItem); + if (!_preventMovingScrollWhenSelecting) + UpdateOffset(); + + SelectionChangedEventArgs args = new SelectionChangedEventArgs(null, new object[] { oldItem }, new object[] { _selectedItem }); + OnSelectionChanged(this, args); + } + } + + /// + /// Gets or sets the SelectedItem + /// + public object SelectedItem + { + get + { + if (SelectedIndex == -1) + return null; + if (Items is IList l) + return l[SelectedIndex]; + else + return Items.ElementAt(SelectedIndex); + } + set + { + if (value == null) + { + SelectedIndex = -1; + } + else + { + if (Items is IList l) + SelectedIndex = l.IndexOf(value); + else + SelectedIndex = Items.IndexOf(value); + } + } + } + + /// + /// Gets or sets the height of the items + /// + public double ItemHeight + { + get => _itemHeight; + set + { + SetAndRaise(ItemHeightProperty, ref _itemHeight, value); + } + } + + /// + /// Gets or sets the item template + /// + public IDataTemplate ItemTemplate + { + get => GetValue(ItemTemplateProperty); + set => SetValue(ItemTemplateProperty, value); + } + + /// + /// Gets or sets whether the items should loop + /// + public bool ShouldLoop + { + get => _shouldLoop; + set + { + SetAndRaise(ShouldLoopProperty, ref _shouldLoop, value); + } + } + + /// + /// Raised when the SelectedItem/Index changes + /// + public event SelectionChangedEventHandler SelectionChanged; + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + _scroller = e.NameScope.Find("Scroller"); + + _scroller.Content = _panel; + + _upButton = e.NameScope.Find("UpButton"); + if (_upButton != null) + { + _upButton.Click += OnUpButtonClick; + } + + _downButton = e.NameScope.Find("DownButton"); + if (_downButton != null) + { + _downButton.Click += OnDownButtonClick; + } + } + + protected override void OnKeyDown(KeyEventArgs e) + { + switch (e.Key) + { + case Key.Up: + if (ShouldLoop) + { + var selIndex = SelectedIndex; + selIndex--; + if (selIndex < 0) + selIndex += ItemCount; + SelectedIndex = selIndex; + } + else + { + SelectedIndex = Math.Max(0, SelectedIndex - 1); + } + e.Handled = true; + break; + case Key.Down: + if (ShouldLoop) + { + var selIndex = SelectedIndex; + selIndex++; + if (selIndex >= ItemCount) + selIndex -= ItemCount; + SelectedIndex = selIndex; + } + else + { + SelectedIndex = Math.Min(ItemCount, SelectedIndex + 1); + } + e.Handled = true; + break; + case Key.PageUp: + if (ShouldLoop) + { + var selIndex = SelectedIndex; + selIndex -= 4; + if (selIndex < 0) + selIndex += ItemCount; + SelectedIndex = selIndex; + } + else + { + SelectedIndex = Math.Max(0, SelectedIndex - 4); + } + e.Handled = true; + break; + } + base.OnKeyDown(e); + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + KeyboardDevice.Instance.SetFocusedElement(this, NavigationMethod.Pointer, KeyModifiers.None); + FocusManager.Instance.Focus(this, NavigationMethod.Pointer, KeyModifiers.None); + } + + private void OnDownButtonClick(object sender, RoutedEventArgs e) + { + var selIndex = SelectedIndex; + if (selIndex == ItemCount - 1) + SelectedIndex = 0; + else + SelectedIndex++; + e.Handled = true; + } + + private void OnUpButtonClick(object sender, RoutedEventArgs e) + { + var selIndex = SelectedIndex; + if (selIndex == 0) + SelectedIndex = ItemCount - 1; + else + SelectedIndex--; + e.Handled = true; + } + + private void OnItemsChanged(AvaloniaPropertyChangedEventArgs e) + { + if (e.OldValue is INotifyCollectionChanged oldC) + { + oldC.CollectionChanged -= OnItemsCollectionChanged; + } + if (e.NewValue is INotifyCollectionChanged newC) + { + newC.CollectionChanged += OnItemsCollectionChanged; + } + + //When the entire items list changes, reset selection quietly + _selectedIndex = -1; + _selectedItem = null; + + if (Items is IList l) + { + ItemCount = l.Count; + } + else + { + ItemCount = Items.Count(); + } + + EnsureContainers(); + UpdateOffset(); + } + + private void OnItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) + { + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + ItemCount += e.NewItems.Count; + var index = e.NewStartingIndex; + //if (IsContainerIndexLoaded(index)) + //{ + // AddContainers(index, e.NewItems); + //} + EnsureContainers(); + UpdateOffset(); + break; + case NotifyCollectionChangedAction.Remove: + ItemCount -= e.OldItems.Count; + EnsureContainers(); + UpdateOffset(); + break; + case NotifyCollectionChangedAction.Reset: + _selectedIndex = 0; + _selectedItem = 0; + ItemCount = 0; + _panel.Children.Clear(); + UpdateOffset(); + break; + + //TODO - items source should be static anyway + case NotifyCollectionChangedAction.Replace: + case NotifyCollectionChangedAction.Move: + throw new NotSupportedException("Can't move or replace items in ItemsCollection"); + } + } + + /// + /// Ensures we have the correct number of containers in the LoopingSelectorPanel + /// This will add, remove, or clear the panel as necessary + /// + private void EnsureContainers() + { + if (Bounds.Height == 0) + return; + + int itemCount = ItemCount; + //How many containers we ideally want + int desiredItemsLoaded = (_totalItemsInViewport * 2) + 1; + //How many we can actually load + int itemsToLoad = itemCount < desiredItemsLoaded ? itemCount : desiredItemsLoaded; + + var realizedContainerCount = _panel.Children.Count; + + if (ShouldLoop) + { + //When looping we should ALWAYS have desiredItemsLoaded number of containers + //available + var delta = Math.Abs(realizedContainerCount - desiredItemsLoaded); + if (realizedContainerCount < desiredItemsLoaded) //Add more containers + { + List panelItems = new List(); + for (int i = 0; i < delta; i++) + { + LoopingSelectorItem lsi = new LoopingSelectorItem(); + lsi.Height = ItemHeight; + lsi.ContentTemplate = ItemTemplate; + panelItems.Add(lsi); + } + _panel.Children.AddRange(panelItems); + } + else if (realizedContainerCount > desiredItemsLoaded) //Remove extra containers + { + _panel.Children.RemoveRange(realizedContainerCount - delta, delta); + } + } + else + { + //When we're not looping, things are a little trickier, if we're near the bounds of scrolling, + //We may not have desiredItemsLoaded containers, so we need to account for that + //NumContainers here should be in range [_totalItemsInViewport, desiredItemsLoaded] + + //First index of realized items + int selIndex = SelectedIndex; + selIndex = selIndex == -1 ? 0 : selIndex; + //TODO: Clean this up... + int numItemsAboveSelected = _totalItemsInViewport; + if (selIndex - numItemsAboveSelected < 0) + numItemsAboveSelected = selIndex; + + int numItemsBelowSelected = _totalItemsInViewport; + if (selIndex + _totalItemsInViewport >= ItemCount) + numItemsBelowSelected = ItemCount - selIndex - 1; + + int neededContainers = numItemsBelowSelected + numItemsAboveSelected + 1; + int desiredContainers = (_totalItemsInViewport * 2) + 1; + int currentCount = _panel.Children.Count; + + + //Do we need containers? + var numContsToAddRemove = neededContainers - currentCount; + // Debug.WriteLine($"Above: {numItemsAboveSelected} | Below: {numItemsBelowSelected} | Add/Remove: {numContsToAddRemove}"); + + if (numContsToAddRemove > 0) //Add Containers + { + List panelItems = new List(); + + for (int i = 0; i < numContsToAddRemove; i++) + { + LoopingSelectorItem lsi = new LoopingSelectorItem(); + lsi.Height = ItemHeight; + lsi.ContentTemplate = ItemTemplate; + panelItems.Add(lsi); + } + _panel.Children.AddRange(panelItems); + } + else if (numContsToAddRemove < 0) //Remove containers + { + numContsToAddRemove = Math.Abs(numContsToAddRemove); + _panel.Children.RemoveRange(currentCount - numContsToAddRemove, numContsToAddRemove); + //Debug.WriteLine($"Containers removed {numContsToAddRemove}"); + + } + } + + if (ItemCount > 0) + SetItemContent(); + } + + /// + /// Sets the content of the loaded containers + /// + private void SetItemContent() + { + int itemCount = ItemCount; + + if (ShouldLoop) + { + var selIndex = SelectedIndex; + var panelItems = _panel.Children; + + int c = 0; + int index = selIndex == -1 ? -_totalItemsInViewport : selIndex - _totalItemsInViewport; + + while (c < panelItems.Count) + { + if (index >= ItemCount) + index -= ItemCount; + if (index < 0) + index += ItemCount; + + if (index == selIndex) + (panelItems[c] as LoopingSelectorItem).IsSelected = true; + else + (panelItems[c] as LoopingSelectorItem).IsSelected = false; + + (panelItems[c] as LoopingSelectorItem).Content = GetElementAt(index); + c++; + index++; + } + } + else + { + //We first need the first item in the realized items... + var selIndex = SelectedIndex; + var firstIndex = Math.Max(0, selIndex - _totalItemsInViewport); + var panelItems = _panel.Children; + + for (int i = 0; i < panelItems.Count; i++) + { + if (firstIndex == selIndex) + (panelItems[i] as LoopingSelectorItem).IsSelected = true; + else + (panelItems[i] as LoopingSelectorItem).IsSelected = false; + + (panelItems[i] as LoopingSelectorItem).Content = GetElementAt(firstIndex); + firstIndex++; + } + + } + } + + private object GetElementAt(int index) + { + if (index < 0 || index >= ItemCount) + return null; + + if (Items is IList l) + return l[index]; + else + return Items.Cast().ToList()[index]; + } + + /// + /// Updates the scrollviewer offset when the selectedindex changed + /// + private void UpdateOffset() + { + if (_panel == null || ItemCount == 0) + return; + + _preventUpdateSelection = true; + + if (ShouldLoop) + { + //We measure for 10x as many items, so when we set the SelectedIndex + //and need to change the offset, should set it towards the middle + //so we preserve scrolling in both directions + int selIndex = SelectedIndex; + selIndex = selIndex == -1 ? 0 : selIndex; + var extent = ItemCount * ItemHeight; + _panel.Offset = new Vector(0, (selIndex * ItemHeight) + (extent * 5)); + + } + else + { + //Not looping, just convert the SelectedIndex to an offset + //if -1, set to 0; + int selIndex = SelectedIndex; + if (ItemCount == 0 || SelectedIndex == -1) + _panel.Offset = new Vector(0, 0); + else + _panel.Offset = new Vector(0, selIndex * ItemHeight); + } + + EnsureContainers(); + + _preventUpdateSelection = false; + } + + /// + /// Updates the SelectedIndex when scrolling occurs + /// + /// + internal void SetSelectedIndexFromOffset(double offsetY) + { + if (_preventUpdateSelection) + return; + + _preventMovingScrollWhenSelecting = true; + + if (ShouldLoop) + { + var extent = ItemCount * ItemHeight; + var numExtents = offsetY / extent; + numExtents = numExtents < 0 ? 0 : Math.Truncate(numExtents); + var pixelOffset = offsetY - extent * numExtents; + + SelectedIndex = (int)(pixelOffset / ItemHeight); + } + else + { + SelectedIndex = (int)(offsetY / ItemHeight); + } + + EnsureContainers(); + + _preventMovingScrollWhenSelecting = false; + } + + private void OnItemSelected(object sender, RoutedEventArgs e) + { + var item = (e.Source as LoopingSelectorItem).Content; + SelectedItem = item; + } + + protected virtual void OnSelectionChanged(object sender, SelectionChangedEventArgs e) + { + SelectionChanged?.Invoke(sender, e); + } + + private void OnBoundsChanged(Rect x) + { + //Ideally we always want this to be odd, since the selected item is placed in the middle, + //so we have the same number of items above and below at all times + _totalItemsInViewport = (int)Math.Ceiling(x.Height / ItemHeight); + if (_totalItemsInViewport % 2 == 0) + _totalItemsInViewport += 1; + + EnsureContainers(); + } + + + //TemplateItems + private RepeatButton _downButton; + private RepeatButton _upButton; + private ScrollViewer _scroller; + + private LoopingPanel _panel; + + private int _totalItemsInViewport; + private IEnumerable _items; + private int _itemCount; + private int _selectedIndex = -1; + private object _selectedItem; + private double _itemHeight = 32; + private bool _shouldLoop = true; + private bool _preventUpdateSelection; + private bool _preventMovingScrollWhenSelecting; + } +} diff --git a/src/Avalonia.Controls/DateTimePickers/LoopingSelectorItem.cs b/src/Avalonia.Controls/DateTimePickers/LoopingSelectorItem.cs new file mode 100644 index 0000000000..8e1d242561 --- /dev/null +++ b/src/Avalonia.Controls/DateTimePickers/LoopingSelectorItem.cs @@ -0,0 +1,98 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using System; + +namespace Avalonia.Controls.Primitives +{ + /// + /// Defines the containers used by the + /// + public sealed class LoopingSelectorItem : ContentControl + { + static LoopingSelectorItem() + { + IsPressedProperty.Changed.AddClassHandler((x, e) => x.OnIsPressedChanged(e)); + IsSelectedProperty.Changed.AddClassHandler((x, e) => x.OnIsSelectedChanged(e)); + } + + /// + /// Defines the Property + /// + public static readonly StyledProperty IsPressedProperty = + AvaloniaProperty.Register("IsPressed"); + + /// + /// Defines the Property + /// + internal static readonly StyledProperty IsSelectedProperty = + AvaloniaProperty.Register("IsSelected"); + + /// + /// Gets whether the item is currently pressed + /// + public bool IsPressed + { + get => GetValue(IsPressedProperty); + private set => SetValue(IsPressedProperty, value); + } + + internal bool IsSelected + { + get => GetValue(IsSelectedProperty); + set => SetValue(IsSelectedProperty, value); + } + + private void OnIsPressedChanged(AvaloniaPropertyChangedEventArgs e) + { + PseudoClasses.Set(":pressed", (bool)e.NewValue); + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + IsPressed = true; + } + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + if (IsPressed) + { + var pt = e.GetPosition(this); + if (pt.X < 0 || pt.Y < 0 || pt.X > Bounds.Width || pt.Y > Bounds.Height) + IsPressed = false; + } + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + if (IsPressed && e.GetCurrentPoint(this).Properties.PointerUpdateKind == PointerUpdateKind.LeftButtonReleased) + { + IsPressed = false; + //The selection event only raises when invoked by pointer events + RaiseEvent(new RoutedEventArgs(SelectedEvent, this)); + } + } + + private void OnIsSelectedChanged(AvaloniaPropertyChangedEventArgs e) + { + var newValue = (bool)e.NewValue; + PseudoClasses.Set(":selected", newValue); + } + + public static readonly RoutedEvent SelectedEvent = + RoutedEvent.Register("Selected", RoutingStrategies.Bubble); + + public event EventHandler Selected + { + add => AddHandler(SelectedEvent, value); + remove => RemoveHandler(SelectedEvent, value); + } + } +} diff --git a/src/Avalonia.Controls/Templates/DateTimePickers/DateTimeFormatter.cs b/src/Avalonia.Controls/Templates/DateTimePickers/DateTimeFormatter.cs new file mode 100644 index 0000000000..46b1c07ebc --- /dev/null +++ b/src/Avalonia.Controls/Templates/DateTimePickers/DateTimeFormatter.cs @@ -0,0 +1,335 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Avalonia.Controls +{ + /// + /// Formats a DateTimeOffset by the specified pattern + /// Based on, but not replica, the Windows.Globalization.DateTimeFormatting.DateTimeFormatter + /// https://docs.microsoft.com/en-us/uwp/api/windows.globalization.datetimeformatting.datetimeformatter?view=winrt-19041 + /// + /// Currently only the Gregorian Calendar is supported, and regions and languages are not supported. + /// Formatting timezones is also not supported, that's a large task + /// + /// + /// /// Most formats used in UWP/WinUI are compatible here, but not all + /// This DateTimeFormatter will also work with TimeSpans (only patterns though), + /// in addition to the default DateTime/DateTimeOffset + /// + /// + /// Formats are broken down into Patterns and Templates, which are "complete" patterns + /// If a Template is used, only 1 may be provided and cannot be mixed with anything else + /// Multiple patterns can be specified, and can be mixed with other text. All patterns + /// must be enclosed in curly braces {}, e.g. {dayofweek}, or in xaml "{}{dayofweek}" + /// NOTE: Formats and Templates are case sensitive + /// + /// + public sealed class DateTimeFormatter + { + public DateTimeFormatter(string formatString) + { + _format = formatString; + var reg = new Regex("{([^{}]*)}"); + var results = reg.Matches(formatString).Cast().Select(m => m.Groups[1].Value).Distinct().ToList(); + Formats = results; + } + + public string Clock + { + get + { + if (_clock == null) + { + var timePattern = CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern; + if (timePattern.IndexOf("H") != -1) + return "24HourClock"; + return "12HourClock"; + } + return _clock; + } + set + { + _clock = value; + } + } + public List Formats { get; } + + /// + /// Formats a DateTimeOffset object by the format of the + /// + public string Format(DateTimeOffset toFormat) + { + string ret = _format; + + if (Formats.Count == 0) + { + return GetFormatTemplate(toFormat); + } + + foreach (var item in Formats) + { + var p = $"{{{item}}}"; + var ns = Regex.Replace(ret, $"{{{Regex.Escape(item)}}}", GetFormatValue(item, toFormat)); + ret = ns; + } + + return ret; + } + + /// + /// Formats a TimeSpan object by the format of the + /// + public string Format(TimeSpan toFormat) + { + string ret = _format; + + foreach (var item in Formats) + { + var p = $"{{{item}}}"; + var ns = Regex.Replace(ret, $"{{{Regex.Escape(item)}}}", GetFormatValue(item, toFormat)); + ret = ns; + } + + return ret; + } + + private string GetFormatValue(string pattern, DateTimeOffset dt) + { + var sp = pattern.Split(new[] { "." }, StringSplitOptions.None); + var type = sp[0].Trim(); + var desc = sp[1].Trim(); + var len = desc.Contains("(") ? int.Parse(desc.Substring(desc.IndexOf("(") + 1, desc.Length - desc.IndexOf(")"))) : -1; + if (type.Equals("era")) + { + return ""; + } + else if (type.Equals("year")) + { + var yr = dt.Year; + if (len == -1) + return yr.ToString(); + else if (len <= 2) + return yr.ToString().Substring(2); + else + return yr.ToString(); + } + else if (type.Equals("month")) + { + var mon = dt.Month; + var fmt = CultureInfo.CurrentCulture.DateTimeFormat; + if (len == -1) + return desc == "full" ? fmt.GetMonthName(dt.Month) : fmt.GetAbbreviatedMonthName(dt.Month); + var nm = desc == "full" ? fmt.GetMonthName(dt.Month) : fmt.GetAbbreviatedMonthName(dt.Month); + len = Math.Min(nm.Length, Math.Max(0, len)); + return nm.Substring(0, len); + } + else if (type.Equals("dayofweek")) + { + var dow = dt.DayOfWeek; + var fmt = CultureInfo.CurrentCulture.DateTimeFormat; + if (len == -1) + return desc == "full" ? fmt.GetDayName(dt.DayOfWeek) : fmt.GetAbbreviatedDayName(dt.DayOfWeek); + var nm = desc == "full" ? fmt.GetDayName(dt.DayOfWeek) : fmt.GetAbbreviatedDayName(dt.DayOfWeek); + len = Math.Min(nm.Length, Math.Max(0, len)); + return nm.Substring(0, len); + } + else if (type.Equals("day")) + { + var dy = dt.Day; + if (len == -1) + return dy.ToString(); + if (len < 1 || len > 2) + len = 2; + return dy.ToString($"D{len}"); + } + else if (type.Equals("period")) + { + if (Clock == "24HourClock") + return ""; + + var hr = dt.Hour; + if (hr >= 12) + return CultureInfo.CurrentCulture.DateTimeFormat.PMDesignator; + else + return CultureInfo.CurrentCulture.DateTimeFormat.AMDesignator; + } + else if (type.Equals("hour")) + { + var shortTimePattern = CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern.ToLower(); + var resolvedLength = 1; + //valid Hours: h, hh + if (shortTimePattern.Contains("hh")) + { + resolvedLength = 2; + } + var hr = dt.Hour; + + if (Clock == "12HourClock") + hr = hr >= 13 ? hr - 12 : hr; + + if (len == -1) + return hr.ToString($"D{resolvedLength}"); + if (len < 1 || len > 2) + len = resolvedLength; + + return hr.ToString($"D{len}"); + } + else if (type.Equals("minute")) + { + var shortTimePattern = CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern.ToLower(); + var resolvedLength = 1; + //valid mintues: m, mm + if (shortTimePattern.Contains("hh")) + { + resolvedLength = 2; + } + + var dy = dt.Minute; + if (len == -1) + return dy.ToString($"D{resolvedLength}"); + if (len < 1 || len > 2) + len = resolvedLength; + + return dy.ToString($"D{len}"); + } + else if (type.Equals("second")) + { + var shortTimePattern = CultureInfo.CurrentCulture.DateTimeFormat.LongTimePattern.ToLower(); + var resolvedLength = 1; + //valid seconds: s, ss + if (shortTimePattern.Contains("ss")) + { + resolvedLength = 2; + } + + var dy = dt.Second; + if (len == -1) + return dy.ToString($"D{resolvedLength}"); + if (len < 1 || len > 2) + len = resolvedLength; + + return dy.ToString($"D{len}"); + } + else if (type.Equals("timezone")) + { + //Timezones aren't supported yet, that's a HUGE task + //Multiple timezones can exist for a given offset from UTC, + //So info about region is necessary to obtain it + //It'd be nice if MS would move stuff from WinRT globalization namespace to .net + throw new NotSupportedException("Timezones aren't supported yet"); + } + + throw new ArgumentException("Invalid format"); + + } + + private string GetFormatValue(string pattern, TimeSpan ts) + { + var sp = pattern.Split(new[] { "." }, StringSplitOptions.None); + var type = sp[0].Trim(); + var desc = sp.Count() > 1 ? sp[1].Trim() : ""; + var len = desc.Contains("(") ? int.Parse(desc.Substring(desc.IndexOf("(") + 1, desc.Length - desc.IndexOf(")"))) : -1; + + if (type.Equals("hour")) + { + var shortTimePattern = CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern.ToLower(); + var resolvedLength = 1; + //valid Hours: h, hh + if (shortTimePattern.Contains("hh")) + { + resolvedLength = 2; + } + var hr = ts.Hours; + + if (Clock == "12HourClock") + hr = hr >= 13 ? hr - 12 : hr; + + if (len == -1) + return hr.ToString($"D{resolvedLength}"); + if (len < 1 || len > 2) + len = resolvedLength; + + return hr.ToString($"D{len}"); + } + else if (type.Equals("minute")) + { + var shortTimePattern = CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern.ToLower(); + var resolvedLength = 1; + //valid mintues: m, mm + if (shortTimePattern.Contains("hh")) + { + resolvedLength = 2; + } + + var dy = ts.Minutes; + if (len == -1) + return dy.ToString($"D{resolvedLength}"); + if (len < 1 || len > 2) + len = resolvedLength; + + return dy.ToString($"D{len}"); + } + else if (type.Equals("second")) + { + var shortTimePattern = CultureInfo.CurrentCulture.DateTimeFormat.LongTimePattern.ToLower(); + var resolvedLength = 1; + //valid seconds: s, ss + if (shortTimePattern.Contains("ss")) + { + resolvedLength = 2; + } + + var dy = ts.Seconds; + if (len == -1) + return dy.ToString($"D{resolvedLength}"); + if (len < 1 || len > 2) + len = resolvedLength; + + return dy.ToString($"D{len}"); + } + else if (type.Equals("period")) + { + if (Clock == "24HourClock") + return ""; + + var hr = ts.Hours; + if (hr >= 12) + return CultureInfo.CurrentCulture.DateTimeFormat.PMDesignator; + else + return CultureInfo.CurrentCulture.DateTimeFormat.AMDesignator; + } + + throw new ArgumentException("Invalid format"); + + } + + private string GetFormatTemplate(DateTimeOffset dt) + { + switch (_format) + { + case "longdate": + return dt.ToString(CultureInfo.CurrentCulture.DateTimeFormat.LongDatePattern); + case "shortdate": + return dt.ToString(CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern); + case "longtime": + return dt.ToString(CultureInfo.CurrentCulture.DateTimeFormat.LongTimePattern); + case "shorttime": + return dt.ToString(CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern); + case "iso8601": + case "sortable": + return dt.ToString(CultureInfo.CurrentCulture.DateTimeFormat.SortableDateTimePattern); + case "universalsortable": + return dt.ToString(CultureInfo.CurrentCulture.DateTimeFormat.UniversalSortableDateTimePattern); + case "rfc1123": + return dt.ToUniversalTime().ToString(CultureInfo.CurrentCulture.DateTimeFormat.RFC1123Pattern); + } + throw new ArgumentException("Invalid template"); + } + + private string _format; + private string _clock; + } +} diff --git a/src/Avalonia.Controls/Templates/DateTimePickers/LoopingSelector.cs b/src/Avalonia.Controls/Templates/DateTimePickers/LoopingSelector.cs new file mode 100644 index 0000000000..9f9be174a8 --- /dev/null +++ b/src/Avalonia.Controls/Templates/DateTimePickers/LoopingSelector.cs @@ -0,0 +1,10 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Avalonia.Controls.DateTimePickers +{ + class LoopingSelector + { + } +} diff --git a/src/Avalonia.Controls/Templates/NumericUpDown/NumericUpDown.cs b/src/Avalonia.Controls/Templates/NumericUpDown/NumericUpDown.cs new file mode 100644 index 0000000000..aae041071d --- /dev/null +++ b/src/Avalonia.Controls/Templates/NumericUpDown/NumericUpDown.cs @@ -0,0 +1,1020 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using Avalonia.Controls.Primitives; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Threading; +using Avalonia.Utilities; + +namespace Avalonia.Controls +{ + /// + /// Control that represents a TextBox with button spinners that allow incrementing and decrementing numeric values. + /// + public class NumericUpDown : TemplatedControl + { + /// + /// Defines the property. + /// + public static readonly StyledProperty AllowSpinProperty = + ButtonSpinner.AllowSpinProperty.AddOwner(); + + /// + /// Defines the property. + /// + public static readonly StyledProperty ButtonSpinnerLocationProperty = + ButtonSpinner.ButtonSpinnerLocationProperty.AddOwner(); + + /// + /// Defines the property. + /// + public static readonly StyledProperty ShowButtonSpinnerProperty = + ButtonSpinner.ShowButtonSpinnerProperty.AddOwner(); + + /// + /// Defines the property. + /// + public static readonly DirectProperty ClipValueToMinMaxProperty = + AvaloniaProperty.RegisterDirect(nameof(ClipValueToMinMax), + updown => updown.ClipValueToMinMax, (updown, b) => updown.ClipValueToMinMax = b); + + /// + /// Defines the property. + /// + public static readonly DirectProperty CultureInfoProperty = + AvaloniaProperty.RegisterDirect(nameof(CultureInfo), o => o.CultureInfo, + (o, v) => o.CultureInfo = v, CultureInfo.CurrentCulture); + + /// + /// Defines the property. + /// + public static readonly StyledProperty FormatStringProperty = + AvaloniaProperty.Register(nameof(FormatString), string.Empty); + + /// + /// Defines the property. + /// + public static readonly StyledProperty IncrementProperty = + AvaloniaProperty.Register(nameof(Increment), 1.0d, coerce: OnCoerceIncrement); + + /// + /// Defines the property. + /// + public static readonly StyledProperty IsReadOnlyProperty = + AvaloniaProperty.Register(nameof(IsReadOnly)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty MaximumProperty = + AvaloniaProperty.Register(nameof(Maximum), double.MaxValue, coerce: OnCoerceMaximum); + + /// + /// Defines the property. + /// + public static readonly StyledProperty MinimumProperty = + AvaloniaProperty.Register(nameof(Minimum), double.MinValue, coerce: OnCoerceMinimum); + + /// + /// Defines the property. + /// + public static readonly DirectProperty ParsingNumberStyleProperty = + AvaloniaProperty.RegisterDirect(nameof(ParsingNumberStyle), + updown => updown.ParsingNumberStyle, (updown, style) => updown.ParsingNumberStyle = style); + + /// + /// Defines the property. + /// + public static readonly DirectProperty TextProperty = + AvaloniaProperty.RegisterDirect(nameof(Text), o => o.Text, (o, v) => o.Text = v, + defaultBindingMode: BindingMode.TwoWay); + + /// + /// Defines the property. + /// + public static readonly DirectProperty ValueProperty = + AvaloniaProperty.RegisterDirect(nameof(Value), updown => updown.Value, + (updown, v) => updown.Value = v, defaultBindingMode: BindingMode.TwoWay); + + /// + /// Defines the property. + /// + public static readonly StyledProperty WatermarkProperty = + AvaloniaProperty.Register(nameof(Watermark)); + + private IDisposable _textBoxTextChangedSubscription; + + private double _value; + private string _text; + private bool _internalValueSet; + private bool _clipValueToMinMax; + private bool _isSyncingTextAndValueProperties; + private bool _isTextChangedFromUI; + private CultureInfo _cultureInfo; + private NumberStyles _parsingNumberStyle = NumberStyles.Any; + + /// + /// Gets the Spinner template part. + /// + private Spinner Spinner { get; set; } + + /// + /// Gets the TextBox template part. + /// + private TextBox TextBox { get; set; } + + /// + /// Gets or sets the ability to perform increment/decrement operations via the keyboard, button spinners, or mouse wheel. + /// + public bool AllowSpin + { + get { return GetValue(AllowSpinProperty); } + set { SetValue(AllowSpinProperty, value); } + } + + /// + /// Gets or sets current location of the . + /// + public Location ButtonSpinnerLocation + { + get { return GetValue(ButtonSpinnerLocationProperty); } + set { SetValue(ButtonSpinnerLocationProperty, value); } + } + + /// + /// Gets or sets a value indicating whether the spin buttons should be shown. + /// + public bool ShowButtonSpinner + { + get { return GetValue(ShowButtonSpinnerProperty); } + set { SetValue(ShowButtonSpinnerProperty, value); } + } + + /// + /// Gets or sets if the value should be clipped when minimum/maximum is reached. + /// + public bool ClipValueToMinMax + { + get { return _clipValueToMinMax; } + set { SetAndRaise(ClipValueToMinMaxProperty, ref _clipValueToMinMax, value); } + } + + /// + /// Gets or sets the current CultureInfo. + /// + public CultureInfo CultureInfo + { + get { return _cultureInfo; } + set { SetAndRaise(CultureInfoProperty, ref _cultureInfo, value); } + } + + /// + /// Gets or sets the display format of the . + /// + public string FormatString + { + get { return GetValue(FormatStringProperty); } + set { SetValue(FormatStringProperty, value); } + } + + /// + /// Gets or sets the amount in which to increment the . + /// + public double Increment + { + get { return GetValue(IncrementProperty); } + set { SetValue(IncrementProperty, value); } + } + + /// + /// Gets or sets if the control is read only. + /// + public bool IsReadOnly + { + get { return GetValue(IsReadOnlyProperty); } + set { SetValue(IsReadOnlyProperty, value); } + } + + /// + /// Gets or sets the maximum allowed value. + /// + public double Maximum + { + get { return GetValue(MaximumProperty); } + set { SetValue(MaximumProperty, value); } + } + + /// + /// Gets or sets the minimum allowed value. + /// + public double Minimum + { + get { return GetValue(MinimumProperty); } + set { SetValue(MinimumProperty, value); } + } + + /// + /// Gets or sets the parsing style (AllowLeadingWhite, Float, AllowHexSpecifier, ...). By default, Any. + /// + public NumberStyles ParsingNumberStyle + { + get { return _parsingNumberStyle; } + set { SetAndRaise(ParsingNumberStyleProperty, ref _parsingNumberStyle, value); } + } + + /// + /// Gets or sets the formatted string representation of the value. + /// + public string Text + { + get { return _text; } + set { SetAndRaise(TextProperty, ref _text, value); } + } + + /// + /// Gets or sets the value. + /// + public double Value + { + get { return _value; } + set + { + value = OnCoerceValue(value); + SetAndRaise(ValueProperty, ref _value, value); + } + } + + /// + /// Gets or sets the object to use as a watermark if the is null. + /// + public string Watermark + { + get { return GetValue(WatermarkProperty); } + set { SetValue(WatermarkProperty, value); } + } + + /// + /// Initializes new instance of class. + /// + public NumericUpDown() + { + Initialized += (sender, e) => + { + if (!_internalValueSet && IsInitialized) + { + SyncTextAndValueProperties(false, null, true); + } + + SetValidSpinDirection(); + }; + } + + /// + /// Initializes static members of the class. + /// + static NumericUpDown() + { + CultureInfoProperty.Changed.Subscribe(OnCultureInfoChanged); + FormatStringProperty.Changed.Subscribe(FormatStringChanged); + IncrementProperty.Changed.Subscribe(IncrementChanged); + IsReadOnlyProperty.Changed.Subscribe(OnIsReadOnlyChanged); + MaximumProperty.Changed.Subscribe(OnMaximumChanged); + MinimumProperty.Changed.Subscribe(OnMinimumChanged); + TextProperty.Changed.Subscribe(OnTextChanged); + ValueProperty.Changed.Subscribe(OnValueChanged); + } + + /// + protected override void OnLostFocus(RoutedEventArgs e) + { + CommitInput(); + base.OnLostFocus(e); + } + + /// + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + if (TextBox != null) + { + TextBox.PointerPressed -= TextBoxOnPointerPressed; + _textBoxTextChangedSubscription?.Dispose(); + } + TextBox = e.NameScope.Find("PART_TextBox"); + if (TextBox != null) + { + TextBox.Text = Text; + TextBox.PointerPressed += TextBoxOnPointerPressed; + _textBoxTextChangedSubscription = TextBox.GetObservable(TextBox.TextProperty).Subscribe(txt => TextBoxOnTextChanged()); + } + + if (Spinner != null) + { + Spinner.Spin -= OnSpinnerSpin; + } + + Spinner = e.NameScope.Find("PART_Spinner"); + + if (Spinner != null) + { + Spinner.Spin += OnSpinnerSpin; + } + + SetValidSpinDirection(); + } + + /// + protected override void OnKeyDown(KeyEventArgs e) + { + switch (e.Key) + { + case Key.Enter: + var commitSuccess = CommitInput(); + e.Handled = !commitSuccess; + break; + } + } + + /// + /// Called when the property value changed. + /// + /// The old value. + /// The new value. + protected virtual void OnCultureInfoChanged(CultureInfo oldValue, CultureInfo newValue) + { + if (IsInitialized) + { + SyncTextAndValueProperties(false, null); + } + } + + /// + /// Called when the property value changed. + /// + /// The old value. + /// The new value. + protected virtual void OnFormatStringChanged(string oldValue, string newValue) + { + if (IsInitialized) + { + SyncTextAndValueProperties(false, null); + } + } + + /// + /// Called when the property value changed. + /// + /// The old value. + /// The new value. + protected virtual void OnIncrementChanged(double oldValue, double newValue) + { + if (IsInitialized) + { + SetValidSpinDirection(); + } + } + + /// + /// Called when the property value changed. + /// + /// The old value. + /// The new value. + protected virtual void OnIsReadOnlyChanged(bool oldValue, bool newValue) + { + SetValidSpinDirection(); + } + + /// + /// Called when the property value changed. + /// + /// The old value. + /// The new value. + protected virtual void OnMaximumChanged(double oldValue, double newValue) + { + if (IsInitialized) + { + SetValidSpinDirection(); + } + if (ClipValueToMinMax) + { + Value = MathUtilities.Clamp(Value, Minimum, Maximum); + } + } + + /// + /// Called when the property value changed. + /// + /// The old value. + /// The new value. + protected virtual void OnMinimumChanged(double oldValue, double newValue) + { + if (IsInitialized) + { + SetValidSpinDirection(); + } + if (ClipValueToMinMax) + { + Value = MathUtilities.Clamp(Value, Minimum, Maximum); + } + } + + /// + /// Called when the property value changed. + /// + /// The old value. + /// The new value. + protected virtual void OnTextChanged(string oldValue, string newValue) + { + if (IsInitialized) + { + SyncTextAndValueProperties(true, Text); + } + } + + /// + /// Called when the property value changed. + /// + /// The old value. + /// The new value. + protected virtual void OnValueChanged(double oldValue, double newValue) + { + if (!_internalValueSet && IsInitialized) + { + SyncTextAndValueProperties(false, null, true); + } + + SetValidSpinDirection(); + + RaiseValueChangedEvent(oldValue, newValue); + } + + /// + /// Called when the property has to be coerced. + /// + /// The value. + protected virtual double OnCoerceIncrement(double baseValue) + { + return baseValue; + } + + /// + /// Called when the property has to be coerced. + /// + /// The value. + protected virtual double OnCoerceMaximum(double baseValue) + { + return Math.Max(baseValue, Minimum); + } + + /// + /// Called when the property has to be coerced. + /// + /// The value. + protected virtual double OnCoerceMinimum(double baseValue) + { + return Math.Min(baseValue, Maximum); + } + + /// + /// Called when the property has to be coerced. + /// + /// The value. + protected virtual double OnCoerceValue(double baseValue) + { + return baseValue; + } + + /// + /// Raises the OnSpin event when spinning is initiated by the end-user. + /// + /// The event args. + protected virtual void OnSpin(SpinEventArgs e) + { + if (e == null) + { + throw new ArgumentNullException(nameof(e)); + } + + var handler = Spinned; + handler?.Invoke(this, e); + + if (e.Direction == SpinDirection.Increase) + { + DoIncrement(); + } + else + { + DoDecrement(); + } + } + + /// + /// Raises the event. + /// + /// The old value. + /// The new value. + protected virtual void RaiseValueChangedEvent(double oldValue, double newValue) + { + var e = new NumericUpDownValueChangedEventArgs(ValueChangedEvent, oldValue, newValue); + RaiseEvent(e); + } + + /// + /// Converts the formatted text to a value. + /// + private double ConvertTextToValue(string text) + { + double result = 0; + + if (string.IsNullOrEmpty(text)) + { + return result; + } + + // Since the conversion from Value to text using a FormatString may not be parsable, + // we verify that the already existing text is not the exact same value. + var currentValueText = ConvertValueToText(); + if (Equals(currentValueText, text)) + { + return Value; + } + + result = ConvertTextToValueCore(currentValueText, text); + + if (ClipValueToMinMax) + { + return MathUtilities.Clamp(result, Minimum, Maximum); + } + + ValidateMinMax(result); + + return result; + } + + /// + /// Converts the value to formatted text. + /// + /// + private string ConvertValueToText() + { + //Manage FormatString of type "{}{0:N2} °" (in xaml) or "{0:N2} °" in code-behind. + if (FormatString.Contains("{0")) + { + return string.Format(CultureInfo, FormatString, Value); + } + + return Value.ToString(FormatString, CultureInfo); + } + + /// + /// Called by OnSpin when the spin direction is SpinDirection.Increase. + /// + private void OnIncrement() + { + var result = Value + Increment; + Value = MathUtilities.Clamp(result, Minimum, Maximum); + } + + /// + /// Called by OnSpin when the spin direction is SpinDirection.Decrease. + /// + private void OnDecrement() + { + var result = Value - Increment; + Value = MathUtilities.Clamp(result, Minimum, Maximum); + } + + /// + /// Sets the valid spin directions. + /// + private void SetValidSpinDirection() + { + var validDirections = ValidSpinDirections.None; + + // Zero increment always prevents spin. + if (Increment != 0 && !IsReadOnly) + { + if (Value < Maximum) + { + validDirections = validDirections | ValidSpinDirections.Increase; + } + + if (Value > Minimum) + { + validDirections = validDirections | ValidSpinDirections.Decrease; + } + } + + if (Spinner != null) + { + Spinner.ValidSpinDirection = validDirections; + } + } + + /// + /// Called when the property value changed. + /// + /// The event args. + private static void OnCultureInfoChanged(AvaloniaPropertyChangedEventArgs e) + { + if (e.Sender is NumericUpDown upDown) + { + var oldValue = (CultureInfo)e.OldValue; + var newValue = (CultureInfo)e.NewValue; + upDown.OnCultureInfoChanged(oldValue, newValue); + } + } + + /// + /// Called when the property value changed. + /// + /// The event args. + private static void IncrementChanged(AvaloniaPropertyChangedEventArgs e) + { + if (e.Sender is NumericUpDown upDown) + { + var oldValue = (double)e.OldValue; + var newValue = (double)e.NewValue; + upDown.OnIncrementChanged(oldValue, newValue); + } + } + + /// + /// Called when the property value changed. + /// + /// The event args. + private static void FormatStringChanged(AvaloniaPropertyChangedEventArgs e) + { + if (e.Sender is NumericUpDown upDown) + { + var oldValue = (string)e.OldValue; + var newValue = (string)e.NewValue; + upDown.OnFormatStringChanged(oldValue, newValue); + } + } + + /// + /// Called when the property value changed. + /// + /// The event args. + private static void OnIsReadOnlyChanged(AvaloniaPropertyChangedEventArgs e) + { + if (e.Sender is NumericUpDown upDown) + { + var oldValue = (bool)e.OldValue; + var newValue = (bool)e.NewValue; + upDown.OnIsReadOnlyChanged(oldValue, newValue); + } + } + + /// + /// Called when the property value changed. + /// + /// The event args. + private static void OnMaximumChanged(AvaloniaPropertyChangedEventArgs e) + { + if (e.Sender is NumericUpDown upDown) + { + var oldValue = (double)e.OldValue; + var newValue = (double)e.NewValue; + upDown.OnMaximumChanged(oldValue, newValue); + } + } + + /// + /// Called when the property value changed. + /// + /// The event args. + private static void OnMinimumChanged(AvaloniaPropertyChangedEventArgs e) + { + if (e.Sender is NumericUpDown upDown) + { + var oldValue = (double)e.OldValue; + var newValue = (double)e.NewValue; + upDown.OnMinimumChanged(oldValue, newValue); + } + } + + /// + /// Called when the property value changed. + /// + /// The event args. + private static void OnTextChanged(AvaloniaPropertyChangedEventArgs e) + { + if (e.Sender is NumericUpDown upDown) + { + var oldValue = (string)e.OldValue; + var newValue = (string)e.NewValue; + upDown.OnTextChanged(oldValue, newValue); + } + } + + /// + /// Called when the property value changed. + /// + /// The event args. + private static void OnValueChanged(AvaloniaPropertyChangedEventArgs e) + { + if (e.Sender is NumericUpDown upDown) + { + var oldValue = (double)e.OldValue; + var newValue = (double)e.NewValue; + upDown.OnValueChanged(oldValue, newValue); + } + } + + private void SetValueInternal(double value) + { + _internalValueSet = true; + try + { + Value = value; + } + finally + { + _internalValueSet = false; + } + } + + private static double OnCoerceMaximum(IAvaloniaObject instance, double value) + { + if (instance is NumericUpDown upDown) + { + return upDown.OnCoerceMaximum(value); + } + + return value; + } + + private static double OnCoerceMinimum(IAvaloniaObject instance, double value) + { + if (instance is NumericUpDown upDown) + { + return upDown.OnCoerceMinimum(value); + } + + return value; + } + + private static double OnCoerceIncrement(IAvaloniaObject instance, double value) + { + if (instance is NumericUpDown upDown) + { + return upDown.OnCoerceIncrement(value); + } + + return value; + } + + private void TextBoxOnTextChanged() + { + try + { + _isTextChangedFromUI = true; + if (TextBox != null) + { + Text = TextBox.Text; + } + } + finally + { + _isTextChangedFromUI = false; + } + } + + private void OnSpinnerSpin(object sender, SpinEventArgs e) + { + if (AllowSpin && !IsReadOnly) + { + var spin = !e.UsingMouseWheel; + spin |= ((TextBox != null) && TextBox.IsFocused); + + if (spin) + { + e.Handled = true; + OnSpin(e); + } + } + } + + private void DoDecrement() + { + if (Spinner == null || (Spinner.ValidSpinDirection & ValidSpinDirections.Decrease) == ValidSpinDirections.Decrease) + { + OnDecrement(); + } + } + + private void DoIncrement() + { + if (Spinner == null || (Spinner.ValidSpinDirection & ValidSpinDirections.Increase) == ValidSpinDirections.Increase) + { + OnIncrement(); + } + } + + public event EventHandler Spinned; + + private void TextBoxOnPointerPressed(object sender, PointerPressedEventArgs e) + { + if (e.Pointer.Captured != Spinner) + { + Dispatcher.UIThread.InvokeAsync(() => { e.Pointer.Capture(Spinner); }, DispatcherPriority.Input); + } + } + + /// + /// Defines the event. + /// + public static readonly RoutedEvent ValueChangedEvent = + RoutedEvent.Register(nameof(ValueChanged), RoutingStrategies.Bubble); + + /// + /// Raised when the changes. + /// + public event EventHandler ValueChanged + { + add { AddHandler(ValueChangedEvent, value); } + remove { RemoveHandler(ValueChangedEvent, value); } + } + + private bool CommitInput() + { + return SyncTextAndValueProperties(true, Text); + } + + /// + /// Synchronize and properties. + /// + /// If value should be updated from text. + /// The text. + private bool SyncTextAndValueProperties(bool updateValueFromText, string text) + { + return SyncTextAndValueProperties(updateValueFromText, text, false); + } + + /// + /// Synchronize and properties. + /// + /// If value should be updated from text. + /// The text. + /// Force text update. + private bool SyncTextAndValueProperties(bool updateValueFromText, string text, bool forceTextUpdate) + { + if (_isSyncingTextAndValueProperties) + return true; + + _isSyncingTextAndValueProperties = true; + var parsedTextIsValid = true; + try + { + if (updateValueFromText) + { + if (!string.IsNullOrEmpty(text)) + { + try + { + var newValue = ConvertTextToValue(text); + if (!Equals(newValue, Value)) + { + SetValueInternal(newValue); + } + } + catch + { + parsedTextIsValid = false; + } + } + } + + // Do not touch the ongoing text input from user. + if (!_isTextChangedFromUI) + { + var keepEmpty = !forceTextUpdate && string.IsNullOrEmpty(Text); + if (!keepEmpty) + { + var newText = ConvertValueToText(); + if (!Equals(Text, newText)) + { + Text = newText; + } + } + + // Sync Text and textBox + if (TextBox != null) + { + TextBox.Text = Text; + } + } + + if (_isTextChangedFromUI && !parsedTextIsValid) + { + // Text input was made from the user and the text + // represents an invalid value. Disable the spinner in this case. + if (Spinner != null) + { + Spinner.ValidSpinDirection = ValidSpinDirections.None; + } + } + else + { + SetValidSpinDirection(); + } + } + finally + { + _isSyncingTextAndValueProperties = false; + } + return parsedTextIsValid; + } + + private double ConvertTextToValueCore(string currentValueText, string text) + { + double result; + + if (IsPercent(FormatString)) + { + result = decimal.ToDouble(ParsePercent(text, CultureInfo)); + } + else + { + // Problem while converting new text + if (!double.TryParse(text, ParsingNumberStyle, CultureInfo, out var outputValue)) + { + var shouldThrow = true; + + // Check if CurrentValueText is also failing => it also contains special characters. ex : 90° + if (!double.TryParse(currentValueText, ParsingNumberStyle, CultureInfo, out var _)) + { + // extract non-digit characters + var currentValueTextSpecialCharacters = currentValueText.Where(c => !char.IsDigit(c)); + var textSpecialCharacters = text.Where(c => !char.IsDigit(c)); + // same non-digit characters on currentValueText and new text => remove them on new Text to parse it again. + if (currentValueTextSpecialCharacters.Except(textSpecialCharacters).ToList().Count == 0) + { + foreach (var character in textSpecialCharacters) + { + text = text.Replace(character.ToString(), string.Empty); + } + // if without the special characters, parsing is good, do not throw + if (double.TryParse(text, ParsingNumberStyle, CultureInfo, out outputValue)) + { + shouldThrow = false; + } + } + } + + if (shouldThrow) + { + throw new InvalidDataException("Input string was not in a correct format."); + } + } + result = outputValue; + } + return result; + } + + private void ValidateMinMax(double value) + { + if (value < Minimum) + { + throw new ArgumentOutOfRangeException(nameof(value), string.Format("Value must be greater than Minimum value of {0}", Minimum)); + } + else if (value > Maximum) + { + throw new ArgumentOutOfRangeException(nameof(value), string.Format("Value must be less than Maximum value of {0}", Maximum)); + } + } + + /// + /// Parse percent format text + /// + /// Text to parse. + /// The culture info. + private static decimal ParsePercent(string text, IFormatProvider cultureInfo) + { + var info = NumberFormatInfo.GetInstance(cultureInfo); + text = text.Replace(info.PercentSymbol, null); + var result = decimal.Parse(text, NumberStyles.Any, info); + result = result / 100; + return result; + } + + + private bool IsPercent(string stringToTest) + { + var PIndex = stringToTest.IndexOf("P", StringComparison.Ordinal); + if (PIndex >= 0) + { + //stringToTest contains a "P" between 2 "'", it's considered as text, not percent + var isText = stringToTest.Substring(0, PIndex).Contains("'") + && stringToTest.Substring(PIndex, FormatString.Length - PIndex).Contains("'"); + + return !isText; + } + return false; + } + } +} diff --git a/src/Avalonia.Controls/Templates/NumericUpDown/NumericUpDownValueChangedEventArgs.cs b/src/Avalonia.Controls/Templates/NumericUpDown/NumericUpDownValueChangedEventArgs.cs new file mode 100644 index 0000000000..e994ffdd15 --- /dev/null +++ b/src/Avalonia.Controls/Templates/NumericUpDown/NumericUpDownValueChangedEventArgs.cs @@ -0,0 +1,16 @@ +using Avalonia.Interactivity; + +namespace Avalonia.Controls +{ + public class NumericUpDownValueChangedEventArgs : RoutedEventArgs + { + public NumericUpDownValueChangedEventArgs(RoutedEvent routedEvent, double oldValue, double newValue) : base(routedEvent) + { + OldValue = oldValue; + NewValue = newValue; + } + + public double OldValue { get; } + public double NewValue { get; } + } +} From e7d6a2a0abd0aac4469e24a2659526f1916f01a8 Mon Sep 17 00:00:00 2001 From: amwx Date: Thu, 11 Jun 2020 21:56:23 -0500 Subject: [PATCH 011/191] Add DatePicker and related classes --- .../DateTimePickers/DatePicker.cs | 532 ++++++++++ .../DateTimePickers/DatePickerPresenter.cs | 971 ++++++++++++++++++ .../DatePickerPresenterItem.cs | 31 + ...DatePickerSelectedValueChangedEventArgs.cs | 19 + .../DatePickerValueChangedEventArgs.cs | 19 + .../DateTimePickers/PickerPresenterBase.cs | 58 ++ .../DateTimePickers/DateTimeFormatter.cs | 335 ------ .../DateTimePickers/LoopingSelector.cs | 10 - 8 files changed, 1630 insertions(+), 345 deletions(-) create mode 100644 src/Avalonia.Controls/DateTimePickers/DatePicker.cs create mode 100644 src/Avalonia.Controls/DateTimePickers/DatePickerPresenter.cs create mode 100644 src/Avalonia.Controls/DateTimePickers/DatePickerPresenterItem.cs create mode 100644 src/Avalonia.Controls/DateTimePickers/DatePickerSelectedValueChangedEventArgs.cs create mode 100644 src/Avalonia.Controls/DateTimePickers/DatePickerValueChangedEventArgs.cs create mode 100644 src/Avalonia.Controls/DateTimePickers/PickerPresenterBase.cs delete mode 100644 src/Avalonia.Controls/Templates/DateTimePickers/DateTimeFormatter.cs delete mode 100644 src/Avalonia.Controls/Templates/DateTimePickers/LoopingSelector.cs diff --git a/src/Avalonia.Controls/DateTimePickers/DatePicker.cs b/src/Avalonia.Controls/DateTimePickers/DatePicker.cs new file mode 100644 index 0000000000..e27af94a2d --- /dev/null +++ b/src/Avalonia.Controls/DateTimePickers/DatePicker.cs @@ -0,0 +1,532 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Shapes; +using Avalonia.Controls.Templates; +using Avalonia.Input; +using System; +using System.Text.RegularExpressions; + +namespace Avalonia.Controls +{ + /// + /// A control to allow the user to select a date + /// + public class DatePicker : TemplatedControl + { + public DatePicker() + { + PseudoClasses.Set(":hasnodate", true); + _presenter = new DatePickerPresenter(); + var now = DateTimeOffset.Now; + _minYear = new DateTimeOffset(now.Date.Year - 100, 1, 1, 0, 0, 0, now.Offset); + _maxYear = new DateTimeOffset(now.Date.Year + 100, 12, 31, 0, 0, 0, now.Offset); + + _presenter.DatePicked += OnPresenterDatePicked; + } + + /// + /// Define the Property + /// + public static readonly DirectProperty DayFormatProperty = + AvaloniaProperty.RegisterDirect("DayFormat", + x => x.DayFormat, (x, v) => x.DayFormat = v); + + /// + /// Defines the Property + /// + public static readonly DirectProperty DayVisibleProperty = + AvaloniaProperty.RegisterDirect("DayVisible", + x => x.DayVisible, (x, v) => x.DayVisible = v); + + /// + /// Defines the Property + /// + public static readonly StyledProperty HeaderProperty = + AvaloniaProperty.Register("Header"); + + /// + /// Defines the Property + /// + public static readonly StyledProperty HeaderTemplateProperty = + AvaloniaProperty.Register("HeaderTemplate"); + + /// + /// Defines the Property + /// + public static readonly DirectProperty MaxYearProperty = + AvaloniaProperty.RegisterDirect("MaxYear", x => x.MaxYear, (x, v) => x.MaxYear = v); + + /// + /// Defines the Property + /// + public static readonly DirectProperty MinYearProperty = + AvaloniaProperty.RegisterDirect("MinYear", x => x.MinYear, (x, v) => x.MinYear = v); + + /// + /// Defines the Property + /// + public static readonly DirectProperty MonthFormatProperty = + AvaloniaProperty.RegisterDirect("MonthFormat", x => x.MonthFormat, (x, v) => x.MonthFormat = v); + + /// + /// Defines the Property + /// + public static readonly DirectProperty MonthVisibleProperty = + AvaloniaProperty.RegisterDirect("MonthVisible", x => x.MonthVisible, (x, v) => x.MonthVisible = v); + + /// + /// Defiens the Property + /// + public static readonly DirectProperty YearFormatProperty = + AvaloniaProperty.RegisterDirect("YearFormat", x => x.YearFormat, (x, v) => x.YearFormat = v); + + /// + /// Defines the Property + /// + public static readonly DirectProperty YearVisibleProperty = + AvaloniaProperty.RegisterDirect("YearVisible", x => x.YearVisible, (x, v) => x.YearVisible = v); + + /// + /// Defines the Property + /// + public static readonly DirectProperty SelectedDateProperty = + AvaloniaProperty.RegisterDirect("SelectedDate", x => x.SelectedDate, (x, v) => x.SelectedDate = v); + + /// + /// Gets or sets the day format + /// + public string DayFormat + { + get => _dayFormat; + set => SetAndRaise(DayFormatProperty, ref _dayFormat, value); + } + + /// + /// Gets or sets whether the day is visible + /// + public bool DayVisible + { + get => _dayVisible; + set + { + SetAndRaise(DayVisibleProperty, ref _dayVisible, value); + SetGrid(); + } + } + + /// + /// Gets or sets the DatePicker header + /// + public object Header + { + get => GetValue(HeaderProperty); + set => SetValue(HeaderProperty, value); + } + + /// + /// Gets or sets the header template + /// + public IDataTemplate HeaderTemplate + { + get => GetValue(HeaderTemplateProperty); + set => SetValue(HeaderTemplateProperty, value); + } + + /// + /// Gets or sets the maximum year for the picker + /// + public DateTimeOffset MaxYear + { + get => _maxYear; + set + { + if (value < MinYear) + throw new InvalidOperationException("MaxDate cannot be less than MinDate"); + SetAndRaise(MaxYearProperty, ref _maxYear, value); + + if (SelectedDate.HasValue && SelectedDate.Value > value) + SelectedDate = value; + } + } + + /// + /// Gets or sets the minimum year for the picker + /// + public DateTimeOffset MinYear + { + get => _minYear; + set + { + if (value > MaxYear) + throw new InvalidOperationException("MinDate cannot be greater than MaxDate"); + SetAndRaise(MinYearProperty, ref _minYear, value); + + if (SelectedDate.HasValue && SelectedDate.Value < value) + SelectedDate = value; + } + } + + /// + /// Gets or sets the month format + /// + public string MonthFormat + { + get => _monthFormat; + set => SetAndRaise(MonthFormatProperty, ref _monthFormat, value); + } + + /// + /// Gets or sets whether the month is visible + /// + public bool MonthVisible + { + get => _monthVisible; + set + { + SetAndRaise(MonthVisibleProperty, ref _monthVisible, value); + SetGrid(); + } + } + + /// + /// Gets or sets the year format + /// + public string YearFormat + { + get => _yearFormat; + set => SetAndRaise(YearFormatProperty, ref _yearFormat, value); + } + + /// + /// Gets or sets whether the year is visible + /// + public bool YearVisible + { + get => _yearVisible; + set + { + SetAndRaise(YearVisibleProperty, ref _yearVisible, value); + SetGrid(); + } + } + + /// + /// Gets or sets the Selected Date for the picker, can be null + /// + public DateTimeOffset? SelectedDate + { + get => _selectedDate; + set + { + var old = _selectedDate; + SetAndRaise(SelectedDateProperty, ref _selectedDate, value); + SetSelectedDateText(); + OnSelectedDateChanged(this, new DatePickerSelectedValueChangedEventArgs(old, value)); + } + } + + /// + /// Raised when the changes + /// + public event EventHandler SelectedDateChanged; + + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + _flyoutButton = e.NameScope.Find("FlyoutButton"); + _dayText = e.NameScope.Find("DayText"); + _monthText = e.NameScope.Find("MonthText"); + _yearText = e.NameScope.Find("YearText"); + _container = e.NameScope.Find("ButtonContentGrid"); + _spacer1 = e.NameScope.Find("FirstSpacer"); + _spacer2 = e.NameScope.Find("SecondSpacer"); + + _areControlsAvailable = true; + + SetGrid(); + + SetSelectedDateText(); + + if (_flyoutButton != null) + { + _flyoutButton.Click += OnFlyoutButtonClicked; + } + + + } + + protected override void OnKeyDown(KeyEventArgs e) + { + //switch (e.Key) + //{ + // case Key.Tab: + // //FocusManager.Instance.Focus(_presenter, NavigationMethod.Tab); + // //Debug.WriteLine(FocusManager.Instance.Current.ToString()); + // e.Handled = true; + // break; + //} + base.OnKeyDown(e); + } + + /// + /// Sets up the container grid and makes sure all label and spacers are placed correctly + /// + private void SetGrid() + { + //Brute force method to setup the container grid, probably a better way to do this + //but it works... + + if (!_areControlsAvailable) //hopefully this never happens + return; + + if (!_hasInit) + { + //Display order of date is based on user's culture, we attempt + //to figure out the normal date pattern + //TODO: Find better way to do this, but for now it works... + var fmt = System.Globalization.CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern; + var monthfmt = Regex.Match(fmt, "(M|MM)"); + var yearfmt = Regex.Match(fmt, "(Y|YY|YYY|YYYY|y|yy|yyy|yyyy)"); + var dayfmt = Regex.Match(fmt, "(d|dd)"); + + //Default is M-D-Y (en-us), this gives us fallback if pattern matching fails + _monthIndex = 0; + _yearIndex = 2; + _dayIndex = 1; + + if (monthfmt.Success && yearfmt.Success && dayfmt.Success) + { + _monthIndex = monthfmt.Index; + _yearIndex = yearfmt.Index; + _dayIndex = dayfmt.Index; + } + //Six possible combos, some probably don't actually exist, + //but prep for them anyway + //M d y [x] + //M y d [x] + //d M y [x] + //d y M [x] + //y d M [x] + //y M d [x] + + _hasInit = true; + } + + bool showMonth = MonthVisible; + bool showDay = DayVisible; + bool showYear = YearVisible; + + _container.ColumnDefinitions.Clear(); + + if (showMonth && !showDay && !showYear) //Month Only + { + _container.ColumnDefinitions.Add(new ColumnDefinition(132, GridUnitType.Star)); + _monthText.IsVisible = true; + _dayText.IsVisible = false; + _yearText.IsVisible = false; + _spacer1.IsVisible = false; + _spacer2.IsVisible = false; + + Grid.SetColumn(_monthText, 0); + } + else if (!showMonth && showDay && !showYear) //Day Only + { + _container.ColumnDefinitions.Add(new ColumnDefinition(132, GridUnitType.Star)); + _monthText.IsVisible = false; + _dayText.IsVisible = true; + _yearText.IsVisible = false; + _spacer1.IsVisible = false; + _spacer2.IsVisible = false; + + Grid.SetColumn(_dayText, 0); + } + else if (!showMonth && !showDay && showYear) //Year Only + { + _container.ColumnDefinitions.Add(new ColumnDefinition(132, GridUnitType.Star)); + _monthText.IsVisible = false; + _dayText.IsVisible = false; + _yearText.IsVisible = true; + _spacer1.IsVisible = false; + _spacer2.IsVisible = false; + + Grid.SetColumn(_yearText, 0); + } + else if (showMonth && showDay && !showYear) //Month and Day Only + { + _container.ColumnDefinitions.Add(new ColumnDefinition(_monthIndex < _dayIndex ? 132 : 78, GridUnitType.Star)); + _container.ColumnDefinitions.Add(new ColumnDefinition(0, GridUnitType.Auto)); + _container.ColumnDefinitions.Add(new ColumnDefinition(_monthIndex < _dayIndex ? 78 : 132, GridUnitType.Star)); + + _monthText.IsVisible = true; + _dayText.IsVisible = true; + _yearText.IsVisible = false; + _spacer1.IsVisible = true; + _spacer2.IsVisible = false; + + Grid.SetColumn(_monthText, _monthIndex < _dayIndex ? 0 : 2); + Grid.SetColumn(_dayText, _monthIndex < _dayIndex ? 2 : 0); + Grid.SetColumn(_spacer1, 1); + } + else if (showMonth && !showDay && showYear) //Month and Year Only + { + _container.ColumnDefinitions.Add(new ColumnDefinition(_monthIndex < _yearIndex ? 132 : 78, GridUnitType.Star)); + _container.ColumnDefinitions.Add(new ColumnDefinition(0, GridUnitType.Auto)); + _container.ColumnDefinitions.Add(new ColumnDefinition(_monthIndex < _yearIndex ? 78 : 132, GridUnitType.Star)); + + _monthText.IsVisible = true; + _dayText.IsVisible = false; + _yearText.IsVisible = true; + _spacer1.IsVisible = true; + _spacer2.IsVisible = false; + + Grid.SetColumn(_monthText, _monthIndex < _yearIndex ? 0 : 2); + Grid.SetColumn(_yearText, _monthIndex < _yearIndex ? 2 : 0); + Grid.SetColumn(_spacer1, 1); + } + else if (!showMonth && showDay && showYear) //Day and Year Only + { + _container.ColumnDefinitions.Add(new ColumnDefinition(78, GridUnitType.Star)); + _container.ColumnDefinitions.Add(new ColumnDefinition(0, GridUnitType.Auto)); + _container.ColumnDefinitions.Add(new ColumnDefinition(78, GridUnitType.Star)); + + _monthText.IsVisible = false; + _dayText.IsVisible = true; + _yearText.IsVisible = true; + _spacer1.IsVisible = true; + _spacer2.IsVisible = false; + + Grid.SetColumn(_yearText, _dayIndex < _yearIndex ? 2 : 0); + Grid.SetColumn(_dayText, _dayIndex < _yearIndex ? 0 : 2); + Grid.SetColumn(_spacer1, 1); + } + else if (showMonth && showDay && showYear) //All Visible + { + bool isMonthFirst = _monthIndex < _dayIndex && _monthIndex < _yearIndex; + bool isMonthSecond = (_monthIndex > _dayIndex && _monthIndex < _yearIndex) || + (_monthIndex < _dayIndex && _monthIndex > _yearIndex); + + _container.ColumnDefinitions.Add(new ColumnDefinition(isMonthFirst ? 138 : 78, GridUnitType.Star)); + _container.ColumnDefinitions.Add(new ColumnDefinition(0, GridUnitType.Auto)); + _container.ColumnDefinitions.Add(new ColumnDefinition(isMonthSecond ? 138 : 78, GridUnitType.Star)); + _container.ColumnDefinitions.Add(new ColumnDefinition(0, GridUnitType.Auto)); + _container.ColumnDefinitions.Add(new ColumnDefinition((!isMonthFirst && !isMonthSecond) ? 138 : 78, GridUnitType.Star)); + + _monthText.IsVisible = true; + _dayText.IsVisible = true; + _yearText.IsVisible = true; + _spacer1.IsVisible = true; + _spacer2.IsVisible = true; + + bool isDayFirst = !isMonthFirst && _dayIndex < _yearIndex; + bool isDaySecond = (_dayIndex > _monthIndex && _dayIndex < _yearIndex) || + (_dayIndex < _monthIndex && _dayIndex > _yearIndex); + + bool isYearFirst = !isDayFirst && !isMonthFirst; + bool isYearSecond = (_yearIndex > _monthIndex && _yearIndex < _dayIndex) || + (_yearIndex < _monthIndex && _yearIndex > _dayIndex); + + Grid.SetColumn(_monthText, isMonthFirst ? 0 : isMonthSecond ? 2 : 4); + Grid.SetColumn(_yearText, isYearFirst ? 0 : (isMonthSecond || isDaySecond) ? 4 : 2); + Grid.SetColumn(_dayText, isDayFirst ? 0 : (isMonthSecond || isYearSecond) ? 4 : 2); + + Grid.SetColumn(_spacer1, 1); + Grid.SetColumn(_spacer2, 3); + } + else + { + _monthText.IsVisible = false; + _dayText.IsVisible = false; + _yearText.IsVisible = false; + _spacer1.IsVisible = false; + _spacer2.IsVisible = false; + } + } + + /// + /// Sets the TextBlocks when the SelectedDate changes + /// + private void SetSelectedDateText() + { + if (!_areControlsAvailable) + return; + + if (SelectedDate.HasValue) + { + PseudoClasses.Set(":hasnodate", false); + var selDate = SelectedDate.Value; + _monthText.Text = new DateTimeFormatter(MonthFormat).Format(selDate); + _yearText.Text = new DateTimeFormatter(YearFormat).Format(selDate); + _dayText.Text = new DateTimeFormatter(DayFormat).Format(selDate); + } + else + { + PseudoClasses.Set(":hasnodate", true); + _monthText.Text = "month"; + _yearText.Text = "year"; + _dayText.Text = "day"; + } + } + + private void OnFlyoutButtonClicked(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + //Need to position the popup before displaying it + //We want to position the selected item overtop of the DatePicker + //We want to see the DatePicker left/right borders on either side of the popup + //_flyout.Width = this.Width - 2; + //_flyout.IsOpen = true; + + _presenter.YearFormat = YearFormat; + _presenter.DayFormat = DayFormat; + _presenter.MonthFormat = MonthFormat; + _presenter.MonthVisible = MonthVisible; + _presenter.YearVisible = YearVisible; + _presenter.DayVisible = DayVisible; + //If SelectedDate hasn't been set, fallback to now + _presenter.Date = SelectedDate.HasValue ? SelectedDate.Value : DateTimeOffset.Now; + _presenter.MaxYear = MaxYear; + _presenter.MinYear = MinYear; + + _presenter.ShowAt(this); + } + + private void OnPresenterDatePicked(object sender, DatePickerValueChangedEventArgs args) + { + SelectedDate = args.NewDate; + } + + protected virtual void OnSelectedDateChanged(object sender, DatePickerSelectedValueChangedEventArgs args) + { + SelectedDateChanged?.Invoke(sender, args); + } + + + //Template Items + private Avalonia.Controls.Button _flyoutButton; + private TextBlock _dayText; + private TextBlock _monthText; + private TextBlock _yearText; + private Grid _container; + private Rectangle _spacer1; + private Rectangle _spacer2; + + private DatePickerPresenter _presenter; + + private bool _hasInit; + private bool _areControlsAvailable; + private int _monthIndex; + private int _dayIndex; + private int _yearIndex; + + private string _dayFormat = "{day.integer}"; + private bool _dayVisible = true; + private DateTimeOffset _maxYear; + private DateTimeOffset _minYear; + private string _monthFormat = "{month.full}"; + private bool _monthVisible = true; + private string _yearFormat = "{year.full}"; + private bool _yearVisible = true; + private DateTimeOffset? _selectedDate; + } +} diff --git a/src/Avalonia.Controls/DateTimePickers/DatePickerPresenter.cs b/src/Avalonia.Controls/DateTimePickers/DatePickerPresenter.cs new file mode 100644 index 0000000000..063e6c3779 --- /dev/null +++ b/src/Avalonia.Controls/DateTimePickers/DatePickerPresenter.cs @@ -0,0 +1,971 @@ +using Avalonia; +using Avalonia.Collections; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Shapes; +using Avalonia.Controls.Templates; +using Avalonia.Input; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Text.RegularExpressions; + +namespace Avalonia.Controls +{ + /// + /// Defines the presenter used for selecting a date. Intended for use with + /// but can be used independently. Combines + /// DatePickerFlyout and DatePickerFlyoutPresenter + /// + public class DatePickerPresenter : PickerPresenterBase + { + public DatePickerPresenter() + { + var now = DateTimeOffset.Now; + _minYear = new DateTimeOffset(now.Year - 100, 1, 1, 0, 0, 0, now.Offset); + _maxYear = new DateTimeOffset(now.Year + 100, 12, 31, 0, 0, 0, now.Offset); + _date = now; + + KeyboardNavigation.SetTabNavigation(this, KeyboardNavigationMode.Cycle); + } + + /// + /// Defines the Property + /// + public static readonly DirectProperty DateProperty = + AvaloniaProperty.RegisterDirect("Date", x => x.Date, (x, v) => x.Date = v); + + /// + /// Defines the Property + /// + public static readonly DirectProperty DayFormatProperty = + AvaloniaProperty.RegisterDirect("DayFormat", x => x.DayFormat, (x, v) => x.DayFormat = v); + + /// + /// Defines the Property + /// + public static readonly DirectProperty DayVisibleProperty = + AvaloniaProperty.RegisterDirect("DayVisible", x => x.DayVisible, (x, v) => x.DayVisible = v); + + /// + /// Defines the Property + /// + public static readonly DirectProperty MaxYearProperty = + AvaloniaProperty.RegisterDirect("MaxYear", x => x.MaxYear, (x, v) => x.MaxYear = v); + + /// + /// Defines the Property + /// + public static readonly DirectProperty MinYearProperty = + AvaloniaProperty.RegisterDirect("MinYear", x => x.MinYear, (x, v) => x.MinYear = v); + + /// + /// Defines the Property + /// + public static readonly DirectProperty MonthFormatProperty = + AvaloniaProperty.RegisterDirect("MonthFormat", x => x.MonthFormat, (x, v) => x.MonthFormat = v); + + /// + /// Defines the Property + /// + public static readonly DirectProperty MonthVisibleProperty = + AvaloniaProperty.RegisterDirect("MonthVisible", x => x.MonthVisible, (x, v) => x.MonthVisible = v); + + /// + /// Defines the Property + /// + public static readonly DirectProperty YearFormatProperty = + AvaloniaProperty.RegisterDirect("YearFormat", x => x.YearFormat, (x, v) => x.YearFormat = v); + + /// + /// Defines the Property + /// + public static readonly DirectProperty YearVisibleProperty = + AvaloniaProperty.RegisterDirect("YearVisible", x => x.YearVisible, (x, v) => x.YearVisible = v); + + //These aren't in WinUI + /// + /// Defines the Property + /// + public static readonly StyledProperty YearSelectorItemTemplateProperty = + AvaloniaProperty.Register("YearSelectorItemTemplate"); + + /// + /// Defines the Property + /// + public static readonly StyledProperty MonthSelectorItemTemplateProperty = + AvaloniaProperty.Register("MonthSelectorItemTemplate"); + + /// + /// Defines the Property + /// + public static readonly StyledProperty DaySelectorItemTemplateProperty = + AvaloniaProperty.Register("DaySelectorItemTemplate"); + + /// + /// Gets or sets the current Date for the picker + /// + public DateTimeOffset Date + { + get => _date; + set => SetAndRaise(DateProperty, ref _date, value); + } + + /// + /// Gets or sets the DayFormat + /// + public string DayFormat + { + get => _dayFormat; + set => SetAndRaise(DayFormatProperty, ref _dayFormat, value); + } + + /// + /// Get or sets whether the Day selector is visible + /// + public bool DayVisible + { + get => _dayVisible; + set + { + SetAndRaise(DayVisibleProperty, ref _dayVisible, value); + } + } + + /// + /// Gets or sets the maximum pickable year + /// + public DateTimeOffset MaxYear + { + get => _maxYear; + set + { + SetAndRaise(MaxYearProperty, ref _maxYear, value); + //Coerce date if needed + } + } + + /// + /// Gets or sets the minimum pickable year + /// + public DateTimeOffset MinYear + { + get => _minYear; + set + { + SetAndRaise(MinYearProperty, ref _minYear, value); + //Coerce date if needed + } + } + + /// + /// Gets or sets the month format + /// + public string MonthFormat + { + get => _monthFormat; + set => SetAndRaise(MonthFormatProperty, ref _monthFormat, value); + } + + /// + /// Gets or sets whether the month selector is visible + /// + public bool MonthVisible + { + get => _monthVisible; + set + { + SetAndRaise(MonthVisibleProperty, ref _monthVisible, value); + } + } + + /// + /// Gets or sets the year format + /// + public string YearFormat + { + get => _yearFormat; + set => SetAndRaise(YearFormatProperty, ref _yearFormat, value); + } + + /// + /// Gets or sets whether the year selector is visible + /// + public bool YearVisible + { + get => _yearVisible; + set + { + SetAndRaise(YearVisibleProperty, ref _yearVisible, value); + } + } + + /// + /// Gets or sets the item template for the YearSelector items + /// + public IDataTemplate YearSelectorItemTemplate + { + get => GetValue(YearSelectorItemTemplateProperty); + set => SetValue(YearSelectorItemTemplateProperty, value); + } + + /// + /// Gets or sets the item template for the MonthSelector items + /// + public IDataTemplate MonthSelectorItemTemplate + { + get => GetValue(MonthSelectorItemTemplateProperty); + set => SetValue(MonthSelectorItemTemplateProperty, value); + } + + /// + /// Gets or sets the item template for the DaySelector items + /// + public IDataTemplate DaySelectorItemTemplate + { + get => GetValue(DaySelectorItemTemplateProperty); + set => SetValue(DaySelectorItemTemplateProperty, value); + } + + /// + /// Raised when the AcceptButton is clicked or Enter is pressed + /// + public event EventHandler DatePicked; + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + //This is a requirement, so throw if not found + _pickerContainer = e.NameScope.Get("PickerContainer"); + + _acceptButton = e.NameScope.Find