diff --git a/Avalonia.sln b/Avalonia.sln index 1e2a3c6027..ea30514c3e 100644 --- a/Avalonia.sln +++ b/Avalonia.sln @@ -169,6 +169,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PlatformSanityChecks", "sam EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.ReactiveUI.UnitTests", "tests\Avalonia.ReactiveUI.UnitTests\Avalonia.ReactiveUI.UnitTests.csproj", "{AF915D5C-AB00-4EA0-B5E6-001F4AE84E68}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Controls.ColorPicker", "src\Avalonia.Controls.ColorPicker\Avalonia.Controls.ColorPicker.csproj", "{1ECC012A-8837-4AE2-9BDA-3E2857898727}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Controls.DataGrid", "src\Avalonia.Controls.DataGrid\Avalonia.Controls.DataGrid.csproj", "{3278F3A9-9509-4A3F-A15B-BDC8B5BFF632}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Dialogs", "src\Avalonia.Dialogs\Avalonia.Dialogs.csproj", "{4D55985A-1EE2-4F25-AD39-6EA8BC04F8FB}" @@ -1963,6 +1965,30 @@ Global {2B390431-288C-435C-BB6B-A374033BD8D1}.Release|iPhone.Build.0 = Release|Any CPU {2B390431-288C-435C-BB6B-A374033BD8D1}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU {2B390431-288C-435C-BB6B-A374033BD8D1}.Release|iPhoneSimulator.Build.0 = Release|Any CPU + {1ECC012A-8837-4AE2-9BDA-3E2857898727}.Ad-Hoc|Any CPU.ActiveCfg = Debug|Any CPU + {1ECC012A-8837-4AE2-9BDA-3E2857898727}.Ad-Hoc|Any CPU.Build.0 = Debug|Any CPU + {1ECC012A-8837-4AE2-9BDA-3E2857898727}.Ad-Hoc|iPhone.ActiveCfg = Debug|Any CPU + {1ECC012A-8837-4AE2-9BDA-3E2857898727}.Ad-Hoc|iPhone.Build.0 = Debug|Any CPU + {1ECC012A-8837-4AE2-9BDA-3E2857898727}.Ad-Hoc|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {1ECC012A-8837-4AE2-9BDA-3E2857898727}.Ad-Hoc|iPhoneSimulator.Build.0 = Debug|Any CPU + {1ECC012A-8837-4AE2-9BDA-3E2857898727}.AppStore|Any CPU.ActiveCfg = Debug|Any CPU + {1ECC012A-8837-4AE2-9BDA-3E2857898727}.AppStore|Any CPU.Build.0 = Debug|Any CPU + {1ECC012A-8837-4AE2-9BDA-3E2857898727}.AppStore|iPhone.ActiveCfg = Debug|Any CPU + {1ECC012A-8837-4AE2-9BDA-3E2857898727}.AppStore|iPhone.Build.0 = Debug|Any CPU + {1ECC012A-8837-4AE2-9BDA-3E2857898727}.AppStore|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {1ECC012A-8837-4AE2-9BDA-3E2857898727}.AppStore|iPhoneSimulator.Build.0 = Debug|Any CPU + {1ECC012A-8837-4AE2-9BDA-3E2857898727}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1ECC012A-8837-4AE2-9BDA-3E2857898727}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1ECC012A-8837-4AE2-9BDA-3E2857898727}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {1ECC012A-8837-4AE2-9BDA-3E2857898727}.Debug|iPhone.Build.0 = Debug|Any CPU + {1ECC012A-8837-4AE2-9BDA-3E2857898727}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {1ECC012A-8837-4AE2-9BDA-3E2857898727}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU + {1ECC012A-8837-4AE2-9BDA-3E2857898727}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1ECC012A-8837-4AE2-9BDA-3E2857898727}.Release|Any CPU.Build.0 = Release|Any CPU + {1ECC012A-8837-4AE2-9BDA-3E2857898727}.Release|iPhone.ActiveCfg = Release|Any CPU + {1ECC012A-8837-4AE2-9BDA-3E2857898727}.Release|iPhone.Build.0 = Release|Any CPU + {1ECC012A-8837-4AE2-9BDA-3E2857898727}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {1ECC012A-8837-4AE2-9BDA-3E2857898727}.Release|iPhoneSimulator.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/samples/ControlCatalog/App.xaml.cs b/samples/ControlCatalog/App.xaml.cs index 866fb8632a..7ebb87094a 100644 --- a/samples/ControlCatalog/App.xaml.cs +++ b/samples/ControlCatalog/App.xaml.cs @@ -18,6 +18,16 @@ namespace ControlCatalog DataContext = new ApplicationViewModel(); } + public static readonly StyleInclude ColorPickerFluent = new StyleInclude(new Uri("avares://ControlCatalog/Styles")) + { + Source = new Uri("avares://Avalonia.Controls.ColorPicker/Themes/Fluent/Fluent.xaml") + }; + + public static readonly StyleInclude ColorPickerDefault = new StyleInclude(new Uri("avares://ControlCatalog/Styles")) + { + Source = new Uri("avares://Avalonia.Controls.ColorPicker/Themes/Default/Default.xaml") + }; + public static readonly StyleInclude DataGridFluent = new StyleInclude(new Uri("avares://ControlCatalog/Styles")) { Source = new Uri("avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml") @@ -69,7 +79,8 @@ namespace ControlCatalog public override void Initialize() { Styles.Insert(0, Fluent); - Styles.Insert(1, DataGridFluent); + Styles.Insert(1, ColorPickerFluent); + Styles.Insert(2, DataGridFluent); AvaloniaXamlLoader.Load(this); } diff --git a/samples/ControlCatalog/ControlCatalog.csproj b/samples/ControlCatalog/ControlCatalog.csproj index c0e24357ca..7cbd8a3f9c 100644 --- a/samples/ControlCatalog/ControlCatalog.csproj +++ b/samples/ControlCatalog/ControlCatalog.csproj @@ -23,6 +23,7 @@ + diff --git a/samples/ControlCatalog/MainView.xaml b/samples/ControlCatalog/MainView.xaml index 85f278b5fa..59d724db69 100644 --- a/samples/ControlCatalog/MainView.xaml +++ b/samples/ControlCatalog/MainView.xaml @@ -43,6 +43,9 @@ + + + diff --git a/samples/ControlCatalog/MainView.xaml.cs b/samples/ControlCatalog/MainView.xaml.cs index e8ea39abbb..0326946c08 100644 --- a/samples/ControlCatalog/MainView.xaml.cs +++ b/samples/ControlCatalog/MainView.xaml.cs @@ -49,7 +49,8 @@ namespace ControlCatalog App.Fluent.Mode = FluentThemeMode.Light; } Application.Current.Styles[0] = App.Fluent; - Application.Current.Styles[1] = App.DataGridFluent; + Application.Current.Styles[1] = App.ColorPickerFluent; + Application.Current.Styles[2] = App.DataGridFluent; } else if (theme == CatalogTheme.FluentDark) { @@ -59,19 +60,22 @@ namespace ControlCatalog App.Fluent.Mode = FluentThemeMode.Dark; } Application.Current.Styles[0] = App.Fluent; - Application.Current.Styles[1] = App.DataGridFluent; + Application.Current.Styles[1] = App.ColorPickerFluent; + Application.Current.Styles[2] = App.DataGridFluent; } else if (theme == CatalogTheme.DefaultLight) { App.Default.Mode = Avalonia.Themes.Default.SimpleThemeMode.Light; Application.Current.Styles[0] = App.DefaultLight; - Application.Current.Styles[1] = App.DataGridDefault; + Application.Current.Styles[1] = App.ColorPickerDefault; + Application.Current.Styles[2] = App.DataGridDefault; } else if (theme == CatalogTheme.DefaultDark) { App.Default.Mode = Avalonia.Themes.Default.SimpleThemeMode.Dark; Application.Current.Styles[0] = App.DefaultDark; - Application.Current.Styles[1] = App.DataGridDefault; + Application.Current.Styles[1] = App.ColorPickerDefault; + Application.Current.Styles[2] = App.DataGridDefault; } } }; diff --git a/samples/ControlCatalog/Pages/ColorPickerPage.xaml b/samples/ControlCatalog/Pages/ColorPickerPage.xaml new file mode 100644 index 0000000000..c0c83d6a35 --- /dev/null +++ b/samples/ControlCatalog/Pages/ColorPickerPage.xaml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/ControlCatalog/Pages/ColorPickerPage.xaml.cs b/samples/ControlCatalog/Pages/ColorPickerPage.xaml.cs new file mode 100644 index 0000000000..6e017e381f --- /dev/null +++ b/samples/ControlCatalog/Pages/ColorPickerPage.xaml.cs @@ -0,0 +1,19 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace ControlCatalog.Pages +{ + public partial class ColorPickerPage : UserControl + { + public ColorPickerPage() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + } +} diff --git a/samples/Sandbox/Sandbox.csproj b/samples/Sandbox/Sandbox.csproj index 8f2812e048..f3c38cd96e 100644 --- a/samples/Sandbox/Sandbox.csproj +++ b/samples/Sandbox/Sandbox.csproj @@ -8,6 +8,7 @@ + diff --git a/src/Avalonia.Base/Controls/ResourceNodeExtensions.cs b/src/Avalonia.Base/Controls/ResourceNodeExtensions.cs index 513b3f2424..1758c45650 100644 --- a/src/Avalonia.Base/Controls/ResourceNodeExtensions.cs +++ b/src/Avalonia.Base/Controls/ResourceNodeExtensions.cs @@ -40,19 +40,16 @@ namespace Avalonia.Controls control = control ?? throw new ArgumentNullException(nameof(control)); key = key ?? throw new ArgumentNullException(nameof(key)); - IResourceHost? current = control; + IResourceNode? current = control; while (current != null) { - if (current is IResourceHost host) + if (current.TryGetResource(key, out value)) { - if (host.TryGetResource(key, out value)) - { - return true; - } + return true; } - current = (current as IStyledElement)?.StylingParent as IResourceHost; + current = (current as IStyledElement)?.StylingParent as IResourceNode; } value = null; diff --git a/src/Avalonia.Base/Media/GlyphRun.cs b/src/Avalonia.Base/Media/GlyphRun.cs index ec270d796a..22be8d8865 100644 --- a/src/Avalonia.Base/Media/GlyphRun.cs +++ b/src/Avalonia.Base/Media/GlyphRun.cs @@ -28,6 +28,8 @@ namespace Avalonia.Media private IReadOnlyList? _glyphOffsets; private IReadOnlyList? _glyphClusters; + private int _offsetToFirstCharacter; + /// /// Initializes a new instance of the class by specifying properties of the class. /// @@ -49,7 +51,7 @@ namespace Avalonia.Media IReadOnlyList? glyphClusters = null, int biDiLevel = 0) { - _glyphTypeface = glyphTypeface; + _glyphTypeface = glyphTypeface; FontRenderingEmSize = fontRenderingEmSize; @@ -203,8 +205,8 @@ namespace Avalonia.Media /// public double GetDistanceFromCharacterHit(CharacterHit characterHit) { - var characterIndex = characterHit.FirstCharacterIndex + characterHit.TrailingLength; - + var characterIndex = characterHit.FirstCharacterIndex + characterHit.TrailingLength - _offsetToFirstCharacter; + var distance = 0.0; if (IsLeftToRight) @@ -223,7 +225,7 @@ namespace Avalonia.Media } var glyphIndex = FindGlyphIndex(characterIndex); - + if (GlyphClusters != null) { var currentCluster = GlyphClusters[glyphIndex]; @@ -249,7 +251,7 @@ namespace Avalonia.Media { //RightToLeft var glyphIndex = FindGlyphIndex(characterIndex); - + if (GlyphClusters != null) { if (characterIndex > GlyphClusters[0]) @@ -284,13 +286,13 @@ namespace Avalonia.Media public CharacterHit GetCharacterHitFromDistance(double distance, out bool isInside) { var characterIndex = 0; - + // Before if (distance <= 0) { isInside = false; - if(GlyphClusters != null) + if (GlyphClusters != null) { characterIndex = GlyphClusters[characterIndex]; } @@ -307,11 +309,11 @@ namespace Avalonia.Media characterIndex = GlyphIndices.Count - 1; - if(GlyphClusters != null) + if (GlyphClusters != null) { characterIndex = GlyphClusters[characterIndex]; } - + var lastCharacterHit = FindNearestCharacterHit(characterIndex, out _); return IsLeftToRight ? lastCharacterHit : new CharacterHit(lastCharacterHit.FirstCharacterIndex); @@ -327,7 +329,7 @@ namespace Avalonia.Media var advance = GetGlyphAdvance(index, out var cluster); characterIndex = cluster; - + if (distance > currentX && distance <= currentX + advance) { break; @@ -345,7 +347,7 @@ namespace Avalonia.Media var advance = GetGlyphAdvance(index, out var cluster); characterIndex = cluster; - + if (currentX - advance < distance) { break; @@ -552,20 +554,20 @@ namespace Avalonia.Media } nextCluster = GlyphClusters[currentIndex]; - } + } int trailingLength; if (nextCluster == cluster) { - trailingLength = Characters.Start + Characters.Length - cluster; + trailingLength = Characters.Start + Characters.Length - _offsetToFirstCharacter - cluster; } else { trailingLength = nextCluster - cluster; } - return new CharacterHit(cluster, trailingLength); + return new CharacterHit(_offsetToFirstCharacter + cluster, trailingLength); } /// @@ -577,7 +579,7 @@ namespace Avalonia.Media private double GetGlyphAdvance(int index, out int cluster) { cluster = GlyphClusters != null ? GlyphClusters[index] : index; - + if (GlyphAdvances != null) { return GlyphAdvances[index]; @@ -599,11 +601,18 @@ namespace Avalonia.Media private GlyphRunMetrics CreateGlyphRunMetrics() { + if (GlyphClusters != null && GlyphClusters.Count > 0) + { + var firstCluster = GlyphClusters[0]; + + _offsetToFirstCharacter = Math.Max(0, Characters.Start - firstCluster); + } + var height = (GlyphTypeface.Descent - GlyphTypeface.Ascent + GlyphTypeface.LineGap) * Scale; var widthIncludingTrailingWhitespace = 0d; var trailingWhitespaceLength = GetTrailingWhitespaceLength(out var newLineLength, out var glyphCount); - + for (var index = 0; index < GlyphIndices.Count; index++) { var advance = GetGlyphAdvance(index, out _); @@ -615,7 +624,7 @@ namespace Avalonia.Media if (IsLeftToRight) { - for (var index = GlyphIndices.Count - glyphCount; index = 0; i--) { - var cluster = GlyphClusters[i]; + var currentCluster = GlyphClusters[i]; + var characterIndex = Math.Max(0, currentCluster - _characters.BufferOffset); + var codepoint = Codepoint.ReadAt(_characters, characterIndex, out _); - var codepointIndex = IsLeftToRight ? cluster - _characters.Start : _characters.End - cluster; - - if (codepointIndex < 0) + if (!codepoint.IsWhiteSpace) { - trailingWhitespaceLength = _characters.Length; - - glyphCount = GlyphClusters.Count; - break; } - - var codepoint = Codepoint.ReadAt(_characters, codepointIndex, out _); - if (!codepoint.IsWhiteSpace) + var clusterLength = 1; + + while(i - 1 >= 0) { + var nextCluster = GlyphClusters[i - 1]; + + if(currentCluster == nextCluster) + { + clusterLength++; + i--; + + continue; + } + break; } if (codepoint.IsBreakChar) { - newLineLength++; + newLineLength += clusterLength; } - trailingWhitespaceLength++; - - glyphCount++; + trailingWhitespaceLength += clusterLength; + + glyphCount++; } } diff --git a/src/Avalonia.Base/Media/TextFormatting/TextFormatter.cs b/src/Avalonia.Base/Media/TextFormatting/TextFormatter.cs index d521077a43..0b5d7649d7 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextFormatter.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextFormatter.cs @@ -1,6 +1,4 @@ -using Avalonia.Media.TextFormatting.Unicode; - -namespace Avalonia.Media.TextFormatting +namespace Avalonia.Media.TextFormatting { /// /// Represents a base class for text formatting. diff --git a/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs index 7c60f73b8d..7f0f204886 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs @@ -8,6 +8,8 @@ namespace Avalonia.Media.TextFormatting { internal class TextFormatterImpl : TextFormatter { + private static readonly char[] s_empty = { ' ' }; + /// public override TextLine FormatLine(ITextSource textSource, int firstTextSourceIndex, double paragraphWidth, TextParagraphProperties paragraphProperties, TextLineBreak? previousLineBreak = null) @@ -77,14 +79,14 @@ namespace Avalonia.Media.TextFormatting { var currentRun = textRuns[i]; - if (currentLength + currentRun.Text.Length < length) + if (currentLength + currentRun.TextSourceLength < length) { currentLength += currentRun.TextSourceLength; continue; } - var firstCount = currentRun.Text.Length >= 1 ? i + 1 : i; + var firstCount = currentRun.TextSourceLength >= 1 ? i + 1 : i; var first = new List(firstCount); @@ -98,13 +100,13 @@ namespace Avalonia.Media.TextFormatting var secondCount = textRuns.Count - firstCount; - if (currentLength + currentRun.Text.Length == length) + if (currentLength + currentRun.TextSourceLength == length) { var second = secondCount > 0 ? new List(secondCount) : null; if (second != null) { - var offset = currentRun.Text.Length >= 1 ? 1 : 0; + var offset = currentRun.TextSourceLength >= 1 ? 1 : 0; for (var j = 0; j < secondCount; j++) { @@ -122,16 +124,14 @@ namespace Avalonia.Media.TextFormatting var second = new List(secondCount); - if (currentRun is not ShapedTextCharacters shapedTextCharacters) + if (currentRun is ShapedTextCharacters shapedTextCharacters) { - throw new NotSupportedException("Only shaped runs can be split in between."); - } - - var split = shapedTextCharacters.Split(length - currentLength); + var split = shapedTextCharacters.Split(length - currentLength); - first.Add(split.First); + first.Add(split.First); - second.Add(split.Second!); + second.Add(split.Second!); + } for (var j = 1; j < secondCount; j++) { @@ -267,7 +267,6 @@ namespace Avalonia.Media.TextFormatting IReadOnlyList textRuns, ReadOnlySlice text, TextShaperOptions options) { var shapedRuns = new List(textRuns.Count); - var firstRun = textRuns[0]; var shapedBuffer = TextShaper.Current.ShapeText(text, options); @@ -471,11 +470,10 @@ namespace Avalonia.Media.TextFormatting return false; } - private static bool TryMeasureLength(IReadOnlyList textRuns, int firstTextSourceIndex, double paragraphWidth, out int measuredLength) + private static bool TryMeasureLength(IReadOnlyList textRuns, double paragraphWidth, out int measuredLength) { measuredLength = 0; var currentWidth = 0.0; - var lastCluster = firstTextSourceIndex; foreach (var currentRun in textRuns) { @@ -483,12 +481,17 @@ namespace Avalonia.Media.TextFormatting { case ShapedTextCharacters shapedTextCharacters: { + var firstCluster = shapedTextCharacters.ShapedBuffer.GlyphClusters[0]; + var lastCluster = firstCluster; + for (var i = 0; i < shapedTextCharacters.ShapedBuffer.Length; i++) { var glyphInfo = shapedTextCharacters.ShapedBuffer[i]; if (currentWidth + glyphInfo.GlyphAdvance > paragraphWidth) { + measuredLength += Math.Max(0, lastCluster - firstCluster); + goto found; } @@ -496,6 +499,8 @@ namespace Avalonia.Media.TextFormatting currentWidth += glyphInfo.GlyphAdvance; } + measuredLength += currentRun.TextSourceLength; + break; } @@ -506,7 +511,7 @@ namespace Avalonia.Media.TextFormatting goto found; } - lastCluster += currentRun.TextSourceLength; + measuredLength += currentRun.TextSourceLength; currentWidth += currentRun.Size.Width; break; @@ -516,11 +521,30 @@ namespace Avalonia.Media.TextFormatting found: - measuredLength = Math.Max(0, lastCluster - firstTextSourceIndex + 1); - return measuredLength != 0; } + /// + /// Creates an empty text line. + /// + /// The empty text line. + public static TextLineImpl CreateEmptyTextLine(int firstTextSourceIndex, TextParagraphProperties paragraphProperties) + { + var flowDirection = paragraphProperties.FlowDirection; + var properties = paragraphProperties.DefaultTextRunProperties; + var glyphTypeface = properties.Typeface.GlyphTypeface; + var text = new ReadOnlySlice(s_empty, firstTextSourceIndex, 1); + var glyph = glyphTypeface.GetGlyph(s_empty[0]); + var glyphInfos = new[] { new GlyphInfo(glyph, firstTextSourceIndex) }; + + var shapedBuffer = new ShapedBuffer(text, glyphInfos, glyphTypeface, properties.FontRenderingEmSize, + (sbyte)flowDirection); + + var textRuns = new List { new ShapedTextCharacters(shapedBuffer, properties) }; + + return new TextLineImpl(textRuns, firstTextSourceIndex, 0, double.PositiveInfinity, paragraphProperties, flowDirection).FinalizeLine(); + } + /// /// Performs text wrapping returns a list of text lines. /// @@ -535,7 +559,12 @@ namespace Avalonia.Media.TextFormatting double paragraphWidth, TextParagraphProperties paragraphProperties, FlowDirection flowDirection, TextLineBreak? currentLineBreak) { - if (!TryMeasureLength(textRuns, firstTextSourceIndex, paragraphWidth, out var measuredLength)) + if(textRuns.Count == 0) + { + return CreateEmptyTextLine(firstTextSourceIndex, paragraphProperties); + } + + if (!TryMeasureLength(textRuns, paragraphWidth, out var measuredLength)) { measuredLength = 1; } diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs b/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs index e3bcdee014..0df608cb34 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs @@ -10,13 +10,12 @@ namespace Avalonia.Media.TextFormatting /// public class TextLayout { - private static readonly char[] s_empty = { ' ' }; - - private readonly ReadOnlySlice _text; + private readonly ITextSource _textSource; private readonly TextParagraphProperties _paragraphProperties; - private readonly IReadOnlyList>? _textStyleOverrides; private readonly TextTrimming _textTrimming; + private int _textSourceLength; + /// /// Initializes a new instance of the class. /// @@ -50,17 +49,49 @@ namespace Avalonia.Media.TextFormatting int maxLines = 0, IReadOnlyList>? textStyleOverrides = null) { - _text = string.IsNullOrEmpty(text) ? - new ReadOnlySlice() : - new ReadOnlySlice(text.AsMemory()); - _paragraphProperties = CreateTextParagraphProperties(typeface, fontSize, foreground, textAlignment, textWrapping, textDecorations, flowDirection, lineHeight); + _textSource = new FormattedTextSource(text.AsMemory(), _paragraphProperties.DefaultTextRunProperties, textStyleOverrides); + _textTrimming = textTrimming ?? TextTrimming.None; - _textStyleOverrides = textStyleOverrides; + LineHeight = lineHeight; + + MaxWidth = maxWidth; + + MaxHeight = maxHeight; + + MaxLines = maxLines; + + TextLines = CreateTextLines(); + } + + /// + /// Initializes a new instance of the class. + /// + /// The text source. + /// The default text paragraph properties. + /// The text trimming. + /// The maximum width. + /// The maximum height. + /// The height of each line of text. + /// The maximum number of text lines. + public TextLayout( + ITextSource textSource, + TextParagraphProperties paragraphProperties, + TextTrimming? textTrimming = null, + double maxWidth = double.PositiveInfinity, + double maxHeight = double.PositiveInfinity, + double lineHeight = double.NaN, + int maxLines = 0) + { + _textSource = textSource; + + _paragraphProperties = paragraphProperties; + + _textTrimming = textTrimming ?? TextTrimming.None; LineHeight = lineHeight; @@ -147,7 +178,7 @@ namespace Avalonia.Media.TextFormatting return new Rect(); } - if (textPosition < 0 || textPosition >= _text.Length) + if (textPosition < 0 || textPosition >= _textSourceLength) { var lastLine = TextLines[TextLines.Count - 1]; @@ -273,7 +304,7 @@ namespace Avalonia.Media.TextFormatting return 0; } - if (charIndex > _text.Length) + if (charIndex > _textSourceLength) { return TextLines.Count - 1; } @@ -375,32 +406,11 @@ namespace Avalonia.Media.TextFormatting height += textLine.Height; } - /// - /// Creates an empty text line. - /// - /// The empty text line. - private TextLine CreateEmptyTextLine(int firstTextSourceIndex) - { - var flowDirection = _paragraphProperties.FlowDirection; - var properties = _paragraphProperties.DefaultTextRunProperties; - var glyphTypeface = properties.Typeface.GlyphTypeface; - var text = new ReadOnlySlice(s_empty, firstTextSourceIndex, 1); - var glyph = glyphTypeface.GetGlyph(s_empty[0]); - var glyphInfos = new[] { new GlyphInfo(glyph, firstTextSourceIndex) }; - - var shapedBuffer = new ShapedBuffer(text, glyphInfos, glyphTypeface, properties.FontRenderingEmSize, - (sbyte)flowDirection); - - var textRuns = new List { new ShapedTextCharacters(shapedBuffer, properties) }; - - return new TextLineImpl(textRuns, firstTextSourceIndex, 1, MaxWidth, _paragraphProperties, flowDirection).FinalizeLine(); - } - private IReadOnlyList CreateTextLines() { - if (_text.IsEmpty || MathUtilities.IsZero(MaxWidth) || MathUtilities.IsZero(MaxHeight)) + if (MathUtilities.IsZero(MaxWidth) || MathUtilities.IsZero(MaxHeight)) { - var textLine = CreateEmptyTextLine(0); + var textLine = TextFormatterImpl.CreateEmptyTextLine(0, _paragraphProperties); Bounds = new Rect(0,0,0, textLine.Height); @@ -411,26 +421,30 @@ namespace Avalonia.Media.TextFormatting double left = double.PositiveInfinity, width = 0.0, height = 0.0; - var currentPosition = 0; - - var textSource = new FormattedTextSource(_text, - _paragraphProperties.DefaultTextRunProperties, _textStyleOverrides); + _textSourceLength = 0; TextLine? previousLine = null; - while (currentPosition < _text.Length) + while (true) { - var textLine = TextFormatter.Current.FormatLine(textSource, currentPosition, MaxWidth, + var textLine = TextFormatter.Current.FormatLine(_textSource, _textSourceLength, MaxWidth, _paragraphProperties, previousLine?.TextLineBreak); -#if DEBUG - if (textLine.Length == 0) + if(textLine == null || textLine.Length == 0) { - throw new InvalidOperationException($"{nameof(textLine)} should not be empty."); + if(previousLine != null && previousLine.NewLineLength > 0) + { + var emptyTextLine = TextFormatterImpl.CreateEmptyTextLine(_textSourceLength, _paragraphProperties); + + textLines.Add(emptyTextLine); + + UpdateBounds(emptyTextLine, ref left, ref width, ref height); + } + + break; } -#endif - currentPosition += textLine.Length; + _textSourceLength += textLine.Length; //Fulfill max height constraint if (textLines.Count > 0 && !double.IsPositiveInfinity(MaxHeight) && height + textLine.Height > MaxHeight) @@ -464,17 +478,16 @@ namespace Avalonia.Media.TextFormatting { break; } - - if (currentPosition != _text.Length || textLine.NewLineLength <= 0) - { - continue; - } + } - var emptyTextLine = CreateEmptyTextLine(currentPosition); + //Make sure the TextLayout always contains at least on empty line + if(textLines.Count == 0) + { + var textLine = TextFormatterImpl.CreateEmptyTextLine(0, _paragraphProperties); - textLines.Add(emptyTextLine); + textLines.Add(textLine); - UpdateBounds(emptyTextLine,ref left, ref width, ref height); + UpdateBounds(textLine, ref left, ref width, ref height); } Bounds = new Rect(left, 0, width, height); diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs index 30e3728d1f..6a704f6f3e 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs @@ -183,6 +183,7 @@ namespace Avalonia.Media.TextFormatting case ShapedTextCharacters shapedRun: { characterHit = shapedRun.GlyphRun.GetCharacterHitFromDistance(distance, out _); + break; } default: @@ -403,7 +404,7 @@ namespace Avalonia.Media.TextFormatting var result = new List(TextRuns.Count); var lastDirection = _flowDirection; var currentDirection = lastDirection; - var currentPosition = 0; + var currentPosition = FirstTextSourceIndex; var currentRect = Rect.Empty; var startX = Start; @@ -417,6 +418,11 @@ namespace Avalonia.Media.TextFormatting continue; } + if(currentPosition + currentRun.TextSourceLength <= firstTextSourceCharacterIndex) + { + continue; + } + TextRun? nextRun = null; if (index + 1 < TextRuns.Count) @@ -426,31 +432,42 @@ namespace Avalonia.Media.TextFormatting if (nextRun != null) { - if (nextRun.Text.Start < currentRun.Text.Start && firstTextSourceCharacterIndex + textLength < currentRun.Text.End) + switch (nextRun) { - goto skip; - } + case ShapedTextCharacters when currentRun is ShapedTextCharacters: + { + if (nextRun.Text.Start < currentRun.Text.Start && firstTextSourceCharacterIndex + textLength < currentRun.Text.End) + { + goto skip; + } - if (currentRun.Text.Start >= firstTextSourceCharacterIndex + textLength) - { - goto skip; - } + if (currentRun.Text.Start >= firstTextSourceCharacterIndex + textLength) + { + goto skip; + } - if (currentRun.Text.Start > nextRun.Text.Start && currentRun.Text.Start < firstTextSourceCharacterIndex) - { - goto skip; - } + if (currentRun.Text.Start > nextRun.Text.Start && currentRun.Text.Start < firstTextSourceCharacterIndex) + { + goto skip; + } - if (currentRun.Text.End < firstTextSourceCharacterIndex) - { - goto skip; - } + if (currentRun.Text.End < firstTextSourceCharacterIndex) + { + goto skip; + } - goto noop; + goto noop; + } + default: + { + goto noop; + } + } skip: { startX += currentRun.Size.Width; + currentPosition += currentRun.TextSourceLength; } continue; @@ -460,7 +477,6 @@ namespace Avalonia.Media.TextFormatting } } - var endX = startX; var endOffset = 0d; @@ -520,11 +536,13 @@ namespace Avalonia.Media.TextFormatting } default: { - if (firstTextSourceCharacterIndex + textLength >= currentRun.Text.Start + currentRun.Text.Length) + if (currentPosition + currentRun.TextSourceLength <= firstTextSourceCharacterIndex + textLength) { endX += currentRun.Size.Width; } + currentPosition += currentRun.TextSourceLength; + break; } } @@ -538,7 +556,9 @@ namespace Avalonia.Media.TextFormatting if (lastDirection == currentDirection && result.Count > 0 && MathUtilities.AreClose(currentRect.Right, startX)) { - var textBounds = new TextBounds(currentRect.WithWidth(currentRect.Width + width), currentDirection); + currentRect = currentRect.WithWidth(currentRect.Width + width); + + var textBounds = new TextBounds(currentRect, currentDirection); result[result.Count - 1] = textBounds; } @@ -551,21 +571,9 @@ namespace Avalonia.Media.TextFormatting if (currentDirection == FlowDirection.LeftToRight) { - if (nextRun != null) - { - if (nextRun.Text.Start > currentRun.Text.Start && nextRun.Text.Start >= firstTextSourceCharacterIndex + textLength) - { - break; - } - - currentPosition = nextRun.Text.End; - } - else + if (currentPosition >= firstTextSourceCharacterIndex + textLength) { - if (currentPosition >= firstTextSourceCharacterIndex + textLength) - { - break; - } + break; } } else @@ -575,10 +583,7 @@ namespace Avalonia.Media.TextFormatting break; } - if (currentPosition != currentRun.Text.Start) - { - endX += currentRun.Size.Width - endOffset; - } + endX += currentRun.Size.Width - endOffset; } lastDirection = currentDirection; @@ -1018,31 +1023,21 @@ namespace Avalonia.Media.TextFormatting private TextLineMetrics CreateLineMetrics() { - var start = 0d; - var height = 0d; + var glyphTypeface = _paragraphProperties.DefaultTextRunProperties.Typeface.GlyphTypeface; + var fontRenderingEmSize = _paragraphProperties.DefaultTextRunProperties.FontRenderingEmSize; + var scale = fontRenderingEmSize / glyphTypeface.DesignEmHeight; + var width = 0d; var widthIncludingWhitespace = 0d; var trailingWhitespaceLength = 0; var newLineLength = 0; - var ascent = 0d; - var descent = 0d; - var lineGap = 0d; - var fontRenderingEmSize = 0d; + var ascent = glyphTypeface.Ascent * scale; + var descent = glyphTypeface.Descent * scale; + var lineGap = glyphTypeface.LineGap * scale; - var lineHeight = _paragraphProperties.LineHeight; - - if (_textRuns.Count == 0) - { - var glyphTypeface = _paragraphProperties.DefaultTextRunProperties.Typeface.GlyphTypeface; - fontRenderingEmSize = _paragraphProperties.DefaultTextRunProperties.FontRenderingEmSize; - var scale = fontRenderingEmSize / glyphTypeface.DesignEmHeight; - ascent = glyphTypeface.Ascent * scale; - height = double.IsNaN(lineHeight) || MathUtilities.IsZero(lineHeight) ? - descent - ascent + lineGap : - lineHeight; - - return new TextLineMetrics(false, height, 0, start, -ascent, 0, 0, 0); - } + var height = descent - ascent + lineGap; + + var lineHeight = _paragraphProperties.LineHeight; for (var index = 0; index < _textRuns.Count; index++) { @@ -1166,12 +1161,15 @@ namespace Avalonia.Media.TextFormatting } } - start = GetParagraphOffsetX(width, widthIncludingWhitespace, _paragraphWidth, + var start = GetParagraphOffsetX(width, widthIncludingWhitespace, _paragraphWidth, _paragraphProperties.TextAlignment, _paragraphProperties.FlowDirection); if (!double.IsNaN(lineHeight) && !MathUtilities.IsZero(lineHeight)) { - height = lineHeight; + if(lineHeight > height) + { + height = lineHeight; + } } return new TextLineMetrics(widthIncludingWhitespace > _paragraphWidth, height, newLineLength, start, diff --git a/src/Avalonia.Base/Media/TextFormatting/Unicode/BiDiAlgorithm.cs b/src/Avalonia.Base/Media/TextFormatting/Unicode/BiDiAlgorithm.cs index d18a4b2a87..2511807d9c 100644 --- a/src/Avalonia.Base/Media/TextFormatting/Unicode/BiDiAlgorithm.cs +++ b/src/Avalonia.Base/Media/TextFormatting/Unicode/BiDiAlgorithm.cs @@ -238,6 +238,11 @@ namespace Avalonia.Media.TextFormatting.Unicode _levelRuns.Clear(); _resolvedLevelsBuffer.Clear(); + if (types.IsEmpty) + { + return; + } + // Setup original types and working types _originalClasses = types; _workingClasses = _workingClassesBuffer.Add(types); diff --git a/src/Avalonia.Base/Properties/AssemblyInfo.cs b/src/Avalonia.Base/Properties/AssemblyInfo.cs index a0560924e7..2c40c768f5 100644 --- a/src/Avalonia.Base/Properties/AssemblyInfo.cs +++ b/src/Avalonia.Base/Properties/AssemblyInfo.cs @@ -19,6 +19,7 @@ using Avalonia.Metadata; [assembly: InternalsVisibleTo("Avalonia.Base.UnitTests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c1bba1142285fe0419326fb25866ba62c47e6c2b5c1ab0c95b46413fad375471232cb81706932e1cef38781b9ebd39d5100401bacb651c6c5bbf59e571e81b3bc08d2a622004e08b1a6ece82a7e0b9857525c86d2b95fab4bc3dce148558d7f3ae61aa3a234086902aeface87d9dfdd32b9d2fe3c6dd4055b5ab4b104998bd87")] [assembly: InternalsVisibleTo("Avalonia.Controls, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c1bba1142285fe0419326fb25866ba62c47e6c2b5c1ab0c95b46413fad375471232cb81706932e1cef38781b9ebd39d5100401bacb651c6c5bbf59e571e81b3bc08d2a622004e08b1a6ece82a7e0b9857525c86d2b95fab4bc3dce148558d7f3ae61aa3a234086902aeface87d9dfdd32b9d2fe3c6dd4055b5ab4b104998bd87")] +[assembly: InternalsVisibleTo("Avalonia.Controls.ColorPicker, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c1bba1142285fe0419326fb25866ba62c47e6c2b5c1ab0c95b46413fad375471232cb81706932e1cef38781b9ebd39d5100401bacb651c6c5bbf59e571e81b3bc08d2a622004e08b1a6ece82a7e0b9857525c86d2b95fab4bc3dce148558d7f3ae61aa3a234086902aeface87d9dfdd32b9d2fe3c6dd4055b5ab4b104998bd87")] [assembly: InternalsVisibleTo("Avalonia.Controls.DataGrid, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c1bba1142285fe0419326fb25866ba62c47e6c2b5c1ab0c95b46413fad375471232cb81706932e1cef38781b9ebd39d5100401bacb651c6c5bbf59e571e81b3bc08d2a622004e08b1a6ece82a7e0b9857525c86d2b95fab4bc3dce148558d7f3ae61aa3a234086902aeface87d9dfdd32b9d2fe3c6dd4055b5ab4b104998bd87")] [assembly: InternalsVisibleTo("Avalonia.Controls.UnitTests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c1bba1142285fe0419326fb25866ba62c47e6c2b5c1ab0c95b46413fad375471232cb81706932e1cef38781b9ebd39d5100401bacb651c6c5bbf59e571e81b3bc08d2a622004e08b1a6ece82a7e0b9857525c86d2b95fab4bc3dce148558d7f3ae61aa3a234086902aeface87d9dfdd32b9d2fe3c6dd4055b5ab4b104998bd87")] [assembly: InternalsVisibleTo("Avalonia.DesignerSupport, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c1bba1142285fe0419326fb25866ba62c47e6c2b5c1ab0c95b46413fad375471232cb81706932e1cef38781b9ebd39d5100401bacb651c6c5bbf59e571e81b3bc08d2a622004e08b1a6ece82a7e0b9857525c86d2b95fab4bc3dce148558d7f3ae61aa3a234086902aeface87d9dfdd32b9d2fe3c6dd4055b5ab4b104998bd87")] diff --git a/src/Avalonia.Base/Styling/IStyle.cs b/src/Avalonia.Base/Styling/IStyle.cs index 78fbe0f2b5..738a69cb88 100644 --- a/src/Avalonia.Base/Styling/IStyle.cs +++ b/src/Avalonia.Base/Styling/IStyle.cs @@ -8,7 +8,7 @@ namespace Avalonia.Styling /// /// Defines the interface for styles. /// - public interface IStyle + public interface IStyle : IResourceNode { /// /// Gets a collection of child styles. diff --git a/src/Avalonia.Base/Styling/Styles.cs b/src/Avalonia.Base/Styling/Styles.cs index 81502f1570..d79081152e 100644 --- a/src/Avalonia.Base/Styling/Styles.cs +++ b/src/Avalonia.Base/Styling/Styles.cs @@ -160,7 +160,7 @@ namespace Avalonia.Styling for (var i = Count - 1; i >= 0; --i) { - if (this[i] is IResourceProvider p && p.TryGetResource(key, out value)) + if (this[i].TryGetResource(key, out value)) { return true; } diff --git a/src/Avalonia.Controls.ColorPicker/Avalonia.Controls.ColorPicker.csproj b/src/Avalonia.Controls.ColorPicker/Avalonia.Controls.ColorPicker.csproj new file mode 100644 index 0000000000..0952c899d4 --- /dev/null +++ b/src/Avalonia.Controls.ColorPicker/Avalonia.Controls.ColorPicker.csproj @@ -0,0 +1,25 @@ + + + net6.0;netstandard2.0 + Avalonia.Controls.ColorPicker + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.Controls.ColorPicker/ColorChangedEventArgs.cs b/src/Avalonia.Controls.ColorPicker/ColorChangedEventArgs.cs new file mode 100644 index 0000000000..b1d15d6b17 --- /dev/null +++ b/src/Avalonia.Controls.ColorPicker/ColorChangedEventArgs.cs @@ -0,0 +1,41 @@ +// Portions of this source file are adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under the MIT License. + +using System; +using Avalonia.Media; + +namespace Avalonia.Controls +{ + /// + /// Holds the details of a ColorChanged event. + /// + /// + /// HSV color information is intentionally not provided. + /// Use to obtain it. + /// + public class ColorChangedEventArgs : EventArgs + { + /// + /// Initializes a new instance of the class. + /// + /// The old/original color from before the change event. + /// The new/updated color that triggered the change event. + public ColorChangedEventArgs(Color oldColor, Color newColor) + { + OldColor = oldColor; + NewColor = newColor; + } + + /// + /// Gets the old/original color from before the change event. + /// + public Color OldColor { get; private set; } + + /// + /// Gets the new/updated color that triggered the change event. + /// + public Color NewColor { get; private set; } + } +} diff --git a/src/Avalonia.Controls.ColorPicker/ColorComponent.cs b/src/Avalonia.Controls.ColorPicker/ColorComponent.cs new file mode 100644 index 0000000000..71725056cf --- /dev/null +++ b/src/Avalonia.Controls.ColorPicker/ColorComponent.cs @@ -0,0 +1,28 @@ +namespace Avalonia.Controls +{ + /// + /// Defines a specific component within a color model. + /// + public enum ColorComponent + { + /// + /// Represents the alpha component. + /// + Alpha = 0, + + /// + /// Represents the first color component which is Red when RGB or Hue when HSV. + /// + Component1 = 1, + + /// + /// Represents the second color component which is Green when RGB or Saturation when HSV. + /// + Component2 = 2, + + /// + /// Represents the third color component which is Blue when RGB or Value when HSV. + /// + Component3 = 3 + } +} diff --git a/src/Avalonia.Controls.ColorPicker/ColorModel.cs b/src/Avalonia.Controls.ColorPicker/ColorModel.cs new file mode 100644 index 0000000000..f11b514706 --- /dev/null +++ b/src/Avalonia.Controls.ColorPicker/ColorModel.cs @@ -0,0 +1,18 @@ +namespace Avalonia.Controls +{ + /// + /// Defines the model used to represent colors. + /// + public enum ColorModel + { + /// + /// Color is represented by hue, saturation, value and alpha components. + /// + Hsva, + + /// + /// Color is represented by red, green, blue and alpha components. + /// + Rgba + } +} diff --git a/src/Avalonia.Controls.ColorPicker/ColorPreviewer/ColorPreviewer.Properties.cs b/src/Avalonia.Controls.ColorPicker/ColorPreviewer/ColorPreviewer.Properties.cs new file mode 100644 index 0000000000..0fa6ab8083 --- /dev/null +++ b/src/Avalonia.Controls.ColorPicker/ColorPreviewer/ColorPreviewer.Properties.cs @@ -0,0 +1,50 @@ +using Avalonia.Data; +using Avalonia.Media; + +namespace Avalonia.Controls.Primitives +{ + /// + public partial class ColorPreviewer + { + /// + /// Defines the property. + /// + public static readonly StyledProperty HsvColorProperty = + AvaloniaProperty.Register( + nameof(HsvColor), + Colors.Transparent.ToHsv(), + defaultBindingMode: BindingMode.TwoWay); + + /// + /// Defines the property. + /// + public static readonly StyledProperty ShowAccentColorsProperty = + AvaloniaProperty.Register( + nameof(ShowAccentColors), + true); + + /// + /// Gets or sets the currently previewed color in the HSV color model. + /// + /// + /// Only an HSV color is supported in this control to ensure there is never any + /// loss of precision or color information. Accent colors, like the color spectrum, + /// only operate with the HSV color model. + /// + public HsvColor HsvColor + { + get => GetValue(HsvColorProperty); + set => SetValue(HsvColorProperty, value); + } + + /// + /// Gets or sets a value indicating whether accent colors are shown along + /// with the preview color. + /// + public bool ShowAccentColors + { + get => GetValue(ShowAccentColorsProperty); + set => SetValue(ShowAccentColorsProperty, value); + } + } +} diff --git a/src/Avalonia.Controls.ColorPicker/ColorPreviewer/ColorPreviewer.cs b/src/Avalonia.Controls.ColorPicker/ColorPreviewer/ColorPreviewer.cs new file mode 100644 index 0000000000..d04ddf4bd6 --- /dev/null +++ b/src/Avalonia.Controls.ColorPicker/ColorPreviewer/ColorPreviewer.cs @@ -0,0 +1,130 @@ +using System; +using System.Globalization; +using Avalonia.Controls.Metadata; +using Avalonia.Controls.Primitives.Converters; +using Avalonia.Input; +using Avalonia.Media; + +namespace Avalonia.Controls.Primitives +{ + /// + /// Presents a preview color with optional accent colors. + /// + [TemplatePart(Name = nameof(AccentDec1Border), Type = typeof(Border))] + [TemplatePart(Name = nameof(AccentDec2Border), Type = typeof(Border))] + [TemplatePart(Name = nameof(AccentInc1Border), Type = typeof(Border))] + [TemplatePart(Name = nameof(AccentInc2Border), Type = typeof(Border))] + public partial class ColorPreviewer : TemplatedControl + { + /// + /// Event for when the selected color changes within the previewer. + /// This occurs when an accent color is pressed. + /// + public event EventHandler? ColorChanged; + + private bool eventsConnected = false; + + private Border? AccentDec1Border; + private Border? AccentDec2Border; + private Border? AccentInc1Border; + private Border? AccentInc2Border; + + /// + /// Initializes a new instance of the class. + /// + public ColorPreviewer() : base() + { + } + + /// + /// Connects or disconnects all control event handlers. + /// + /// True to connect event handlers, otherwise false. + private void ConnectEvents(bool connected) + { + if (connected == true && eventsConnected == false) + { + // Add all events + if (AccentDec1Border != null) { AccentDec1Border.PointerPressed += AccentBorder_PointerPressed; } + if (AccentDec2Border != null) { AccentDec2Border.PointerPressed += AccentBorder_PointerPressed; } + if (AccentInc1Border != null) { AccentInc1Border.PointerPressed += AccentBorder_PointerPressed; } + if (AccentInc2Border != null) { AccentInc2Border.PointerPressed += AccentBorder_PointerPressed; } + + eventsConnected = true; + } + else if (connected == false && eventsConnected == true) + { + // Remove all events + if (AccentDec1Border != null) { AccentDec1Border.PointerPressed -= AccentBorder_PointerPressed; } + if (AccentDec2Border != null) { AccentDec2Border.PointerPressed -= AccentBorder_PointerPressed; } + if (AccentInc1Border != null) { AccentInc1Border.PointerPressed -= AccentBorder_PointerPressed; } + if (AccentInc2Border != null) { AccentInc2Border.PointerPressed -= AccentBorder_PointerPressed; } + + eventsConnected = false; + } + } + + /// + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + // Remove any existing events present if the control was previously loaded then unloaded + ConnectEvents(false); + + AccentDec1Border = e.NameScope.Find(nameof(AccentDec1Border)); + AccentDec2Border = e.NameScope.Find(nameof(AccentDec2Border)); + AccentInc1Border = e.NameScope.Find(nameof(AccentInc1Border)); + AccentInc2Border = e.NameScope.Find(nameof(AccentInc2Border)); + + // Must connect after controls are found + ConnectEvents(true); + + base.OnApplyTemplate(e); + } + + /// + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + if (change.Property == HsvColorProperty) + { + OnColorChanged(new ColorChangedEventArgs( + change.GetOldValue().ToRgb(), + change.GetNewValue().ToRgb())); + } + + base.OnPropertyChanged(change); + } + + /// + /// Called before the event occurs. + /// + /// The defining old/new colors. + protected virtual void OnColorChanged(ColorChangedEventArgs e) + { + ColorChanged?.Invoke(this, e); + } + + /// + /// Event handler for when an accent color border is pressed. + /// This will update the color to the background of the pressed panel. + /// + private void AccentBorder_PointerPressed(object? sender, PointerPressedEventArgs e) + { + Border? border = sender as Border; + int accentStep = 0; + HsvColor hsvColor = HsvColor; + + // Get the value component delta + try + { + accentStep = int.Parse(border?.Tag?.ToString() ?? "", CultureInfo.InvariantCulture); + } + catch { } + + HsvColor newHsvColor = AccentColorConverter.GetAccent(hsvColor, accentStep); + HsvColor oldHsvColor = HsvColor; + + HsvColor = newHsvColor; + OnColorChanged(new ColorChangedEventArgs(oldHsvColor.ToRgb(), newHsvColor.ToRgb())); + } + } +} diff --git a/src/Avalonia.Controls.ColorPicker/ColorSlider/ColorSlider.Properties.cs b/src/Avalonia.Controls.ColorPicker/ColorSlider/ColorSlider.Properties.cs new file mode 100644 index 0000000000..31bd296288 --- /dev/null +++ b/src/Avalonia.Controls.ColorPicker/ColorSlider/ColorSlider.Properties.cs @@ -0,0 +1,146 @@ +using Avalonia.Data; +using Avalonia.Media; + +namespace Avalonia.Controls.Primitives +{ + /// + public partial class ColorSlider + { + /// + /// Defines the property. + /// + public static readonly StyledProperty ColorProperty = + AvaloniaProperty.Register( + nameof(Color), + Colors.White, + defaultBindingMode: BindingMode.TwoWay); + + /// + /// Defines the property. + /// + public static readonly StyledProperty ColorComponentProperty = + AvaloniaProperty.Register( + nameof(ColorComponent), + ColorComponent.Component1); + + /// + /// Defines the property. + /// + public static readonly StyledProperty ColorModelProperty = + AvaloniaProperty.Register( + nameof(ColorModel), + ColorModel.Rgba); + + /// + /// Defines the property. + /// + public static readonly StyledProperty HsvColorProperty = + AvaloniaProperty.Register( + nameof(HsvColor), + Colors.White.ToHsv(), + defaultBindingMode: BindingMode.TwoWay); + + /// + /// Defines the property. + /// + public static readonly StyledProperty IsAlphaMaxForcedProperty = + AvaloniaProperty.Register( + nameof(IsAlphaMaxForced), + true); + + /// + /// Defines the property. + /// + public static readonly StyledProperty IsAutoUpdatingEnabledProperty = + AvaloniaProperty.Register( + nameof(IsAutoUpdatingEnabled), + true); + + /// + /// Defines the property. + /// + public static readonly StyledProperty IsSaturationValueMaxForcedProperty = + AvaloniaProperty.Register( + nameof(IsSaturationValueMaxForced), + true); + + /// + /// Gets or sets the currently selected color in the RGB color model. + /// + /// + /// Use this property instead of when in + /// to avoid loss of precision and color drifting. + /// + public Color Color + { + get => GetValue(ColorProperty); + set => SetValue(ColorProperty, value); + } + + /// + /// Gets or sets the color component represented by the slider. + /// + public ColorComponent ColorComponent + { + get => GetValue(ColorComponentProperty); + set => SetValue(ColorComponentProperty, value); + } + + /// + /// Gets or sets the active color model used by the slider. + /// + public ColorModel ColorModel + { + get => GetValue(ColorModelProperty); + set => SetValue(ColorModelProperty, value); + } + + /// + /// Gets or sets the currently selected color in the HSV color model. + /// + /// + /// Use this property instead of when in + /// to avoid loss of precision and color drifting. + /// + public HsvColor HsvColor + { + get => GetValue(HsvColorProperty); + set => SetValue(HsvColorProperty, value); + } + + /// + /// Gets or sets a value indicating whether the alpha component is always forced to maximum for components + /// other than . + /// This ensures that the background is always visible and never transparent regardless of the actual color. + /// + public bool IsAlphaMaxForced + { + get => GetValue(IsAlphaMaxForcedProperty); + set => SetValue(IsAlphaMaxForcedProperty, value); + } + + /// + /// Gets or sets a value indicating whether automatic background and foreground updates will be + /// calculated when the set color changes. + /// + /// + /// This can be disabled for performance reasons when working with multiple sliders. + /// + public bool IsAutoUpdatingEnabled + { + get => GetValue(IsAutoUpdatingEnabledProperty); + set => SetValue(IsAutoUpdatingEnabledProperty, value); + } + + /// + /// Gets or sets a value indicating whether the saturation and value components are always forced to maximum values + /// when using the HSVA color model. Only component values other than will be changed. + /// This ensures, for example, that the Hue background is always visible and never washed out regardless of the actual color. + /// + public bool IsSaturationValueMaxForced + { + get => GetValue(IsSaturationValueMaxForcedProperty); + set => SetValue(IsSaturationValueMaxForcedProperty, value); + } + } +} diff --git a/src/Avalonia.Controls.ColorPicker/ColorSlider/ColorSlider.cs b/src/Avalonia.Controls.ColorPicker/ColorSlider/ColorSlider.cs new file mode 100644 index 0000000000..3c38c6ed1b --- /dev/null +++ b/src/Avalonia.Controls.ColorPicker/ColorSlider/ColorSlider.cs @@ -0,0 +1,399 @@ +using System; +using Avalonia.Controls.Metadata; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Utilities; + +namespace Avalonia.Controls.Primitives +{ + /// + /// A slider with a background that represents a single color component. + /// + [PseudoClasses(pcDarkSelector, pcLightSelector)] + public partial class ColorSlider : Slider + { + protected const string pcDarkSelector = ":dark-selector"; + protected const string pcLightSelector = ":light-selector"; + + /// + /// Event for when the selected color changes within the slider. + /// + public event EventHandler? ColorChanged; + + private const double MaxHue = 359.99999999999999999; // 17 decimal places + private bool disableUpdates = false; + + /// + /// Initializes a new instance of the class. + /// + public ColorSlider() : base() + { + } + + /// + /// Updates the visual state of the control by applying latest PseudoClasses. + /// + private void UpdatePseudoClasses() + { + // The slider itself can be transparent for certain color values. + // This causes an issue where a white selector thumb over a light window background or + // a black selector thumb over a dark window background is not visible. + // This means under a certain alpha threshold, neither a white or black selector thumb + // should be shown and instead the default slider thumb color should be used instead. + if (Color.A < 128 && + (IsAlphaMaxForced == false || + ColorComponent == ColorComponent.Alpha)) + { + PseudoClasses.Set(pcDarkSelector, false); + PseudoClasses.Set(pcLightSelector, false); + } + else + { + Color perceivedColor; + + if (ColorModel == ColorModel.Hsva) + { + perceivedColor = GetEquivalentBackgroundColor(HsvColor).ToRgb(); + } + else + { + perceivedColor = GetEquivalentBackgroundColor(Color); + } + + if (ColorHelper.GetRelativeLuminance(perceivedColor) <= 0.5) + { + PseudoClasses.Set(pcDarkSelector, false); + PseudoClasses.Set(pcLightSelector, true); + } + else + { + PseudoClasses.Set(pcDarkSelector, true); + PseudoClasses.Set(pcLightSelector, false); + } + } + } + + /// + /// Generates a new background image for the color slider and applies it. + /// + private async void UpdateBackground() + { + // In Avalonia, Bounds returns the actual device-independent pixel size of a control. + // However, this is not necessarily the size of the control rendered on a display. + // A desktop or application scaling factor may be applied which must be accounted for here. + // Remember bitmaps in Avalonia are rendered mapping to actual device pixels, not the device- + // independent pixels of controls. + + var scale = LayoutHelper.GetLayoutScale(this); + var pixelWidth = Convert.ToInt32(Bounds.Width * scale); + var pixelHeight = Convert.ToInt32(Bounds.Height * scale); + + if (pixelWidth != 0 && pixelHeight != 0) + { + var bitmap = await ColorPickerHelpers.CreateComponentBitmapAsync( + pixelWidth, + pixelHeight, + Orientation, + ColorModel, + ColorComponent, + HsvColor, + IsAlphaMaxForced, + IsSaturationValueMaxForced); + + if (bitmap != null) + { + Background = new ImageBrush(ColorPickerHelpers.CreateBitmapFromPixelData(bitmap, pixelWidth, pixelHeight)); + } + } + } + + /// + /// Updates the slider property values by applying the current color. + /// + /// + /// Warning: This will trigger property changed updates. + /// Consider using externally. + /// + private void SetColorToSliderValues() + { + var hsvColor = HsvColor; + var rgbColor = Color; + var component = ColorComponent; + + if (ColorModel == ColorModel.Hsva) + { + // Note: Components converted into a usable range for the user + switch (component) + { + case ColorComponent.Alpha: + Minimum = 0; + Maximum = 100; + Value = hsvColor.A * 100; + break; + case ColorComponent.Component1: // Hue + Minimum = 0; + Maximum = MaxHue; + Value = hsvColor.H; + break; + case ColorComponent.Component2: // Saturation + Minimum = 0; + Maximum = 100; + Value = hsvColor.S * 100; + break; + case ColorComponent.Component3: // Value + Minimum = 0; + Maximum = 100; + Value = hsvColor.V * 100; + break; + } + } + else + { + switch (component) + { + case ColorComponent.Alpha: + Minimum = 0; + Maximum = 255; + Value = Convert.ToDouble(rgbColor.A); + break; + case ColorComponent.Component1: // Red + Minimum = 0; + Maximum = 255; + Value = Convert.ToDouble(rgbColor.R); + break; + case ColorComponent.Component2: // Green + Minimum = 0; + Maximum = 255; + Value = Convert.ToDouble(rgbColor.G); + break; + case ColorComponent.Component3: // Blue + Minimum = 0; + Maximum = 255; + Value = Convert.ToDouble(rgbColor.B); + break; + } + } + } + + /// + /// Gets the current color determined by the slider values. + /// + private (Color, HsvColor) GetColorFromSliderValues() + { + HsvColor hsvColor = new HsvColor(); + Color rgbColor = new Color(); + double sliderPercent = Value / (Maximum - Minimum); + + var baseHsvColor = HsvColor; + var baseRgbColor = Color; + var component = ColorComponent; + + if (ColorModel == ColorModel.Hsva) + { + switch (component) + { + case ColorComponent.Alpha: + { + hsvColor = new HsvColor(sliderPercent, baseHsvColor.H, baseHsvColor.S, baseHsvColor.V); + break; + } + case ColorComponent.Component1: + { + hsvColor = new HsvColor(baseHsvColor.A, sliderPercent * MaxHue, baseHsvColor.S, baseHsvColor.V); + break; + } + case ColorComponent.Component2: + { + hsvColor = new HsvColor(baseHsvColor.A, baseHsvColor.H, sliderPercent, baseHsvColor.V); + break; + } + case ColorComponent.Component3: + { + hsvColor = new HsvColor(baseHsvColor.A, baseHsvColor.H, baseHsvColor.S, sliderPercent); + break; + } + } + + return (hsvColor.ToRgb(), hsvColor); + } + else + { + byte componentValue = Convert.ToByte(MathUtilities.Clamp(sliderPercent * 255, 0, 255)); + + switch (component) + { + case ColorComponent.Alpha: + rgbColor = new Color(componentValue, baseRgbColor.R, baseRgbColor.G, baseRgbColor.B); + break; + case ColorComponent.Component1: + rgbColor = new Color(baseRgbColor.A, componentValue, baseRgbColor.G, baseRgbColor.B); + break; + case ColorComponent.Component2: + rgbColor = new Color(baseRgbColor.A, baseRgbColor.R, componentValue, baseRgbColor.B); + break; + case ColorComponent.Component3: + rgbColor = new Color(baseRgbColor.A, baseRgbColor.R, baseRgbColor.G, componentValue); + break; + } + + return (rgbColor, rgbColor.ToHsv()); + } + } + + /// + /// Gets the actual background color displayed for the given HSV color. + /// This can differ due to the effects of certain properties intended to improve perception. + /// + /// The actual color to get the equivalent background color for. + /// The equivalent, perceived background color. + private HsvColor GetEquivalentBackgroundColor(HsvColor hsvColor) + { + var component = ColorComponent; + var isAlphaMaxForced = IsAlphaMaxForced; + var isSaturationValueMaxForced = IsSaturationValueMaxForced; + + if (isAlphaMaxForced && + component != ColorComponent.Alpha) + { + hsvColor = new HsvColor(1.0, hsvColor.H, hsvColor.S, hsvColor.V); + } + + switch (component) + { + case ColorComponent.Component1: + return new HsvColor( + hsvColor.A, + hsvColor.H, + isSaturationValueMaxForced ? 1.0 : hsvColor.S, + isSaturationValueMaxForced ? 1.0 : hsvColor.V); + case ColorComponent.Component2: + return new HsvColor( + hsvColor.A, + hsvColor.H, + hsvColor.S, + isSaturationValueMaxForced ? 1.0 : hsvColor.V); + case ColorComponent.Component3: + return new HsvColor( + hsvColor.A, + hsvColor.H, + isSaturationValueMaxForced ? 1.0 : hsvColor.S, + hsvColor.V); + default: + return hsvColor; + } + } + + /// + /// Gets the actual background color displayed for the given RGB color. + /// This can differ due to the effects of certain properties intended to improve perception. + /// + /// The actual color to get the equivalent background color for. + /// The equivalent, perceived background color. + private Color GetEquivalentBackgroundColor(Color rgbColor) + { + var component = ColorComponent; + var isAlphaMaxForced = IsAlphaMaxForced; + + if (isAlphaMaxForced && + component != ColorComponent.Alpha) + { + rgbColor = new Color(255, rgbColor.R, rgbColor.G, rgbColor.B); + } + + return rgbColor; + } + + /// + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + if (disableUpdates) + { + base.OnPropertyChanged(change); + return; + } + + // Always keep the two color properties in sync + if (change.Property == ColorProperty) + { + disableUpdates = true; + + HsvColor = Color.ToHsv(); + + if (IsAutoUpdatingEnabled) + { + SetColorToSliderValues(); + UpdateBackground(); + } + + UpdatePseudoClasses(); + OnColorChanged(new ColorChangedEventArgs( + change.GetOldValue(), + change.GetNewValue())); + + disableUpdates = false; + } + else if (change.Property == HsvColorProperty) + { + disableUpdates = true; + + Color = HsvColor.ToRgb(); + + if (IsAutoUpdatingEnabled) + { + SetColorToSliderValues(); + UpdateBackground(); + } + + UpdatePseudoClasses(); + OnColorChanged(new ColorChangedEventArgs( + change.GetOldValue().ToRgb(), + change.GetNewValue().ToRgb())); + + disableUpdates = false; + } + else if (change.Property == BoundsProperty) + { + if (IsAutoUpdatingEnabled) + { + UpdateBackground(); + } + } + else if (change.Property == ValueProperty || + change.Property == MinimumProperty || + change.Property == MaximumProperty) + { + disableUpdates = true; + + Color oldColor = Color; + (var color, var hsvColor) = GetColorFromSliderValues(); + + if (ColorModel == ColorModel.Hsva) + { + HsvColor = hsvColor; + Color = hsvColor.ToRgb(); + } + else + { + Color = color; + HsvColor = color.ToHsv(); + } + + UpdatePseudoClasses(); + OnColorChanged(new ColorChangedEventArgs(oldColor, Color)); + + disableUpdates = false; + } + + base.OnPropertyChanged(change); + } + + /// + /// Called before the event occurs. + /// + /// The defining old/new colors. + protected virtual void OnColorChanged(ColorChangedEventArgs e) + { + ColorChanged?.Invoke(this, e); + } + } +} diff --git a/src/Avalonia.Controls.ColorPicker/ColorSpectrum/ColorSpectrum.Properties.cs b/src/Avalonia.Controls.ColorPicker/ColorSpectrum/ColorSpectrum.Properties.cs new file mode 100644 index 0000000000..587a89ee38 --- /dev/null +++ b/src/Avalonia.Controls.ColorPicker/ColorSpectrum/ColorSpectrum.Properties.cs @@ -0,0 +1,222 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under the MIT License. + +using Avalonia.Data; +using Avalonia.Media; + +namespace Avalonia.Controls.Primitives +{ + /// + public partial class ColorSpectrum + { + /// + /// Defines the property. + /// + public static readonly StyledProperty ColorProperty = + AvaloniaProperty.Register( + nameof(Color), + Colors.White, + defaultBindingMode: BindingMode.TwoWay); + + /// + /// Defines the property. + /// + public static readonly StyledProperty ComponentsProperty = + AvaloniaProperty.Register( + nameof(Components), + ColorSpectrumComponents.HueSaturation); + + /// + /// Defines the property. + /// + public static readonly StyledProperty HsvColorProperty = + AvaloniaProperty.Register( + nameof(HsvColor), + Colors.White.ToHsv(), + defaultBindingMode: BindingMode.TwoWay); + + /// + /// Defines the property. + /// + public static readonly StyledProperty MaxHueProperty = + AvaloniaProperty.Register( + nameof(MaxHue), + 359); + + /// + /// Defines the property. + /// + public static readonly StyledProperty MaxSaturationProperty = + AvaloniaProperty.Register( + nameof(MaxSaturation), + 100); + + /// + /// Defines the property. + /// + public static readonly StyledProperty MaxValueProperty = + AvaloniaProperty.Register( + nameof(MaxValue), + 100); + + /// + /// Defines the property. + /// + public static readonly StyledProperty MinHueProperty = + AvaloniaProperty.Register( + nameof(MinHue), + 0); + + /// + /// Defines the property. + /// + public static readonly StyledProperty MinSaturationProperty = + AvaloniaProperty.Register( + nameof(MinSaturation), + 0); + + /// + /// Defines the property. + /// + public static readonly StyledProperty MinValueProperty = + AvaloniaProperty.Register( + nameof(MinValue), + 0); + + /// + /// Defines the property. + /// + public static readonly StyledProperty ShapeProperty = + AvaloniaProperty.Register( + nameof(Shape), + ColorSpectrumShape.Box); + + /// + /// Gets or sets the currently selected color in the RGB color model. + /// + /// + /// For control authors use instead to avoid loss + /// of precision and color drifting. + /// + public Color Color + { + get => GetValue(ColorProperty); + set => SetValue(ColorProperty, value); + } + + /// + /// Gets or sets the two HSV color components displayed by the spectrum. + /// + /// + /// Internally, the uses the HSV color model. + /// + public ColorSpectrumComponents Components + { + get => GetValue(ComponentsProperty); + set => SetValue(ComponentsProperty, value); + } + + /// + /// Gets or sets the currently selected color in the HSV color model. + /// + /// + /// This should be used in all cases instead of the property. + /// Internally, the uses the HSV color model and using + /// this property will avoid loss of precision and color drifting. + /// + public HsvColor HsvColor + { + get => GetValue(HsvColorProperty); + set => SetValue(HsvColorProperty, value); + } + + /// + /// Gets or sets the maximum value of the Hue component in the range from 0..359. + /// This property must be greater than . + /// + /// + /// Internally, the uses the HSV color model. + /// + public int MaxHue + { + get => GetValue(MaxHueProperty); + set => SetValue(MaxHueProperty, value); + } + + /// + /// Gets or sets the maximum value of the Saturation component in the range from 0..100. + /// This property must be greater than . + /// + /// + /// Internally, the uses the HSV color model. + /// + public int MaxSaturation + { + get => GetValue(MaxSaturationProperty); + set => SetValue(MaxSaturationProperty, value); + } + + /// + /// Gets or sets the maximum value of the Value component in the range from 0..100. + /// This property must be greater than . + /// + /// + /// Internally, the uses the HSV color model. + /// + public int MaxValue + { + get => GetValue(MaxValueProperty); + set => SetValue(MaxValueProperty, value); + } + + /// + /// Gets or sets the minimum value of the Hue component in the range from 0..359. + /// This property must be less than . + /// + /// + /// Internally, the uses the HSV color model. + /// + public int MinHue + { + get => GetValue(MinHueProperty); + set => SetValue(MinHueProperty, value); + } + + /// + /// Gets or sets the minimum value of the Saturation component in the range from 0..100. + /// This property must be less than . + /// + /// + /// Internally, the uses the HSV color model. + /// + public int MinSaturation + { + get => GetValue(MinSaturationProperty); + set => SetValue(MinSaturationProperty, value); + } + + /// + /// Gets or sets the minimum value of the Value component in the range from 0..100. + /// This property must be less than . + /// + /// + /// Internally, the uses the HSV color model. + /// + public int MinValue + { + get => GetValue(MinValueProperty); + set => SetValue(MinValueProperty, value); + } + + /// + /// Gets or sets the displayed shape of the spectrum. + /// + public ColorSpectrumShape Shape + { + get => GetValue(ShapeProperty); + set => SetValue(ShapeProperty, value); + } + } +} diff --git a/src/Avalonia.Controls.ColorPicker/ColorSpectrum/ColorSpectrum.cs b/src/Avalonia.Controls.ColorPicker/ColorSpectrum/ColorSpectrum.cs new file mode 100644 index 0000000000..245592207e --- /dev/null +++ b/src/Avalonia.Controls.ColorPicker/ColorSpectrum/ColorSpectrum.cs @@ -0,0 +1,1606 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under the MIT License. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Avalonia.Controls.Metadata; +using Avalonia.Controls.Shapes; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Threading; +using Avalonia.Utilities; + +namespace Avalonia.Controls.Primitives +{ + /// + /// A two dimensional spectrum for color selection. + /// + [TemplatePart("PART_InputTarget", typeof(Canvas))] + [TemplatePart("PART_LayoutRoot", typeof(Panel))] + [TemplatePart("PART_SelectionEllipsePanel", typeof(Panel))] + [TemplatePart("PART_SizingPanel", typeof(Panel))] + [TemplatePart("PART_SpectrumEllipse", typeof(Ellipse))] + [TemplatePart("PART_SpectrumRectangle", typeof(Rectangle))] + [TemplatePart("PART_SpectrumOverlayEllipse", typeof(Ellipse))] + [TemplatePart("PART_SpectrumOverlayRectangle", typeof(Rectangle))] + [PseudoClasses(pcPressed, pcLargeSelector, pcDarkSelector, pcLightSelector)] + public partial class ColorSpectrum : TemplatedControl + { + protected const string pcPressed = ":pressed"; + protected const string pcDarkSelector = ":dark-selector"; + protected const string pcLargeSelector = ":large-selector"; + protected const string pcLightSelector = ":light-selector"; + + /// + /// Event for when the selected color changes within the spectrum. + /// + public event EventHandler? ColorChanged; + + private bool _updatingColor = false; + private bool _updatingHsvColor = false; + private bool _isPointerOver = false; + private bool _isPointerPressed = false; + private bool _shouldShowLargeSelection = false; + private List _hsvValues = new List(); + + private IDisposable? _layoutRootDisposable; + private IDisposable? _selectionEllipsePanelDisposable; + + // XAML template parts + private Panel? _layoutRoot; + private Panel? _sizingPanel; + private Rectangle? _spectrumRectangle; + private Ellipse? _spectrumEllipse; + private Rectangle? _spectrumOverlayRectangle; + private Ellipse? _spectrumOverlayEllipse; + private Canvas? _inputTarget; + private Panel? _selectionEllipsePanel; + + // Put the spectrum images in a bitmap, which is then given to an ImageBrush. + private WriteableBitmap? _hueRedBitmap; + private WriteableBitmap? _hueYellowBitmap; + private WriteableBitmap? _hueGreenBitmap; + private WriteableBitmap? _hueCyanBitmap; + private WriteableBitmap? _hueBlueBitmap; + private WriteableBitmap? _huePurpleBitmap; + + private WriteableBitmap? _saturationMinimumBitmap; + private WriteableBitmap? _saturationMaximumBitmap; + + private WriteableBitmap? _valueBitmap; + + // Fields used by UpdateEllipse() to ensure that it's using the data + // associated with the last call to CreateBitmapsAndColorMap(), + // in order to function properly while the asynchronous bitmap creation + // is in progress. + private ColorSpectrumShape _shapeFromLastBitmapCreation = ColorSpectrumShape.Box; + private ColorSpectrumComponents _componentsFromLastBitmapCreation = ColorSpectrumComponents.HueSaturation; + private double _imageWidthFromLastBitmapCreation = 0.0; + private double _imageHeightFromLastBitmapCreation = 0.0; + private int _minHueFromLastBitmapCreation = 0; + private int _maxHueFromLastBitmapCreation = 0; + private int _minSaturationFromLastBitmapCreation = 0; + private int _maxSaturationFromLastBitmapCreation = 0; + private int _minValueFromLastBitmapCreation = 0; + private int _maxValueFromLastBitmapCreation = 0; + + private Color _oldColor = Color.FromArgb(255, 255, 255, 255); + private HsvColor _oldHsvColor = HsvColor.FromAhsv(0.0f, 0.0f, 1.0f, 1.0f); + + /// + /// Initializes a new instance of the class. + /// + public ColorSpectrum() + { + _shapeFromLastBitmapCreation = Shape; + _componentsFromLastBitmapCreation = Components; + _imageWidthFromLastBitmapCreation = 0; + _imageHeightFromLastBitmapCreation = 0; + _minHueFromLastBitmapCreation = MinHue; + _maxHueFromLastBitmapCreation = MaxHue; + _minSaturationFromLastBitmapCreation = MinSaturation; + _maxSaturationFromLastBitmapCreation = MaxSaturation; + _minValueFromLastBitmapCreation = MinValue; + _maxValueFromLastBitmapCreation = MaxValue; + } + + /// + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + + UnregisterEvents(); // Failsafe + + _inputTarget = e.NameScope.Find("PART_InputTarget"); + _layoutRoot = e.NameScope.Find("PART_LayoutRoot"); + _selectionEllipsePanel = e.NameScope.Find("PART_SelectionEllipsePanel"); + _sizingPanel = e.NameScope.Find("PART_SizingPanel"); + _spectrumEllipse = e.NameScope.Find("PART_SpectrumEllipse"); + _spectrumRectangle = e.NameScope.Find("PART_SpectrumRectangle"); + _spectrumOverlayEllipse = e.NameScope.Find("PART_SpectrumOverlayEllipse"); + _spectrumOverlayRectangle = e.NameScope.Find("PART_SpectrumOverlayRectangle"); + + if (_inputTarget != null) + { + _inputTarget.PointerEnter += InputTarget_PointerEnter; + _inputTarget.PointerLeave += InputTarget_PointerLeave; + _inputTarget.PointerPressed += InputTarget_PointerPressed; + _inputTarget.PointerMoved += InputTarget_PointerMoved; + _inputTarget.PointerReleased += InputTarget_PointerReleased; + } + + if (_layoutRoot != null) + { + _layoutRootDisposable = _layoutRoot.GetObservable(BoundsProperty).Subscribe(_ => + { + CreateBitmapsAndColorMap(); + }); + } + + if (_selectionEllipsePanel != null) + { + _selectionEllipsePanelDisposable = _selectionEllipsePanel.GetObservable(FlowDirectionProperty).Subscribe(_ => + { + UpdateEllipse(); + }); + } + + if (_selectionEllipsePanel != null && + ColorHelper.ToDisplayNameExists) + { + ToolTip.SetTip(_selectionEllipsePanel, ColorHelper.ToDisplayName(Color)); + } + + // If we haven't yet created our bitmaps, do so now. + if (_hsvValues.Count == 0) + { + CreateBitmapsAndColorMap(); + } + + UpdateEllipse(); + UpdatePseudoClasses(); + } + + /// + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + + // OnAttachedToVisualTree is called after OnApplyTemplate so events cannot be connected here + } + + /// + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + } + + /// + /// Explicitly unregisters all events connected in OnApplyTemplate(). + /// + private void UnregisterEvents() + { + _layoutRootDisposable?.Dispose(); + _layoutRootDisposable = null; + + _selectionEllipsePanelDisposable?.Dispose(); + _selectionEllipsePanelDisposable = null; + + if (_inputTarget != null) + { + _inputTarget.PointerEnter -= InputTarget_PointerEnter; + _inputTarget.PointerLeave -= InputTarget_PointerLeave; + _inputTarget.PointerPressed -= InputTarget_PointerPressed; + _inputTarget.PointerMoved -= InputTarget_PointerMoved; + _inputTarget.PointerReleased -= InputTarget_PointerReleased; + } + } + + /// + protected override void OnKeyDown(KeyEventArgs e) + { + var key = e.Key; + + if (key != Key.Left && + key != Key.Right && + key != Key.Up && + key != Key.Down) + { + base.OnKeyDown(e); + return; + } + + bool isControlDown = e.KeyModifiers.HasFlag(KeyModifiers.Control); + + HsvComponent incrementComponent = HsvComponent.Hue; + + bool isSaturationValue = false; + + if (key == Key.Left || + key == Key.Right) + { + switch (Components) + { + case ColorSpectrumComponents.HueSaturation: + case ColorSpectrumComponents.HueValue: + incrementComponent = HsvComponent.Hue; + break; + + case ColorSpectrumComponents.SaturationValue: + isSaturationValue = true; + goto case ColorSpectrumComponents.SaturationHue; + case ColorSpectrumComponents.SaturationHue: + incrementComponent = HsvComponent.Saturation; + break; + + case ColorSpectrumComponents.ValueHue: + case ColorSpectrumComponents.ValueSaturation: + incrementComponent = HsvComponent.Value; + break; + } + } + else if (key == Key.Up || + key == Key.Down) + { + switch (Components) + { + case ColorSpectrumComponents.SaturationHue: + case ColorSpectrumComponents.ValueHue: + incrementComponent = HsvComponent.Hue; + break; + + case ColorSpectrumComponents.HueSaturation: + case ColorSpectrumComponents.ValueSaturation: + incrementComponent = HsvComponent.Saturation; + break; + + case ColorSpectrumComponents.SaturationValue: + isSaturationValue = true; + goto case ColorSpectrumComponents.HueValue; + case ColorSpectrumComponents.HueValue: + incrementComponent = HsvComponent.Value; + break; + } + } + + double minBound = 0.0; + double maxBound = 0.0; + + switch (incrementComponent) + { + case HsvComponent.Hue: + minBound = MinHue; + maxBound = MaxHue; + break; + + case HsvComponent.Saturation: + minBound = MinSaturation; + maxBound = MaxSaturation; + break; + + case HsvComponent.Value: + minBound = MinValue; + maxBound = MaxValue; + break; + } + + // The order of saturation and value in the spectrum is reversed - the max value is at the bottom while the min value is at the top - + // so we want left and up to be lower for hue, but higher for saturation and value. + // This will ensure that the icon always moves in the direction of the key press. + IncrementDirection direction = + (incrementComponent == HsvComponent.Hue && (key == Key.Left || key == Key.Up)) || + (incrementComponent != HsvComponent.Hue && (key == Key.Right || key == Key.Down)) ? + IncrementDirection.Lower : + IncrementDirection.Higher; + + // Image is flipped in RightToLeft, so we need to invert direction in that case. + // The combination saturation and value is also flipped, so we need to invert in that case too. + // If both are false, we don't need to invert. + // If both are true, we would invert twice, so not invert at all. + if ((FlowDirection == FlowDirection.RightToLeft) != isSaturationValue && + (key == Key.Left || key == Key.Right)) + { + if (direction == IncrementDirection.Higher) + { + direction = IncrementDirection.Lower; + } + else + { + direction = IncrementDirection.Higher; + } + } + + IncrementAmount amount = isControlDown ? IncrementAmount.Large : IncrementAmount.Small; + + HsvColor hsvColor = HsvColor; + UpdateColor(ColorPickerHelpers.IncrementColorComponent( + new Hsv(hsvColor), + incrementComponent, + direction, + amount, + shouldWrap: true, + minBound, + maxBound)); + + e.Handled = true; + } + + /// + protected override void OnGotFocus(GotFocusEventArgs e) + { + // We only want to bother with the color name tool tip if we can provide color names. + if (_selectionEllipsePanel != null && + ColorHelper.ToDisplayNameExists) + { + ToolTip.SetIsOpen(_selectionEllipsePanel, true); + } + + UpdatePseudoClasses(); + + base.OnGotFocus(e); + } + + /// + protected override void OnLostFocus(RoutedEventArgs e) + { + // We only want to bother with the color name tool tip if we can provide color names. + if (_selectionEllipsePanel != null && + ColorHelper.ToDisplayNameExists) + { + ToolTip.SetIsOpen(_selectionEllipsePanel, false); + } + + UpdatePseudoClasses(); + + base.OnLostFocus(e); + } + + /// + protected override void OnPointerLeave(PointerEventArgs e) + { + // We only want to bother with the color name tool tip if we can provide color names. + if (_selectionEllipsePanel != null && + ColorHelper.ToDisplayNameExists) + { + ToolTip.SetIsOpen(_selectionEllipsePanel, false); + } + + UpdatePseudoClasses(); + + base.OnPointerLeave(e); + } + + /// + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + if (change.Property == ColorProperty) + { + // If we're in the process of internally updating the color, + // then we don't want to respond to the Color property changing. + if (!_updatingColor) + { + Color color = Color; + + _updatingHsvColor = true; + Hsv newHsv = (new Rgb(color)).ToHsv(); + HsvColor = newHsv.ToHsvColor(color.A / 255.0); + _updatingHsvColor = false; + + UpdateEllipse(); + UpdateBitmapSources(); + } + + _oldColor = change.GetOldValue(); + } + else if (change.Property == HsvColorProperty) + { + // If we're in the process of internally updating the HSV color, + // then we don't want to respond to the HsvColor property changing. + if (!_updatingHsvColor) + { + SetColor(); + } + + _oldHsvColor = change.GetOldValue(); + } + else if (change.Property == MinHueProperty || + change.Property == MaxHueProperty) + { + int minHue = MinHue; + int maxHue = MaxHue; + + if (minHue < 0 || minHue > 359) + { + throw new ArgumentException("MinHue must be between 0 and 359."); + } + else if (maxHue < 0 || maxHue > 359) + { + throw new ArgumentException("MaxHue must be between 0 and 359."); + } + + ColorSpectrumComponents components = Components; + + // If hue is one of the axes in the spectrum bitmap, then we'll need to regenerate it + // if the maximum or minimum value has changed. + if (components != ColorSpectrumComponents.SaturationValue && + components != ColorSpectrumComponents.ValueSaturation) + { + CreateBitmapsAndColorMap(); + } + } + else if (change.Property == MinSaturationProperty || + change.Property == MaxSaturationProperty) + { + int minSaturation = MinSaturation; + int maxSaturation = MaxSaturation; + + if (minSaturation < 0 || minSaturation > 100) + { + throw new ArgumentException("MinSaturation must be between 0 and 100."); + } + else if (maxSaturation < 0 || maxSaturation > 100) + { + throw new ArgumentException("MaxSaturation must be between 0 and 100."); + } + + ColorSpectrumComponents components = Components; + + // If value is one of the axes in the spectrum bitmap, then we'll need to regenerate it + // if the maximum or minimum value has changed. + if (components != ColorSpectrumComponents.HueValue && + components != ColorSpectrumComponents.ValueHue) + { + CreateBitmapsAndColorMap(); + } + } + else if (change.Property == MinValueProperty || + change.Property == MaxValueProperty) + { + int minValue = MinValue; + int maxValue = MaxValue; + + if (minValue < 0 || minValue > 100) + { + throw new ArgumentException("MinValue must be between 0 and 100."); + } + else if (maxValue < 0 || maxValue > 100) + { + throw new ArgumentException("MaxValue must be between 0 and 100."); + } + + ColorSpectrumComponents components = Components; + + // If value is one of the axes in the spectrum bitmap, then we'll need to regenerate it + // if the maximum or minimum value has changed. + if (components != ColorSpectrumComponents.HueSaturation && + components != ColorSpectrumComponents.SaturationHue) + { + CreateBitmapsAndColorMap(); + } + } + else if (change.Property == ShapeProperty) + { + CreateBitmapsAndColorMap(); + } + else if (change.Property == ComponentsProperty) + { + CreateBitmapsAndColorMap(); + } + + base.OnPropertyChanged(change); + } + + private void SetColor() + { + HsvColor hsvColor = HsvColor; + + _updatingColor = true; + Rgb newRgb = (new Hsv(hsvColor)).ToRgb(); + + Color = newRgb.ToColor(hsvColor.A); + + _updatingColor = false; + + UpdateEllipse(); + UpdateBitmapSources(); + RaiseColorChanged(); + } + + public void RaiseColorChanged() + { + Color newColor = Color; + + bool colorChanged = + _oldColor.A != newColor.A || + _oldColor.R != newColor.R || + _oldColor.G != newColor.G || + _oldColor.B != newColor.B; + + bool areBothColorsBlack = + (_oldColor.R == newColor.R && newColor.R == 0) || + (_oldColor.G == newColor.G && newColor.G == 0) || + (_oldColor.B == newColor.B && newColor.B == 0); + + if (colorChanged || areBothColorsBlack) + { + var colorChangedEventArgs = new ColorChangedEventArgs(_oldColor, newColor); + ColorChanged?.Invoke(this, colorChangedEventArgs); + + if (_selectionEllipsePanel != null && + ColorHelper.ToDisplayNameExists) + { + ToolTip.SetTip(_selectionEllipsePanel, ColorHelper.ToDisplayName(Color)); + } + } + } + + /// + /// Updates the visual state of the control by applying latest PseudoClasses. + /// + private void UpdatePseudoClasses() + { + PseudoClasses.Set(pcPressed, _isPointerPressed); + // Note: The ":pointerover" pseudo class is set in the base Control + + if (_isPointerPressed) + { + PseudoClasses.Set(pcLargeSelector, _shouldShowLargeSelection); + } + else + { + PseudoClasses.Set(pcLargeSelector, false); + } + + if (SelectionEllipseShouldBeLight()) + { + PseudoClasses.Set(pcDarkSelector, false); + PseudoClasses.Set(pcLightSelector, true); + } + else + { + PseudoClasses.Set(pcDarkSelector, true); + PseudoClasses.Set(pcLightSelector, false); + } + } + + private void UpdateColor(Hsv newHsv) + { + _updatingColor = true; + _updatingHsvColor = true; + + Rgb newRgb = newHsv.ToRgb(); + double alpha = HsvColor.A; + + Color = newRgb.ToColor(alpha); + HsvColor = newHsv.ToHsvColor(alpha); + + UpdateEllipse(); + UpdatePseudoClasses(); + + _updatingHsvColor = false; + _updatingColor = false; + + RaiseColorChanged(); + } + + private void UpdateColorFromPoint(PointerPoint point) + { + // If we haven't initialized our HSV value array yet, then we should just ignore any user input - + // we don't yet know what to do with it. + if (_hsvValues.Count == 0) + { + return; + } + + // Remember the bitmap size follows physical device pixels + var scale = LayoutHelper.GetLayoutScale(this); + double xPosition = point.Position.X * scale; + double yPosition = point.Position.Y * scale; + double radius = Math.Min(_imageWidthFromLastBitmapCreation, _imageHeightFromLastBitmapCreation) / 2; + double distanceFromRadius = Math.Sqrt(Math.Pow(xPosition - radius, 2) + Math.Pow(yPosition - radius, 2)); + + var shape = Shape; + + // If the point is outside the circle, we should bring it back into the circle. + if (distanceFromRadius > radius && shape == ColorSpectrumShape.Ring) + { + xPosition = (radius / distanceFromRadius) * (xPosition - radius) + radius; + yPosition = (radius / distanceFromRadius) * (yPosition - radius) + radius; + } + + // Now we need to find the index into the array of HSL values at each point in the spectrum m_image. + int x = (int)Math.Round(xPosition); + int y = (int)Math.Round(yPosition); + int width = (int)Math.Round(_imageWidthFromLastBitmapCreation); + + if (x < 0) + { + x = 0; + } + else if (x >= _imageWidthFromLastBitmapCreation) + { + x = (int)Math.Round(_imageWidthFromLastBitmapCreation) - 1; + } + + if (y < 0) + { + y = 0; + } + else if (y >= _imageHeightFromLastBitmapCreation) + { + y = (int)Math.Round(_imageHeightFromLastBitmapCreation) - 1; + } + + // The gradient image contains two dimensions of HSL information, but not the third. + // We should keep the third where it already was. + // Note: This can sometimes cause a crash -- possibly due to differences in c# rounding. Therefore, index is now clamped. + Hsv hsvAtPoint = _hsvValues[MathUtilities.Clamp((y * width + x), 0, _hsvValues.Count - 1)]; + + var hsvColor = HsvColor; + + switch (Components) + { + case ColorSpectrumComponents.HueValue: + case ColorSpectrumComponents.ValueHue: + hsvAtPoint.S = hsvColor.S; + break; + + case ColorSpectrumComponents.HueSaturation: + case ColorSpectrumComponents.SaturationHue: + hsvAtPoint.V = hsvColor.V; + break; + + case ColorSpectrumComponents.ValueSaturation: + case ColorSpectrumComponents.SaturationValue: + hsvAtPoint.H = hsvColor.H; + break; + } + + UpdateColor(hsvAtPoint); + } + + private void UpdateEllipse() + { + if (_selectionEllipsePanel == null) + { + return; + } + + // If we don't have an image size yet, we shouldn't be showing the ellipse. + if (_imageWidthFromLastBitmapCreation == 0 || + _imageHeightFromLastBitmapCreation == 0) + { + _selectionEllipsePanel.IsVisible = false; + return; + } + else + { + _selectionEllipsePanel.IsVisible = true; + } + + double xPosition; + double yPosition; + + Hsv hsvColor = new Hsv(HsvColor); + + hsvColor.H = MathUtilities.Clamp(hsvColor.H, (double)_minHueFromLastBitmapCreation, (double)_maxHueFromLastBitmapCreation); + hsvColor.S = MathUtilities.Clamp(hsvColor.S, _minSaturationFromLastBitmapCreation / 100.0, _maxSaturationFromLastBitmapCreation / 100.0); + hsvColor.V = MathUtilities.Clamp(hsvColor.V, _minValueFromLastBitmapCreation / 100.0, _maxValueFromLastBitmapCreation / 100.0); + + if (_shapeFromLastBitmapCreation == ColorSpectrumShape.Box) + { + double xPercent = 0; + double yPercent = 0; + + double hPercent = (hsvColor.H - _minHueFromLastBitmapCreation) / (_maxHueFromLastBitmapCreation - _minHueFromLastBitmapCreation); + double sPercent = (hsvColor.S * 100.0 - _minSaturationFromLastBitmapCreation) / (_maxSaturationFromLastBitmapCreation - _minSaturationFromLastBitmapCreation); + double vPercent = (hsvColor.V * 100.0 - _minValueFromLastBitmapCreation) / (_maxValueFromLastBitmapCreation - _minValueFromLastBitmapCreation); + + // In the case where saturation was an axis in the spectrum with hue, or value is an axis, full stop, + // we inverted the direction of that axis in order to put more hue on the outside of the ring, + // so we need to do similarly here when positioning the ellipse. + if (_componentsFromLastBitmapCreation == ColorSpectrumComponents.HueSaturation || + _componentsFromLastBitmapCreation == ColorSpectrumComponents.SaturationHue) + { + sPercent = 1 - sPercent; + } + else + { + vPercent = 1 - vPercent; + } + + switch (_componentsFromLastBitmapCreation) + { + case ColorSpectrumComponents.HueValue: + xPercent = hPercent; + yPercent = vPercent; + break; + + case ColorSpectrumComponents.HueSaturation: + xPercent = hPercent; + yPercent = sPercent; + break; + + case ColorSpectrumComponents.ValueHue: + xPercent = vPercent; + yPercent = hPercent; + break; + + case ColorSpectrumComponents.ValueSaturation: + xPercent = vPercent; + yPercent = sPercent; + break; + + case ColorSpectrumComponents.SaturationHue: + xPercent = sPercent; + yPercent = hPercent; + break; + + case ColorSpectrumComponents.SaturationValue: + xPercent = sPercent; + yPercent = vPercent; + break; + } + + xPosition = _imageWidthFromLastBitmapCreation * xPercent; + yPosition = _imageHeightFromLastBitmapCreation * yPercent; + } + else + { + double thetaValue = 0; + double rValue = 0; + + double hThetaValue = + _maxHueFromLastBitmapCreation != _minHueFromLastBitmapCreation ? + 360 * (hsvColor.H - _minHueFromLastBitmapCreation) / (_maxHueFromLastBitmapCreation - _minHueFromLastBitmapCreation) : + 0; + double sThetaValue = + _maxSaturationFromLastBitmapCreation != _minSaturationFromLastBitmapCreation ? + 360 * (hsvColor.S * 100.0 - _minSaturationFromLastBitmapCreation) / (_maxSaturationFromLastBitmapCreation - _minSaturationFromLastBitmapCreation) : + 0; + double vThetaValue = + _maxValueFromLastBitmapCreation != _minValueFromLastBitmapCreation ? + 360 * (hsvColor.V * 100.0 - _minValueFromLastBitmapCreation) / (_maxValueFromLastBitmapCreation - _minValueFromLastBitmapCreation) : + 0; + double hRValue = _maxHueFromLastBitmapCreation != _minHueFromLastBitmapCreation ? + (hsvColor.H - _minHueFromLastBitmapCreation) / (_maxHueFromLastBitmapCreation - _minHueFromLastBitmapCreation) - 1 : + 0; + double sRValue = _maxSaturationFromLastBitmapCreation != _minSaturationFromLastBitmapCreation ? + (hsvColor.S * 100.0 - _minSaturationFromLastBitmapCreation) / (_maxSaturationFromLastBitmapCreation - _minSaturationFromLastBitmapCreation) - 1 : + 0; + double vRValue = _maxValueFromLastBitmapCreation != _minValueFromLastBitmapCreation ? + (hsvColor.V * 100.0 - _minValueFromLastBitmapCreation) / (_maxValueFromLastBitmapCreation - _minValueFromLastBitmapCreation) - 1 : + 0; + + // In the case where saturation was an axis in the spectrum with hue, or value is an axis, full stop, + // we inverted the direction of that axis in order to put more hue on the outside of the ring, + // so we need to do similarly here when positioning the ellipse. + if (_componentsFromLastBitmapCreation == ColorSpectrumComponents.HueSaturation || + _componentsFromLastBitmapCreation == ColorSpectrumComponents.SaturationHue) + { + sThetaValue = 360 - sThetaValue; + sRValue = -sRValue - 1; + } + else + { + vThetaValue = 360 - vThetaValue; + vRValue = -vRValue - 1; + } + + switch (_componentsFromLastBitmapCreation) + { + case ColorSpectrumComponents.HueValue: + thetaValue = hThetaValue; + rValue = vRValue; + break; + + case ColorSpectrumComponents.HueSaturation: + thetaValue = hThetaValue; + rValue = sRValue; + break; + + case ColorSpectrumComponents.ValueHue: + thetaValue = vThetaValue; + rValue = hRValue; + break; + + case ColorSpectrumComponents.ValueSaturation: + thetaValue = vThetaValue; + rValue = sRValue; + break; + + case ColorSpectrumComponents.SaturationHue: + thetaValue = sThetaValue; + rValue = hRValue; + break; + + case ColorSpectrumComponents.SaturationValue: + thetaValue = sThetaValue; + rValue = vRValue; + break; + } + + double radius = Math.Min(_imageWidthFromLastBitmapCreation, _imageHeightFromLastBitmapCreation) / 2; + + xPosition = (Math.Cos((thetaValue * Math.PI / 180.0) + Math.PI) * radius * rValue) + radius; + yPosition = (Math.Sin((thetaValue * Math.PI / 180.0) + Math.PI) * radius * rValue) + radius; + } + + // Remember the bitmap size follows physical device pixels + var scale = LayoutHelper.GetLayoutScale(this); + Canvas.SetLeft(_selectionEllipsePanel, (xPosition / scale) - (_selectionEllipsePanel.Width / 2)); + Canvas.SetTop(_selectionEllipsePanel, (yPosition / scale) - (_selectionEllipsePanel.Height / 2)); + + // We only want to bother with the color name tool tip if we can provide color names. + if (IsFocused && + _selectionEllipsePanel != null && + ColorHelper.ToDisplayNameExists) + { + ToolTip.SetIsOpen(_selectionEllipsePanel, true); + } + + UpdatePseudoClasses(); + } + + /// + private void InputTarget_PointerEnter(object? sender, PointerEventArgs args) + { + _isPointerOver = true; + UpdatePseudoClasses(); + args.Handled = true; + } + + /// + private void InputTarget_PointerLeave(object? sender, PointerEventArgs args) + { + _isPointerOver = false; + UpdatePseudoClasses(); + args.Handled = true; + } + + /// + private void InputTarget_PointerPressed(object? sender, PointerPressedEventArgs args) + { + var inputTarget = _inputTarget; + + Focus(); + + _isPointerPressed = true; + _shouldShowLargeSelection = + // TODO: After Pen PR is merged: https://github.com/AvaloniaUI/Avalonia/pull/7412 + // args.Pointer.Type == PointerType.Pen || + args.Pointer.Type == PointerType.Touch; + + args.Pointer.Capture(inputTarget); + UpdateColorFromPoint(args.GetCurrentPoint(inputTarget)); + UpdatePseudoClasses(); + UpdateEllipse(); + + args.Handled = true; + } + + /// + private void InputTarget_PointerMoved(object? sender, PointerEventArgs args) + { + if (!_isPointerPressed) + { + return; + } + + UpdateColorFromPoint(args.GetCurrentPoint(_inputTarget)); + args.Handled = true; + } + + /// + private void InputTarget_PointerReleased(object? sender, PointerReleasedEventArgs args) + { + _isPointerPressed = false; + _shouldShowLargeSelection = false; + + args.Pointer.Capture(null); + UpdatePseudoClasses(); + UpdateEllipse(); + + args.Handled = true; + } + + private async void CreateBitmapsAndColorMap() + { + if (_layoutRoot == null || + _sizingPanel == null || + _inputTarget == null || + _spectrumRectangle == null || + _spectrumEllipse == null || + _spectrumOverlayRectangle == null || + _spectrumOverlayEllipse == null + /*|| SharedHelpers.IsInDesignMode*/) + { + return; + } + + // We want ColorSpectrum to always be a square, so we'll take the smaller of the dimensions + // and size the sizing panel to that. + double minDimension = Math.Min(_layoutRoot.Bounds.Width, _layoutRoot.Bounds.Height); + + if (minDimension == 0) + { + return; + } + + _sizingPanel.Width = minDimension; + _sizingPanel.Height = minDimension; + _inputTarget.Width = minDimension; + _inputTarget.Height = minDimension; + _spectrumRectangle.Width = minDimension; + _spectrumRectangle.Height = minDimension; + _spectrumEllipse.Width = minDimension; + _spectrumEllipse.Height = minDimension; + _spectrumOverlayRectangle.Width = minDimension; + _spectrumOverlayRectangle.Height = minDimension; + _spectrumOverlayEllipse.Width = minDimension; + _spectrumOverlayEllipse.Height = minDimension; + + HsvColor hsvColor = HsvColor; + int minHue = MinHue; + int maxHue = MaxHue; + int minSaturation = MinSaturation; + int maxSaturation = MaxSaturation; + int minValue = MinValue; + int maxValue = MaxValue; + ColorSpectrumShape shape = Shape; + ColorSpectrumComponents components = Components; + + // If min >= max, then by convention, min is the only number that a property can have. + if (minHue >= maxHue) + { + maxHue = minHue; + } + + if (minSaturation >= maxSaturation) + { + maxSaturation = minSaturation; + } + + if (minValue >= maxValue) + { + maxValue = minValue; + } + + Hsv hsv = new Hsv(hsvColor); + + // The middle 4 are only needed and used in the case of hue as the third dimension. + // Saturation and luminosity need only a min and max. + List bgraMinPixelData = new List(); + List bgraMiddle1PixelData = new List(); + List bgraMiddle2PixelData = new List(); + List bgraMiddle3PixelData = new List(); + List bgraMiddle4PixelData = new List(); + List bgraMaxPixelData = new List(); + List newHsvValues = new List(); + + // In Avalonia, Bounds returns the actual device-independent pixel size of a control. + // However, this is not necessarily the size of the control rendered on a display. + // A desktop or application scaling factor may be applied which must be accounted for here. + // Remember bitmaps in Avalonia are rendered mapping to actual device pixels, not the device- + // independent pixels of controls. + var scale = LayoutHelper.GetLayoutScale(this); + int pixelDimension = (int)Math.Round(minDimension * scale); + var pixelCount = pixelDimension * pixelDimension; + var pixelDataSize = pixelCount * 4; + bgraMinPixelData.Capacity = pixelDataSize; + + // We'll only save pixel data for the middle bitmaps if our third dimension is hue. + if (components == ColorSpectrumComponents.ValueSaturation || + components == ColorSpectrumComponents.SaturationValue) + { + bgraMiddle1PixelData.Capacity = pixelDataSize; + bgraMiddle2PixelData.Capacity = pixelDataSize; + bgraMiddle3PixelData.Capacity = pixelDataSize; + bgraMiddle4PixelData.Capacity = pixelDataSize; + } + + bgraMaxPixelData.Capacity = pixelDataSize; + newHsvValues.Capacity = pixelCount; + + await Task.Run(() => + { + // As the user perceives it, every time the third dimension not represented in the ColorSpectrum changes, + // the ColorSpectrum will visually change to accommodate that value. For example, if the ColorSpectrum handles hue and luminosity, + // and the saturation externally goes from 1.0 to 0.5, then the ColorSpectrum will visually change to look more washed out + // to represent that third dimension's new value. + // Internally, however, we don't want to regenerate the ColorSpectrum bitmap every single time this happens, since that's very expensive. + // In order to make it so that we don't have to, we implement an optimization where, rather than having only one bitmap, + // we instead have multiple that we blend together using opacity to create the effect that we want. + // In the case where the third dimension is saturation or luminosity, we only need two: one bitmap at the minimum value + // of the third dimension, and one bitmap at the maximum. Then we set the second's opacity at whatever the value of + // the third dimension is - e.g., a saturation of 0.5 implies an opacity of 50%. + // In the case where the third dimension is hue, we need six: one bitmap corresponding to red, yellow, green, cyan, blue, and purple. + // We'll then blend between whichever colors our hue exists between - e.g., an orange color would use red and yellow with an opacity of 50%. + // This optimization does incur slightly more startup time initially since we have to generate multiple bitmaps at once instead of only one, + // but the running time savings after that are *huge* when we can just set an opacity instead of generating a brand new bitmap. + if (shape == ColorSpectrumShape.Box) + { + for (int x = pixelDimension - 1; x >= 0; --x) + { + for (int y = pixelDimension - 1; y >= 0; --y) + { + FillPixelForBox( + x, y, hsv, pixelDimension, components, minHue, maxHue, minSaturation, maxSaturation, minValue, maxValue, + bgraMinPixelData, bgraMiddle1PixelData, bgraMiddle2PixelData, bgraMiddle3PixelData, bgraMiddle4PixelData, bgraMaxPixelData, + newHsvValues); + } + } + } + else + { + for (int y = 0; y < pixelDimension; ++y) + { + for (int x = 0; x < pixelDimension; ++x) + { + FillPixelForRing( + x, y, pixelDimension / 2.0, hsv, components, minHue, maxHue, minSaturation, maxSaturation, minValue, maxValue, + bgraMinPixelData, bgraMiddle1PixelData, bgraMiddle2PixelData, bgraMiddle3PixelData, bgraMiddle4PixelData, bgraMaxPixelData, + newHsvValues); + } + } + } + }); + + Dispatcher.UIThread.Post(() => + { + int pixelWidth = pixelDimension; + int pixelHeight = pixelDimension; + + ColorSpectrumComponents components2 = Components; + + WriteableBitmap minBitmap = ColorPickerHelpers.CreateBitmapFromPixelData(bgraMinPixelData, pixelWidth, pixelHeight); + WriteableBitmap maxBitmap = ColorPickerHelpers.CreateBitmapFromPixelData(bgraMaxPixelData, pixelWidth, pixelHeight); + + switch (components2) + { + case ColorSpectrumComponents.HueValue: + case ColorSpectrumComponents.ValueHue: + _saturationMinimumBitmap = minBitmap; + _saturationMaximumBitmap = maxBitmap; + break; + case ColorSpectrumComponents.HueSaturation: + case ColorSpectrumComponents.SaturationHue: + _valueBitmap = maxBitmap; + break; + case ColorSpectrumComponents.ValueSaturation: + case ColorSpectrumComponents.SaturationValue: + _hueRedBitmap = minBitmap; + _hueYellowBitmap = ColorPickerHelpers.CreateBitmapFromPixelData(bgraMiddle1PixelData, pixelWidth, pixelHeight); + _hueGreenBitmap = ColorPickerHelpers.CreateBitmapFromPixelData(bgraMiddle2PixelData, pixelWidth, pixelHeight); + _hueCyanBitmap = ColorPickerHelpers.CreateBitmapFromPixelData(bgraMiddle3PixelData, pixelWidth, pixelHeight); + _hueBlueBitmap = ColorPickerHelpers.CreateBitmapFromPixelData(bgraMiddle4PixelData, pixelWidth, pixelHeight); + _huePurpleBitmap = maxBitmap; + break; + } + + _shapeFromLastBitmapCreation = Shape; + _componentsFromLastBitmapCreation = Components; + _imageWidthFromLastBitmapCreation = pixelDimension; + _imageHeightFromLastBitmapCreation = pixelDimension; + _minHueFromLastBitmapCreation = MinHue; + _maxHueFromLastBitmapCreation = MaxHue; + _minSaturationFromLastBitmapCreation = MinSaturation; + _maxSaturationFromLastBitmapCreation = MaxSaturation; + _minValueFromLastBitmapCreation = MinValue; + _maxValueFromLastBitmapCreation = MaxValue; + + _hsvValues = newHsvValues; + + UpdateBitmapSources(); + UpdateEllipse(); + }); + } + + private void FillPixelForBox( + double x, + double y, + Hsv baseHsv, + int minDimension, + ColorSpectrumComponents components, + double minHue, + double maxHue, + double minSaturation, + double maxSaturation, + double minValue, + double maxValue, + List bgraMinPixelData, + List bgraMiddle1PixelData, + List bgraMiddle2PixelData, + List bgraMiddle3PixelData, + List bgraMiddle4PixelData, + List bgraMaxPixelData, + List newHsvValues) + { + double hMin = minHue; + double hMax = maxHue; + double sMin = minSaturation / 100.0; + double sMax = maxSaturation / 100.0; + double vMin = minValue / 100.0; + double vMax = maxValue / 100.0; + + Hsv hsvMin = baseHsv; + Hsv hsvMiddle1 = baseHsv; + Hsv hsvMiddle2 = baseHsv; + Hsv hsvMiddle3 = baseHsv; + Hsv hsvMiddle4 = baseHsv; + Hsv hsvMax = baseHsv; + + double xPercent = (minDimension - 1 - x) / (minDimension - 1); + double yPercent = (minDimension - 1 - y) / (minDimension - 1); + + switch (components) + { + case ColorSpectrumComponents.HueValue: + hsvMin.H = hsvMiddle1.H = hsvMiddle2.H = hsvMiddle3.H = hsvMiddle4.H = hsvMax.H = hMin + yPercent * (hMax - hMin); + hsvMin.V = hsvMiddle1.V = hsvMiddle2.V = hsvMiddle3.V = hsvMiddle4.V = hsvMax.V = vMin + xPercent * (vMax - vMin); + hsvMin.S = 0; + hsvMax.S = 1; + break; + + case ColorSpectrumComponents.HueSaturation: + hsvMin.H = hsvMiddle1.H = hsvMiddle2.H = hsvMiddle3.H = hsvMiddle4.H = hsvMax.H = hMin + yPercent * (hMax - hMin); + hsvMin.S = hsvMiddle1.S = hsvMiddle2.S = hsvMiddle3.S = hsvMiddle4.S = hsvMax.S = sMin + xPercent * (sMax - sMin); + hsvMin.V = 0; + hsvMax.V = 1; + break; + + case ColorSpectrumComponents.ValueHue: + hsvMin.V = hsvMiddle1.V = hsvMiddle2.V = hsvMiddle3.V = hsvMiddle4.V = hsvMax.V = vMin + yPercent * (vMax - vMin); + hsvMin.H = hsvMiddle1.H = hsvMiddle2.H = hsvMiddle3.H = hsvMiddle4.H = hsvMax.H = hMin + xPercent * (hMax - hMin); + hsvMin.S = 0; + hsvMax.S = 1; + break; + + case ColorSpectrumComponents.ValueSaturation: + hsvMin.V = hsvMiddle1.V = hsvMiddle2.V = hsvMiddle3.V = hsvMiddle4.V = hsvMax.V = vMin + yPercent * (vMax - vMin); + hsvMin.S = hsvMiddle1.S = hsvMiddle2.S = hsvMiddle3.S = hsvMiddle4.S = hsvMax.S = sMin + xPercent * (sMax - sMin); + hsvMin.H = 0; + hsvMiddle1.H = 60; + hsvMiddle2.H = 120; + hsvMiddle3.H = 180; + hsvMiddle4.H = 240; + hsvMax.H = 300; + break; + + case ColorSpectrumComponents.SaturationHue: + hsvMin.S = hsvMiddle1.S = hsvMiddle2.S = hsvMiddle3.S = hsvMiddle4.S = hsvMax.S = sMin + yPercent * (sMax - sMin); + hsvMin.H = hsvMiddle1.H = hsvMiddle2.H = hsvMiddle3.H = hsvMiddle4.H = hsvMax.H = hMin + xPercent * (hMax - hMin); + hsvMin.V = 0; + hsvMax.V = 1; + break; + + case ColorSpectrumComponents.SaturationValue: + hsvMin.S = hsvMiddle1.S = hsvMiddle2.S = hsvMiddle3.S = hsvMiddle4.S = hsvMax.S = sMin + yPercent * (sMax - sMin); + hsvMin.V = hsvMiddle1.V = hsvMiddle2.V = hsvMiddle3.V = hsvMiddle4.V = hsvMax.V = vMin + xPercent * (vMax - vMin); + hsvMin.H = 0; + hsvMiddle1.H = 60; + hsvMiddle2.H = 120; + hsvMiddle3.H = 180; + hsvMiddle4.H = 240; + hsvMax.H = 300; + break; + } + + // If saturation is an axis in the spectrum with hue, or value is an axis, then we want + // that axis to go from maximum at the top to minimum at the bottom, + // or maximum at the outside to minimum at the inside in the case of the ring configuration, + // so we'll invert the number before assigning the HSL value to the array. + // Otherwise, we'll have a very narrow section in the middle that actually has meaningful hue + // in the case of the ring configuration. + if (components == ColorSpectrumComponents.HueSaturation || + components == ColorSpectrumComponents.SaturationHue) + { + hsvMin.S = sMax - hsvMin.S + sMin; + hsvMiddle1.S = sMax - hsvMiddle1.S + sMin; + hsvMiddle2.S = sMax - hsvMiddle2.S + sMin; + hsvMiddle3.S = sMax - hsvMiddle3.S + sMin; + hsvMiddle4.S = sMax - hsvMiddle4.S + sMin; + hsvMax.S = sMax - hsvMax.S + sMin; + } + else + { + hsvMin.V = vMax - hsvMin.V + vMin; + hsvMiddle1.V = vMax - hsvMiddle1.V + vMin; + hsvMiddle2.V = vMax - hsvMiddle2.V + vMin; + hsvMiddle3.V = vMax - hsvMiddle3.V + vMin; + hsvMiddle4.V = vMax - hsvMiddle4.V + vMin; + hsvMax.V = vMax - hsvMax.V + vMin; + } + + newHsvValues.Add(hsvMin); + + Rgb rgbMin = hsvMin.ToRgb(); + bgraMinPixelData.Add((byte)Math.Round(rgbMin.B * 255.0)); // b + bgraMinPixelData.Add((byte)Math.Round(rgbMin.G * 255.0)); // g + bgraMinPixelData.Add((byte)Math.Round(rgbMin.R * 255.0)); // r + bgraMinPixelData.Add(255); // a - ignored + + // We'll only save pixel data for the middle bitmaps if our third dimension is hue. + if (components == ColorSpectrumComponents.ValueSaturation || + components == ColorSpectrumComponents.SaturationValue) + { + Rgb rgbMiddle1 = hsvMiddle1.ToRgb(); + bgraMiddle1PixelData.Add((byte)Math.Round(rgbMiddle1.B * 255.0)); // b + bgraMiddle1PixelData.Add((byte)Math.Round(rgbMiddle1.G * 255.0)); // g + bgraMiddle1PixelData.Add((byte)Math.Round(rgbMiddle1.R * 255.0)); // r + bgraMiddle1PixelData.Add(255); // a - ignored + + Rgb rgbMiddle2 = hsvMiddle2.ToRgb(); + bgraMiddle2PixelData.Add((byte)Math.Round(rgbMiddle2.B * 255.0)); // b + bgraMiddle2PixelData.Add((byte)Math.Round(rgbMiddle2.G * 255.0)); // g + bgraMiddle2PixelData.Add((byte)Math.Round(rgbMiddle2.R * 255.0)); // r + bgraMiddle2PixelData.Add(255); // a - ignored + + Rgb rgbMiddle3 = hsvMiddle3.ToRgb(); + bgraMiddle3PixelData.Add((byte)Math.Round(rgbMiddle3.B * 255.0)); // b + bgraMiddle3PixelData.Add((byte)Math.Round(rgbMiddle3.G * 255.0)); // g + bgraMiddle3PixelData.Add((byte)Math.Round(rgbMiddle3.R * 255.0)); // r + bgraMiddle3PixelData.Add(255); // a - ignored + + Rgb rgbMiddle4 = hsvMiddle4.ToRgb(); + bgraMiddle4PixelData.Add((byte)Math.Round(rgbMiddle4.B * 255.0)); // b + bgraMiddle4PixelData.Add((byte)Math.Round(rgbMiddle4.G * 255.0)); // g + bgraMiddle4PixelData.Add((byte)Math.Round(rgbMiddle4.R * 255.0)); // r + bgraMiddle4PixelData.Add(255); // a - ignored + } + + Rgb rgbMax = hsvMax.ToRgb(); + bgraMaxPixelData.Add((byte)Math.Round(rgbMax.B * 255.0)); // b + bgraMaxPixelData.Add((byte)Math.Round(rgbMax.G * 255.0)); // g + bgraMaxPixelData.Add((byte)Math.Round(rgbMax.R * 255.0)); // r + bgraMaxPixelData.Add(255); // a - ignored + } + + private void FillPixelForRing( + double x, + double y, + double radius, + Hsv baseHsv, + ColorSpectrumComponents components, + double minHue, + double maxHue, + double minSaturation, + double maxSaturation, + double minValue, + double maxValue, + List bgraMinPixelData, + List bgraMiddle1PixelData, + List bgraMiddle2PixelData, + List bgraMiddle3PixelData, + List bgraMiddle4PixelData, + List bgraMaxPixelData, + List newHsvValues) + { + double hMin = minHue; + double hMax = maxHue; + double sMin = minSaturation / 100.0; + double sMax = maxSaturation / 100.0; + double vMin = minValue / 100.0; + double vMax = maxValue / 100.0; + + double distanceFromRadius = Math.Sqrt(Math.Pow(x - radius, 2) + Math.Pow(y - radius, 2)); + + double xToUse = x; + double yToUse = y; + + // If we're outside the ring, then we want the pixel to appear as blank. + // However, to avoid issues with rounding errors, we'll act as though this point + // is on the edge of the ring for the purposes of returning an HSL value. + // That way, hit testing on the edges will always return the correct value. + if (distanceFromRadius > radius) + { + xToUse = (radius / distanceFromRadius) * (x - radius) + radius; + yToUse = (radius / distanceFromRadius) * (y - radius) + radius; + distanceFromRadius = radius; + } + + Hsv hsvMin = baseHsv; + Hsv hsvMiddle1 = baseHsv; + Hsv hsvMiddle2 = baseHsv; + Hsv hsvMiddle3 = baseHsv; + Hsv hsvMiddle4 = baseHsv; + Hsv hsvMax = baseHsv; + + double r = 1 - distanceFromRadius / radius; + + double theta = Math.Atan2((radius - yToUse), (radius - xToUse)) * 180.0 / Math.PI; + theta += 180.0; + theta = Math.Floor(theta); + + while (theta > 360) + { + theta -= 360; + } + + double thetaPercent = theta / 360; + + switch (components) + { + case ColorSpectrumComponents.HueValue: + hsvMin.H = hsvMiddle1.H = hsvMiddle2.H = hsvMiddle3.H = hsvMiddle4.H = hsvMax.H = hMin + thetaPercent * (hMax - hMin); + hsvMin.V = hsvMiddle1.V = hsvMiddle2.V = hsvMiddle3.V = hsvMiddle4.V = hsvMax.V = vMin + r * (vMax - vMin); + hsvMin.S = 0; + hsvMax.S = 1; + break; + + case ColorSpectrumComponents.HueSaturation: + hsvMin.H = hsvMiddle1.H = hsvMiddle2.H = hsvMiddle3.H = hsvMiddle4.H = hsvMax.H = hMin + thetaPercent * (hMax - hMin); + hsvMin.S = hsvMiddle1.S = hsvMiddle2.S = hsvMiddle3.S = hsvMiddle4.S = hsvMax.S = sMin + r * (sMax - sMin); + hsvMin.V = 0; + hsvMax.V = 1; + break; + + case ColorSpectrumComponents.ValueHue: + hsvMin.V = hsvMiddle1.V = hsvMiddle2.V = hsvMiddle3.V = hsvMiddle4.V = hsvMax.V = vMin + thetaPercent * (vMax - vMin); + hsvMin.H = hsvMiddle1.H = hsvMiddle2.H = hsvMiddle3.H = hsvMiddle4.H = hsvMax.H = hMin + r * (hMax - hMin); + hsvMin.S = 0; + hsvMax.S = 1; + break; + + case ColorSpectrumComponents.ValueSaturation: + hsvMin.V = hsvMiddle1.V = hsvMiddle2.V = hsvMiddle3.V = hsvMiddle4.V = hsvMax.V = vMin + thetaPercent * (vMax - vMin); + hsvMin.S = hsvMiddle1.S = hsvMiddle2.S = hsvMiddle3.S = hsvMiddle4.S = hsvMax.S = sMin + r * (sMax - sMin); + hsvMin.H = 0; + hsvMiddle1.H = 60; + hsvMiddle2.H = 120; + hsvMiddle3.H = 180; + hsvMiddle4.H = 240; + hsvMax.H = 300; + break; + + case ColorSpectrumComponents.SaturationHue: + hsvMin.S = hsvMiddle1.S = hsvMiddle2.S = hsvMiddle3.S = hsvMiddle4.S = hsvMax.S = sMin + thetaPercent * (sMax - sMin); + hsvMin.H = hsvMiddle1.H = hsvMiddle2.H = hsvMiddle3.H = hsvMiddle4.H = hsvMax.H = hMin + r * (hMax - hMin); + hsvMin.V = 0; + hsvMax.V = 1; + break; + + case ColorSpectrumComponents.SaturationValue: + hsvMin.S = hsvMiddle1.S = hsvMiddle2.S = hsvMiddle3.S = hsvMiddle4.S = hsvMax.S = sMin + thetaPercent * (sMax - sMin); + hsvMin.V = hsvMiddle1.V = hsvMiddle2.V = hsvMiddle3.V = hsvMiddle4.V = hsvMax.V = vMin + r * (vMax - vMin); + hsvMin.H = 0; + hsvMiddle1.H = 60; + hsvMiddle2.H = 120; + hsvMiddle3.H = 180; + hsvMiddle4.H = 240; + hsvMax.H = 300; + break; + } + + // If saturation is an axis in the spectrum with hue, or value is an axis, then we want + // that axis to go from maximum at the top to minimum at the bottom, + // or maximum at the outside to minimum at the inside in the case of the ring configuration, + // so we'll invert the number before assigning the HSL value to the array. + // Otherwise, we'll have a very narrow section in the middle that actually has meaningful hue + // in the case of the ring configuration. + if (components == ColorSpectrumComponents.HueSaturation || + components == ColorSpectrumComponents.SaturationHue) + { + hsvMin.S = sMax - hsvMin.S + sMin; + hsvMiddle1.S = sMax - hsvMiddle1.S + sMin; + hsvMiddle2.S = sMax - hsvMiddle2.S + sMin; + hsvMiddle3.S = sMax - hsvMiddle3.S + sMin; + hsvMiddle4.S = sMax - hsvMiddle4.S + sMin; + hsvMax.S = sMax - hsvMax.S + sMin; + } + else + { + hsvMin.V = vMax - hsvMin.V + vMin; + hsvMiddle1.V = vMax - hsvMiddle1.V + vMin; + hsvMiddle2.V = vMax - hsvMiddle2.V + vMin; + hsvMiddle3.V = vMax - hsvMiddle3.V + vMin; + hsvMiddle4.V = vMax - hsvMiddle4.V + vMin; + hsvMax.V = vMax - hsvMax.V + vMin; + } + + newHsvValues.Add(hsvMin); + + Rgb rgbMin = hsvMin.ToRgb(); + bgraMinPixelData.Add((byte)Math.Round(rgbMin.B * 255)); // b + bgraMinPixelData.Add((byte)Math.Round(rgbMin.G * 255)); // g + bgraMinPixelData.Add((byte)Math.Round(rgbMin.R * 255)); // r + bgraMinPixelData.Add(255); // a + + // We'll only save pixel data for the middle bitmaps if our third dimension is hue. + if (components == ColorSpectrumComponents.ValueSaturation || + components == ColorSpectrumComponents.SaturationValue) + { + Rgb rgbMiddle1 = hsvMiddle1.ToRgb(); + bgraMiddle1PixelData.Add((byte)Math.Round(rgbMiddle1.B * 255)); // b + bgraMiddle1PixelData.Add((byte)Math.Round(rgbMiddle1.G * 255)); // g + bgraMiddle1PixelData.Add((byte)Math.Round(rgbMiddle1.R * 255)); // r + bgraMiddle1PixelData.Add(255); // a + + Rgb rgbMiddle2 = hsvMiddle2.ToRgb(); + bgraMiddle2PixelData.Add((byte)Math.Round(rgbMiddle2.B * 255)); // b + bgraMiddle2PixelData.Add((byte)Math.Round(rgbMiddle2.G * 255)); // g + bgraMiddle2PixelData.Add((byte)Math.Round(rgbMiddle2.R * 255)); // r + bgraMiddle2PixelData.Add(255); // a + + Rgb rgbMiddle3 = hsvMiddle3.ToRgb(); + bgraMiddle3PixelData.Add((byte)Math.Round(rgbMiddle3.B * 255)); // b + bgraMiddle3PixelData.Add((byte)Math.Round(rgbMiddle3.G * 255)); // g + bgraMiddle3PixelData.Add((byte)Math.Round(rgbMiddle3.R * 255)); // r + bgraMiddle3PixelData.Add(255); // a + + Rgb rgbMiddle4 = hsvMiddle4.ToRgb(); + bgraMiddle4PixelData.Add((byte)Math.Round(rgbMiddle4.B * 255)); // b + bgraMiddle4PixelData.Add((byte)Math.Round(rgbMiddle4.G * 255)); // g + bgraMiddle4PixelData.Add((byte)Math.Round(rgbMiddle4.R * 255)); // r + bgraMiddle4PixelData.Add(255); // a + } + + Rgb rgbMax = hsvMax.ToRgb(); + bgraMaxPixelData.Add((byte)Math.Round(rgbMax.B * 255)); // b + bgraMaxPixelData.Add((byte)Math.Round(rgbMax.G * 255)); // g + bgraMaxPixelData.Add((byte)Math.Round(rgbMax.R * 255)); // r + bgraMaxPixelData.Add(255); // a + } + + private void UpdateBitmapSources() + { + if (_spectrumOverlayRectangle == null || + _spectrumOverlayEllipse == null || + _spectrumRectangle == null || + _spectrumEllipse == null) + { + return; + } + + HsvColor hsvColor = HsvColor; + ColorSpectrumComponents components = Components; + + // We'll set the base image and the overlay image based on which component is our third dimension. + // If it's saturation or luminosity, then the base image is that dimension at its minimum value, + // while the overlay image is that dimension at its maximum value. + // If it's hue, then we'll figure out where in the color wheel we are, and then use the two + // colors on either side of our position as our base image and overlay image. + // For example, if our hue is orange, then the base image would be red and the overlay image yellow. + switch (components) + { + case ColorSpectrumComponents.HueValue: + case ColorSpectrumComponents.ValueHue: + { + if (_saturationMinimumBitmap == null || + _saturationMaximumBitmap == null) + { + return; + } + + ImageBrush spectrumBrush = new ImageBrush(_saturationMinimumBitmap); + ImageBrush spectrumOverlayBrush = new ImageBrush(_saturationMaximumBitmap); + + _spectrumOverlayRectangle.Opacity = hsvColor.S; + _spectrumOverlayEllipse.Opacity = hsvColor.S; + _spectrumRectangle.Fill = spectrumBrush; + _spectrumEllipse.Fill = spectrumBrush; + _spectrumOverlayRectangle.Fill = spectrumOverlayBrush; + _spectrumOverlayRectangle.Fill = spectrumOverlayBrush; + } + break; + + case ColorSpectrumComponents.HueSaturation: + case ColorSpectrumComponents.SaturationHue: + { + if (_valueBitmap == null) + { + return; + } + + ImageBrush spectrumBrush = new ImageBrush(_valueBitmap); + ImageBrush spectrumOverlayBrush = new ImageBrush(_valueBitmap); + + _spectrumOverlayRectangle.Opacity = 1.0; + _spectrumOverlayEllipse.Opacity = 1.0; + _spectrumRectangle.Fill = spectrumBrush; + _spectrumEllipse.Fill = spectrumBrush; + _spectrumOverlayRectangle.Fill = spectrumOverlayBrush; + _spectrumOverlayRectangle.Fill = spectrumOverlayBrush; + } + break; + + case ColorSpectrumComponents.ValueSaturation: + case ColorSpectrumComponents.SaturationValue: + { + if (_hueRedBitmap == null || + _hueYellowBitmap == null || + _hueGreenBitmap == null || + _hueCyanBitmap == null || + _hueBlueBitmap == null || + _huePurpleBitmap == null) + { + return; + } + + ImageBrush spectrumBrush; + ImageBrush spectrumOverlayBrush; + + double sextant = hsvColor.H / 60.0; + + if (sextant < 1) + { + spectrumBrush = new ImageBrush(_hueRedBitmap); + spectrumOverlayBrush = new ImageBrush(_hueYellowBitmap); + } + else if (sextant >= 1 && sextant < 2) + { + spectrumBrush = new ImageBrush(_hueYellowBitmap); + spectrumOverlayBrush = new ImageBrush(_hueGreenBitmap); + } + else if (sextant >= 2 && sextant < 3) + { + spectrumBrush = new ImageBrush(_hueGreenBitmap); + spectrumOverlayBrush = new ImageBrush(_hueCyanBitmap); + } + else if (sextant >= 3 && sextant < 4) + { + spectrumBrush = new ImageBrush(_hueCyanBitmap); + spectrumOverlayBrush = new ImageBrush(_hueBlueBitmap); + } + else if (sextant >= 4 && sextant < 5) + { + spectrumBrush = new ImageBrush(_hueBlueBitmap); + spectrumOverlayBrush = new ImageBrush(_huePurpleBitmap); + } + else + { + spectrumBrush = new ImageBrush(_huePurpleBitmap); + spectrumOverlayBrush = new ImageBrush(_hueRedBitmap); + } + + _spectrumOverlayRectangle.Opacity = sextant - (int)sextant; + _spectrumOverlayEllipse.Opacity = sextant - (int)sextant; + _spectrumRectangle.Fill = spectrumBrush; + _spectrumEllipse.Fill = spectrumBrush; + _spectrumOverlayRectangle.Fill = spectrumOverlayBrush; + _spectrumOverlayRectangle.Fill = spectrumOverlayBrush; + } + break; + } + } + + /// + /// Determines whether the selection ellipse should be light based on the relative + /// luminance of the selected color. + /// + private bool SelectionEllipseShouldBeLight() + { + // The selection ellipse should be light if and only if the chosen color + // contrasts more with black than it does with white. + // To find how much something contrasts with white, we use the equation + // for relative luminance. + // + // If the third component is value, then we won't be updating the spectrum's displayed colors, + // so in that case we should use a value of 1 when considering the backdrop + // for the selection ellipse. + Color displayedColor; + + if (Components == ColorSpectrumComponents.HueSaturation || + Components == ColorSpectrumComponents.SaturationHue) + { + HsvColor hsvColor = HsvColor; + Rgb color = (new Hsv(hsvColor.H, hsvColor.S, 1.0)).ToRgb(); + displayedColor = color.ToColor(hsvColor.A); + } + else + { + displayedColor = Color; + } + + var lum = ColorHelper.GetRelativeLuminance(displayedColor); + + return lum <= 0.5; + } + } +} diff --git a/src/Avalonia.Controls.ColorPicker/ColorSpectrum/ColorSpectrumComponents.cs b/src/Avalonia.Controls.ColorPicker/ColorSpectrum/ColorSpectrumComponents.cs new file mode 100644 index 0000000000..164089096e --- /dev/null +++ b/src/Avalonia.Controls.ColorPicker/ColorSpectrum/ColorSpectrumComponents.cs @@ -0,0 +1,73 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under the MIT License. + +using Avalonia.Controls.Primitives; + +namespace Avalonia.Controls +{ + /// + /// Defines the two HSV color components displayed by a . + /// + /// + /// Order of the color components is important and correspond with an X/Y axis in Box + /// shape or a degree/radius in Ring shape. + /// + public enum ColorSpectrumComponents + { + /// + /// The Hue and Value components. + /// + /// + /// In Box shape, Hue is mapped to the X-axis and Value is mapped to the Y-axis. + /// In Ring shape, Hue is mapped to degrees and Value is mapped to radius. + /// + HueValue, + + /// + /// The Value and Hue components. + /// + /// + /// In Box shape, Value is mapped to the X-axis and Hue is mapped to the Y-axis. + /// In Ring shape, Value is mapped to degrees and Hue is mapped to radius. + /// + ValueHue, + + /// + /// The Hue and Saturation components. + /// + /// + /// In Box shape, Hue is mapped to the X-axis and Saturation is mapped to the Y-axis. + /// In Ring shape, Hue is mapped to degrees and Saturation is mapped to radius. + /// + HueSaturation, + + /// + /// The Saturation and Hue components. + /// + /// + /// In Box shape, Saturation is mapped to the X-axis and Hue is mapped to the Y-axis. + /// In Ring shape, Saturation is mapped to degrees and Hue is mapped to radius. + /// + SaturationHue, + + /// + /// The Saturation and Value components. + /// + /// + /// In Box shape, Saturation is mapped to the X-axis and Value is mapped to the Y-axis. + /// In Ring shape, Saturation is mapped to degrees and Value is mapped to radius. + /// + SaturationValue, + + /// + /// The Value and Saturation components. + /// + /// + /// In Box shape, Value is mapped to the X-axis and Saturation is mapped to the Y-axis. + /// In Ring shape, Value is mapped to degrees and Saturation is mapped to radius. + /// + ValueSaturation, + }; +} diff --git a/src/Avalonia.Controls.ColorPicker/ColorSpectrum/ColorSpectrumShape.cs b/src/Avalonia.Controls.ColorPicker/ColorSpectrum/ColorSpectrumShape.cs new file mode 100644 index 0000000000..0319d4a6c8 --- /dev/null +++ b/src/Avalonia.Controls.ColorPicker/ColorSpectrum/ColorSpectrumShape.cs @@ -0,0 +1,26 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under the MIT License. + +using Avalonia.Controls.Primitives; + +namespace Avalonia.Controls +{ + /// + /// Defines the shape of a . + /// + public enum ColorSpectrumShape + { + /// + /// The spectrum is in the shape of a rectangular or square box. + /// Note that more colors are visible to the user in Box shape. + /// + Box, + + /// + /// The spectrum is in the shape of an ellipse or circle. + /// + Ring, + }; +} diff --git a/src/Avalonia.Controls.ColorPicker/Converters/AccentColorConverter.cs b/src/Avalonia.Controls.ColorPicker/Converters/AccentColorConverter.cs new file mode 100644 index 0000000000..4d05222e31 --- /dev/null +++ b/src/Avalonia.Controls.ColorPicker/Converters/AccentColorConverter.cs @@ -0,0 +1,116 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace Avalonia.Controls.Primitives.Converters +{ + /// + /// Creates an accent color for a given base color value and step parameter. + /// This is a highly-specialized converter for the color picker. + /// + public class AccentColorConverter : IValueConverter + { + /// + /// The amount to change the Value component for each accent color step. + /// + public const double ValueDelta = 0.1; + + /// + public object? Convert( + object? value, + Type targetType, + object? parameter, + CultureInfo culture) + { + int accentStep; + Color? rgbColor = null; + HsvColor? hsvColor = null; + + if (value is Color valueColor) + { + rgbColor = valueColor; + } + else if (value is HslColor valueHslColor) + { + rgbColor = valueHslColor.ToRgb(); + } + else if (value is HsvColor valueHsvColor) + { + hsvColor = valueHsvColor; + } + else if (value is SolidColorBrush valueBrush) + { + rgbColor = valueBrush.Color; + } + else + { + // Invalid color value provided + return AvaloniaProperty.UnsetValue; + } + + // Get the value component delta + try + { + accentStep = int.Parse(parameter?.ToString() ?? "", CultureInfo.InvariantCulture); + } + catch + { + // Invalid parameter provided, unable to convert to integer + return AvaloniaProperty.UnsetValue; + } + + if (hsvColor == null && + rgbColor != null) + { + hsvColor = rgbColor.Value.ToHsv(); + } + + if (hsvColor != null) + { + return new SolidColorBrush(GetAccent(hsvColor.Value, accentStep).ToRgb()); + } + else + { + return AvaloniaProperty.UnsetValue; + } + } + + /// + public object? ConvertBack( + object? value, + Type targetType, + object? parameter, + CultureInfo culture) + { + return AvaloniaProperty.UnsetValue; + } + + /// + /// This does not account for perceptual differences and also does not match with + /// system accent color calculation. + /// + /// + /// Use the HSV representation as it's more perceptual. + /// In most cases only the value is changed by a fixed percentage so the algorithm is reproducible. + /// + /// The base color to calculate the accent from. + /// The number of accent color steps to move. + /// The new accent color. + public static HsvColor GetAccent(HsvColor hsvColor, int accentStep) + { + if (accentStep != 0) + { + double colorValue = hsvColor.V; + colorValue += (accentStep * AccentColorConverter.ValueDelta); + colorValue = Math.Round(colorValue, 2); + + return new HsvColor(hsvColor.A, hsvColor.H, hsvColor.S, colorValue); + } + else + { + return hsvColor; + } + } + } +} diff --git a/src/Avalonia.Controls.ColorPicker/Converters/ColorToDisplayNameConverter.cs b/src/Avalonia.Controls.ColorPicker/Converters/ColorToDisplayNameConverter.cs new file mode 100644 index 0000000000..4f727287ba --- /dev/null +++ b/src/Avalonia.Controls.ColorPicker/Converters/ColorToDisplayNameConverter.cs @@ -0,0 +1,68 @@ +using System; +using System.Globalization; +using Avalonia.Controls.Primitives; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace Avalonia.Controls.Converters +{ + /// + /// Gets the approximated display name for the color. + /// + public class ColorToDisplayNameConverter : IValueConverter + { + /// + public object? Convert( + object? value, + Type targetType, + object? parameter, + CultureInfo culture) + { + Color color; + + if (value is Color valueColor) + { + color = valueColor; + } + else if (value is HslColor valueHslColor) + { + color = valueHslColor.ToRgb(); + } + else if (value is HsvColor valueHsvColor) + { + color = valueHsvColor.ToRgb(); + } + else if (value is SolidColorBrush valueBrush) + { + color = valueBrush.Color; + } + else + { + // Invalid color value provided + return AvaloniaProperty.UnsetValue; + } + + // ColorHelper.ToDisplayName ignores the alpha component + // This means fully transparent colors will be named as a real color + // That undesirable behavior is specially overridden here + if (color.A == 0x00) + { + return AvaloniaProperty.UnsetValue; + } + else + { + return ColorHelper.ToDisplayName(color); + } + } + + /// + public object? ConvertBack( + object? value, + Type targetType, + object? parameter, + CultureInfo culture) + { + return AvaloniaProperty.UnsetValue; + } + } +} diff --git a/src/Avalonia.Controls.ColorPicker/Converters/ColorToHexConverter.cs b/src/Avalonia.Controls.ColorPicker/Converters/ColorToHexConverter.cs new file mode 100644 index 0000000000..9b09073d9d --- /dev/null +++ b/src/Avalonia.Controls.ColorPicker/Converters/ColorToHexConverter.cs @@ -0,0 +1,82 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace Avalonia.Controls.Converters +{ + /// + /// Converts a color to a hex string and vice versa. + /// + public class ColorToHexConverter : IValueConverter + { + /// + public object? Convert( + object? value, + Type targetType, + object? parameter, + CultureInfo culture) + { + Color color; + bool includeSymbol = parameter as bool? ?? false; + + if (value is Color valueColor) + { + color = valueColor; + } + else if (value is HslColor valueHslColor) + { + color = valueHslColor.ToRgb(); + } + else if (value is HsvColor valueHsvColor) + { + color = valueHsvColor.ToRgb(); + } + else if (value is SolidColorBrush valueBrush) + { + color = valueBrush.Color; + } + else + { + // Invalid color value provided + return AvaloniaProperty.UnsetValue; + } + + string hexColor = color.ToString(); + + if (includeSymbol == false) + { + // TODO: When .net standard 2.0 is dropped, replace the below line + //hexColor = hexColor.Replace("#", string.Empty, StringComparison.Ordinal); + hexColor = hexColor.Replace("#", string.Empty); + } + + return hexColor; + } + + /// + public object? ConvertBack( + object? value, + Type targetType, + object? parameter, + CultureInfo culture) + { + string hexValue = value?.ToString() ?? string.Empty; + + if (Color.TryParse(hexValue, out Color color)) + { + return color; + } + else if (hexValue.StartsWith("#", StringComparison.Ordinal) == false && + Color.TryParse("#" + hexValue, out Color color2)) + { + return color2; + } + else + { + // Invalid hex color value provided + return AvaloniaProperty.UnsetValue; + } + } + } +} diff --git a/src/Avalonia.Controls.ColorPicker/Converters/ThirdComponentConverter.cs b/src/Avalonia.Controls.ColorPicker/Converters/ThirdComponentConverter.cs new file mode 100644 index 0000000000..220a993f99 --- /dev/null +++ b/src/Avalonia.Controls.ColorPicker/Converters/ThirdComponentConverter.cs @@ -0,0 +1,51 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; + +namespace Avalonia.Controls.Primitives.Converters +{ + /// + /// Gets the third corresponding with a given + /// that represents the other two components. + /// This is a highly-specialized converter for the color picker. + /// + public class ThirdComponentConverter : IValueConverter + { + /// + public object? Convert( + object? value, + Type targetType, + object? parameter, + CultureInfo culture) + { + if (value is ColorSpectrumComponents components) + { + // Note: Alpha is not relevant here + switch (components) + { + case ColorSpectrumComponents.HueSaturation: + case ColorSpectrumComponents.SaturationHue: + return (ColorComponent)HsvComponent.Value; + case ColorSpectrumComponents.HueValue: + case ColorSpectrumComponents.ValueHue: + return (ColorComponent)HsvComponent.Saturation; + case ColorSpectrumComponents.SaturationValue: + case ColorSpectrumComponents.ValueSaturation: + return (ColorComponent)HsvComponent.Hue; + } + } + + return AvaloniaProperty.UnsetValue; + } + + /// + public object? ConvertBack( + object? value, + Type targetType, + object? parameter, + CultureInfo culture) + { + return AvaloniaProperty.UnsetValue; + } + } +} diff --git a/src/Avalonia.Controls.ColorPicker/Converters/ToBrushConverter.cs b/src/Avalonia.Controls.ColorPicker/Converters/ToBrushConverter.cs new file mode 100644 index 0000000000..9e8c264dd3 --- /dev/null +++ b/src/Avalonia.Controls.ColorPicker/Converters/ToBrushConverter.cs @@ -0,0 +1,50 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace Avalonia.Controls.Converters +{ + /// + /// Converts the given value into an when a conversion is possible. + /// + public class ToBrushConverter : IValueConverter + { + /// + public object? Convert( + object? value, + Type targetType, + object? parameter, + CultureInfo culture) + { + if (value is IBrush brush) + { + return brush; + } + else if (value is Color valueColor) + { + return new SolidColorBrush(valueColor); + } + else if (value is HslColor valueHslColor) + { + return new SolidColorBrush(valueHslColor.ToRgb()); + } + else if (value is HsvColor valueHsvColor) + { + return new SolidColorBrush(valueHsvColor.ToRgb()); + } + + return AvaloniaProperty.UnsetValue; + } + + /// + public object? ConvertBack( + object? value, + Type targetType, + object? parameter, + CultureInfo culture) + { + return AvaloniaProperty.UnsetValue; + } + } +} diff --git a/src/Avalonia.Controls.ColorPicker/Converters/ToColorConverter.cs b/src/Avalonia.Controls.ColorPicker/Converters/ToColorConverter.cs new file mode 100644 index 0000000000..14b2be225e --- /dev/null +++ b/src/Avalonia.Controls.ColorPicker/Converters/ToColorConverter.cs @@ -0,0 +1,58 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; +using Avalonia.Utilities; + +namespace Avalonia.Controls.Converters +{ + /// + /// Converts the given value into a when a conversion is possible. + /// + public class ToColorConverter : IValueConverter + { + /// + public object? Convert( + object? value, + Type targetType, + object? parameter, + CultureInfo culture) + { + if (value is Color valueColor) + { + return valueColor; + } + else if (value is HslColor valueHslColor) + { + return valueHslColor.ToRgb(); + } + else if (value is HsvColor valueHsvColor) + { + return valueHsvColor.ToRgb(); + } + else if (value is SolidColorBrush valueBrush) + { + // A brush may have an opacity set along with alpha transparency + double alpha = valueBrush.Color.A * valueBrush.Opacity; + + return new Color( + (byte)MathUtilities.Clamp(alpha, 0x00, 0xFF), + valueBrush.Color.R, + valueBrush.Color.G, + valueBrush.Color.B); + } + + return AvaloniaProperty.UnsetValue; + } + + /// + public object? ConvertBack( + object? value, + Type targetType, + object? parameter, + CultureInfo culture) + { + return AvaloniaProperty.UnsetValue; + } + } +} diff --git a/src/Avalonia.Controls.ColorPicker/Converters/ValueConverterGroup.cs b/src/Avalonia.Controls.ColorPicker/Converters/ValueConverterGroup.cs new file mode 100644 index 0000000000..2710c220f4 --- /dev/null +++ b/src/Avalonia.Controls.ColorPicker/Converters/ValueConverterGroup.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Avalonia.Data.Converters; + +namespace Avalonia.Controls.Primitives.Converters +{ + /// + /// Converter to chain together multiple converters. + /// + public class ValueConverterGroup : List, IValueConverter + { + /// + /// + public object? Convert( + object? value, + Type targetType, + object? parameter, + CultureInfo culture) + { + object? curValue; + + curValue = value; + for (int i = 0; i < Count; i++) + { + curValue = this[i].Convert(curValue, targetType, parameter, culture); + } + + return curValue; + } + + /// + public object? ConvertBack( + object? value, + Type targetType, + object? parameter, + CultureInfo culture) + { + object? curValue; + + curValue = value; + for (int i = (Count - 1); i >= 0; i--) + { + curValue = this[i].ConvertBack(curValue, targetType, parameter, culture); + } + + return curValue; + } + } +} diff --git a/src/Avalonia.Controls.ColorPicker/Helpers/ColorHelper.cs b/src/Avalonia.Controls.ColorPicker/Helpers/ColorHelper.cs new file mode 100644 index 0000000000..32a898ee71 --- /dev/null +++ b/src/Avalonia.Controls.ColorPicker/Helpers/ColorHelper.cs @@ -0,0 +1,142 @@ +using System; +using System.Globalization; +using System.Collections.Generic; +using Avalonia.Media; +using System.Text; + +namespace Avalonia.Controls.Primitives +{ + /// + /// Contains helpers useful when working with colors. + /// + public static class ColorHelper + { + private static readonly Dictionary cachedDisplayNames = new Dictionary(); + private static readonly object cacheMutex = new object(); + + /// + /// Gets the relative (perceptual) luminance/brightness of the given color. + /// 1 is closer to white while 0 is closer to black. + /// + /// The color to calculate relative luminance for. + /// The relative (perceptual) luminance/brightness of the given color. + public static double GetRelativeLuminance(Color color) + { + // The equation for relative luminance is given by + // + // L = 0.2126 * Rg + 0.7152 * Gg + 0.0722 * Bg + // + // where Xg = { X/3294 if X <= 10, (R/269 + 0.0513)^2.4 otherwise } + // + // If L is closer to 1, then the color is closer to white; if it is closer to 0, + // then the color is closer to black. This is based on the fact that the human + // eye perceives green to be much brighter than red, which in turn is perceived to be + // brighter than blue. + + double rg = color.R <= 10 ? color.R / 3294.0 : Math.Pow(color.R / 269.0 + 0.0513, 2.4); + double gg = color.G <= 10 ? color.G / 3294.0 : Math.Pow(color.G / 269.0 + 0.0513, 2.4); + double bg = color.B <= 10 ? color.B / 3294.0 : Math.Pow(color.B / 269.0 + 0.0513, 2.4); + + return (0.2126 * rg + 0.7152 * gg + 0.0722 * bg); + } + + /// + /// Determines if color display names are supported based on the current thread culture. + /// + /// + /// Only English names are currently supported following known color names. + /// In the future known color names could be localized. + /// + public static bool ToDisplayNameExists + { + get => CultureInfo.CurrentUICulture.Name.StartsWith("EN", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Determines an approximate display name for the given color. + /// + /// The color to get the display name for. + /// The approximate color display name. + public static string ToDisplayName(Color color) + { + // Without rounding, there are 16,777,216 possible RGB colors (without alpha). + // This is too many to cache and search through for performance reasons. + // It is also needlessly large as there are only ~140 known/named colors. + // Therefore, rounding of the input color's component values is done to + // reduce the color space into something more useful. + double rounding = 5; + var roundedColor = new Color( + 0xFF, + Convert.ToByte(Math.Round(color.R / rounding) * rounding), + Convert.ToByte(Math.Round(color.G / rounding) * rounding), + Convert.ToByte(Math.Round(color.B / rounding) * rounding)); + + // Attempt to use a previously cached display name + lock (cacheMutex) + { + if (cachedDisplayNames.TryGetValue(roundedColor, out var displayName)) + { + return displayName; + } + } + + // Find the closest known color by measuring 3D Euclidean distance (ignore alpha) + var closestKnownColor = KnownColor.None; + var closestKnownColorDistance = double.PositiveInfinity; + var knownColors = (KnownColor[])Enum.GetValues(typeof(KnownColor)); + + for (int i = 1; i < knownColors.Length; i++) // Skip 'None' + { + // Transparent is skipped since alpha is ignored making it equivalent to White + if (knownColors[i] != KnownColor.Transparent) + { + Color knownColor = KnownColors.ToColor(knownColors[i]); + + double distance = Math.Sqrt( + Math.Pow((double)(roundedColor.R - knownColor.R), 2.0) + + Math.Pow((double)(roundedColor.G - knownColor.G), 2.0) + + Math.Pow((double)(roundedColor.B - knownColor.B), 2.0)); + + if (distance < closestKnownColorDistance) + { + closestKnownColor = knownColors[i]; + closestKnownColorDistance = distance; + } + } + } + + // Return the closest known color as the display name + // Cache results for next time as well + if (closestKnownColor != KnownColor.None) + { + StringBuilder sb = new StringBuilder(); + string name = closestKnownColor.ToString(); + + // Add spaces converting PascalCase to human-readable names + for (int i = 0; i < name.Length; i++) + { + if (i != 0 && + char.IsUpper(name[i])) + { + sb.Append(' '); + } + + sb.Append(name[i]); + } + + string displayName = sb.ToString(); + + lock (cacheMutex) + { + cachedDisplayNames.Add(roundedColor, displayName); + } + + return displayName; + } + else + { + return string.Empty; + } + } + } +} diff --git a/src/Avalonia.Controls.ColorPicker/Helpers/ColorPickerHelpers.cs b/src/Avalonia.Controls.ColorPicker/Helpers/ColorPickerHelpers.cs new file mode 100644 index 0000000000..c1904a3c30 --- /dev/null +++ b/src/Avalonia.Controls.ColorPicker/Helpers/ColorPickerHelpers.cs @@ -0,0 +1,629 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using Avalonia.Utilities; + +namespace Avalonia.Controls.Primitives +{ + /// + /// Contains internal, special-purpose helpers used with the color picker. + /// + internal static class ColorPickerHelpers + { + /// + /// Generates a new bitmap of the specified size by changing a specific color component. + /// This will produce a gradient representing a sweep of all possible values of the color component. + /// + /// The pixel width (X, horizontal) of the resulting bitmap. + /// The pixel height (Y, vertical) of the resulting bitmap. + /// The orientation of the resulting bitmap (gradient direction). + /// The color model being used: RGBA or HSVA. + /// The specific color component to sweep. + /// The base HSV color used for components not being changed. + /// Fix the alpha component value to maximum during calculation. + /// This will remove any alpha/transparency from the other component backgrounds. + /// Fix the saturation and value components to maximum + /// during calculation with the HSVA color model. + /// This will ensure colors are always discernible regardless of saturation/value. + /// A new bitmap representing a gradient of color component values. + public static async Task CreateComponentBitmapAsync( + int width, + int height, + Orientation orientation, + ColorModel colorModel, + ColorComponent component, + HsvColor baseHsvColor, + bool isAlphaMaxForced, + bool isSaturationValueMaxForced) + { + if (width == 0 || height == 0) + { + return new byte[0]; + } + + var bitmap = await Task.Run(() => + { + int pixelDataIndex = 0; + double componentStep; + byte[] bgraPixelData; + Color baseRgbColor = Colors.White; + Color rgbColor; + int bgraPixelDataHeight; + int bgraPixelDataWidth; + + // Allocate the buffer + // BGRA formatted color components 1 byte each (4 bytes in a pixel) + bgraPixelData = new byte[width * height * 4]; + bgraPixelDataHeight = height * 4; + bgraPixelDataWidth = width * 4; + + // Maximize alpha component value + if (isAlphaMaxForced && + component != ColorComponent.Alpha) + { + baseHsvColor = new HsvColor(1.0, baseHsvColor.H, baseHsvColor.S, baseHsvColor.V); + } + + // Convert HSV to RGB once + if (colorModel == ColorModel.Rgba) + { + baseRgbColor = baseHsvColor.ToRgb(); + } + + // Maximize Saturation and Value components when in HSVA mode + if (isSaturationValueMaxForced && + colorModel == ColorModel.Hsva && + component != ColorComponent.Alpha) + { + switch (component) + { + case ColorComponent.Component1: + baseHsvColor = new HsvColor(baseHsvColor.A, baseHsvColor.H, 1.0, 1.0); + break; + case ColorComponent.Component2: + baseHsvColor = new HsvColor(baseHsvColor.A, baseHsvColor.H, baseHsvColor.S, 1.0); + break; + case ColorComponent.Component3: + baseHsvColor = new HsvColor(baseHsvColor.A, baseHsvColor.H, 1.0, baseHsvColor.V); + break; + } + } + + // Create the color component gradient + if (orientation == Orientation.Horizontal) + { + // Determine the numerical increment of the color steps within the component + if (colorModel == ColorModel.Hsva) + { + if (component == ColorComponent.Component1) + { + componentStep = 360.0 / width; + } + else + { + componentStep = 1.0 / width; + } + } + else + { + componentStep = 255.0 / width; + } + + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + if (y == 0) + { + rgbColor = GetColor(x * componentStep); + + // Get a new color + bgraPixelData[pixelDataIndex + 0] = Convert.ToByte(rgbColor.B * rgbColor.A / 255); + bgraPixelData[pixelDataIndex + 1] = Convert.ToByte(rgbColor.G * rgbColor.A / 255); + bgraPixelData[pixelDataIndex + 2] = Convert.ToByte(rgbColor.R * rgbColor.A / 255); + bgraPixelData[pixelDataIndex + 3] = rgbColor.A; + } + else + { + // Use the color in the row above + // Remember the pixel data is 1 dimensional instead of 2 + bgraPixelData[pixelDataIndex + 0] = bgraPixelData[pixelDataIndex + 0 - bgraPixelDataWidth]; + bgraPixelData[pixelDataIndex + 1] = bgraPixelData[pixelDataIndex + 1 - bgraPixelDataWidth]; + bgraPixelData[pixelDataIndex + 2] = bgraPixelData[pixelDataIndex + 2 - bgraPixelDataWidth]; + bgraPixelData[pixelDataIndex + 3] = bgraPixelData[pixelDataIndex + 3 - bgraPixelDataWidth]; + } + + pixelDataIndex += 4; + } + } + } + else + { + // Determine the numerical increment of the color steps within the component + if (colorModel == ColorModel.Hsva) + { + if (component == ColorComponent.Component1) + { + componentStep = 360.0 / height; + } + else + { + componentStep = 1.0 / height; + } + } + else + { + componentStep = 255.0 / height; + } + + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + if (x == 0) + { + // The lowest component value should be at the 'bottom' of the bitmap + rgbColor = GetColor((height - 1 - y) * componentStep); + + // Get a new color + bgraPixelData[pixelDataIndex + 0] = Convert.ToByte(rgbColor.B * rgbColor.A / 255); + bgraPixelData[pixelDataIndex + 1] = Convert.ToByte(rgbColor.G * rgbColor.A / 255); + bgraPixelData[pixelDataIndex + 2] = Convert.ToByte(rgbColor.R * rgbColor.A / 255); + bgraPixelData[pixelDataIndex + 3] = rgbColor.A; + } + else + { + // Use the color in the column to the left + // Remember the pixel data is 1 dimensional instead of 2 + bgraPixelData[pixelDataIndex + 0] = bgraPixelData[pixelDataIndex - 4]; + bgraPixelData[pixelDataIndex + 1] = bgraPixelData[pixelDataIndex - 3]; + bgraPixelData[pixelDataIndex + 2] = bgraPixelData[pixelDataIndex - 2]; + bgraPixelData[pixelDataIndex + 3] = bgraPixelData[pixelDataIndex - 1]; + } + + pixelDataIndex += 4; + } + } + } + + Color GetColor(double componentValue) + { + Color newRgbColor = Colors.White; + + switch (component) + { + case ColorComponent.Component1: + { + if (colorModel == ColorModel.Hsva) + { + // Sweep hue + newRgbColor = HsvColor.ToRgb( + MathUtilities.Clamp(componentValue, 0.0, 360.0), + baseHsvColor.S, + baseHsvColor.V, + baseHsvColor.A); + } + else + { + // Sweep red + newRgbColor = new Color( + baseRgbColor.A, + Convert.ToByte(MathUtilities.Clamp(componentValue, 0.0, 255.0)), + baseRgbColor.G, + baseRgbColor.B); + } + + break; + } + case ColorComponent.Component2: + { + if (colorModel == ColorModel.Hsva) + { + // Sweep saturation + newRgbColor = HsvColor.ToRgb( + baseHsvColor.H, + MathUtilities.Clamp(componentValue, 0.0, 1.0), + baseHsvColor.V, + baseHsvColor.A); + } + else + { + // Sweep green + newRgbColor = new Color( + baseRgbColor.A, + baseRgbColor.R, + Convert.ToByte(MathUtilities.Clamp(componentValue, 0.0, 255.0)), + baseRgbColor.B); + } + + break; + } + case ColorComponent.Component3: + { + if (colorModel == ColorModel.Hsva) + { + // Sweep value + newRgbColor = HsvColor.ToRgb( + baseHsvColor.H, + baseHsvColor.S, + MathUtilities.Clamp(componentValue, 0.0, 1.0), + baseHsvColor.A); + } + else + { + // Sweep blue + newRgbColor = new Color( + baseRgbColor.A, + baseRgbColor.R, + baseRgbColor.G, + Convert.ToByte(MathUtilities.Clamp(componentValue, 0.0, 255.0))); + } + + break; + } + case ColorComponent.Alpha: + { + if (colorModel == ColorModel.Hsva) + { + // Sweep alpha + newRgbColor = HsvColor.ToRgb( + baseHsvColor.H, + baseHsvColor.S, + baseHsvColor.V, + MathUtilities.Clamp(componentValue, 0.0, 1.0)); + } + else + { + // Sweep alpha + newRgbColor = new Color( + Convert.ToByte(MathUtilities.Clamp(componentValue, 0.0, 255.0)), + baseRgbColor.R, + baseRgbColor.G, + baseRgbColor.B); + } + + break; + } + } + + return newRgbColor; + } + + return bgraPixelData; + }); + + return bitmap; + } + + public static Hsv IncrementColorComponent( + Hsv originalHsv, + HsvComponent component, + IncrementDirection direction, + IncrementAmount amount, + bool shouldWrap, + double minBound, + double maxBound) + { + Hsv newHsv = originalHsv; + + if (amount == IncrementAmount.Small || !ColorHelper.ToDisplayNameExists) + { + // In order to avoid working with small values that can incur rounding issues, + // we'll multiple saturation and value by 100 to put them in the range of 0-100 instead of 0-1. + newHsv.S *= 100; + newHsv.V *= 100; + + // Note: *valueToIncrement replaced with ref local variable for C#, must be initialized + ref double valueToIncrement = ref newHsv.H; + double incrementAmount = 0.0; + + // If we're adding a small increment, then we'll just add or subtract 1. + // If we're adding a large increment, then we want to snap to the next + // or previous major value - for hue, this is every increment of 30; + // for saturation and value, this is every increment of 10. + switch (component) + { + case HsvComponent.Hue: + valueToIncrement = ref newHsv.H; + incrementAmount = amount == IncrementAmount.Small ? 1 : 30; + break; + + case HsvComponent.Saturation: + valueToIncrement = ref newHsv.S; + incrementAmount = amount == IncrementAmount.Small ? 1 : 10; + break; + + case HsvComponent.Value: + valueToIncrement = ref newHsv.V; + incrementAmount = amount == IncrementAmount.Small ? 1 : 10; + break; + + default: + throw new InvalidOperationException("Invalid HsvComponent."); + } + + double previousValue = valueToIncrement; + + valueToIncrement += (direction == IncrementDirection.Lower ? -incrementAmount : incrementAmount); + + // If the value has reached outside the bounds, we were previous at the boundary, and we should wrap, + // then we'll place the selection on the other side of the spectrum. + // Otherwise, we'll place it on the boundary that was exceeded. + if (valueToIncrement < minBound) + { + valueToIncrement = (shouldWrap && previousValue == minBound) ? maxBound : minBound; + } + + if (valueToIncrement > maxBound) + { + valueToIncrement = (shouldWrap && previousValue == maxBound) ? minBound : maxBound; + } + + // We multiplied saturation and value by 100 previously, so now we want to put them back in the 0-1 range. + newHsv.S /= 100; + newHsv.V /= 100; + } + else + { + // While working with named colors, we're going to need to be working in actual HSV units, + // so we'll divide the min bound and max bound by 100 in the case of saturation or value, + // since we'll have received units between 0-100 and we need them within 0-1. + if (component == HsvComponent.Saturation || + component == HsvComponent.Value) + { + minBound /= 100; + maxBound /= 100; + } + + newHsv = FindNextNamedColor(originalHsv, component, direction, shouldWrap, minBound, maxBound); + } + + return newHsv; + } + + public static Hsv FindNextNamedColor( + Hsv originalHsv, + HsvComponent component, + IncrementDirection direction, + bool shouldWrap, + double minBound, + double maxBound) + { + // There's no easy way to directly get the next named color, so what we'll do + // is just iterate in the direction that we want to find it until we find a color + // in that direction that has a color name different than our current color name. + // Once we find a new color name, then we'll iterate across that color name until + // we find its bounds on the other side, and then select the color that is exactly + // in the middle of that color's bounds. + Hsv newHsv = originalHsv; + + string originalColorName = ColorHelper.ToDisplayName(originalHsv.ToRgb().ToColor()); + string newColorName = originalColorName; + + // Note: *newValue replaced with ref local variable for C#, must be initialized + double originalValue = 0.0; + ref double newValue = ref newHsv.H; + double incrementAmount = 0.0; + + switch (component) + { + case HsvComponent.Hue: + originalValue = originalHsv.H; + newValue = ref newHsv.H; + incrementAmount = 1; + break; + + case HsvComponent.Saturation: + originalValue = originalHsv.S; + newValue = ref newHsv.S; + incrementAmount = 0.01; + break; + + case HsvComponent.Value: + originalValue = originalHsv.V; + newValue = ref newHsv.V; + incrementAmount = 0.01; + break; + + default: + throw new InvalidOperationException("Invalid HsvComponent."); + } + + bool shouldFindMidPoint = true; + + while (newColorName == originalColorName) + { + double previousValue = newValue; + newValue += (direction == IncrementDirection.Lower ? -1 : 1) * incrementAmount; + + bool justWrapped = false; + + // If we've hit a boundary, then either we should wrap or we shouldn't. + // If we should, then we'll perform that wrapping if we were previously up against + // the boundary that we've now hit. Otherwise, we'll stop at that boundary. + if (newValue > maxBound) + { + if (shouldWrap) + { + newValue = minBound; + justWrapped = true; + } + else + { + newValue = maxBound; + shouldFindMidPoint = false; + newColorName = ColorHelper.ToDisplayName(newHsv.ToRgb().ToColor()); + break; + } + } + else if (newValue < minBound) + { + if (shouldWrap) + { + newValue = maxBound; + justWrapped = true; + } + else + { + newValue = minBound; + shouldFindMidPoint = false; + newColorName = ColorHelper.ToDisplayName(newHsv.ToRgb().ToColor()); + break; + } + } + + if (!justWrapped && + previousValue != originalValue && + Math.Sign(newValue - originalValue) != Math.Sign(previousValue - originalValue)) + { + // If we've wrapped all the way back to the start and have failed to find a new color name, + // then we'll just quit - there isn't a new color name that we're going to find. + shouldFindMidPoint = false; + break; + } + + newColorName = ColorHelper.ToDisplayName(newHsv.ToRgb().ToColor()); + } + + if (shouldFindMidPoint) + { + Hsv startHsv = newHsv; + Hsv currentHsv = startHsv; + double startEndOffset = 0; + string currentColorName = newColorName; + + // Note: *startValue/*currentValue replaced with ref local variables for C#, must be initialized + ref double startValue = ref startHsv.H; + ref double currentValue = ref currentHsv.H; + double wrapIncrement = 0; + + switch (component) + { + case HsvComponent.Hue: + startValue = ref startHsv.H; + currentValue = ref currentHsv.H; + wrapIncrement = 360.0; + break; + + case HsvComponent.Saturation: + startValue = ref startHsv.S; + currentValue = ref currentHsv.S; + wrapIncrement = 1.0; + break; + + case HsvComponent.Value: + startValue = ref startHsv.V; + currentValue = ref currentHsv.V; + wrapIncrement = 1.0; + break; + + default: + throw new InvalidOperationException("Invalid HsvComponent."); + } + + while (newColorName == currentColorName) + { + currentValue += (direction == IncrementDirection.Lower ? -1 : 1) * incrementAmount; + + // If we've hit a boundary, then either we should wrap or we shouldn't. + // If we should, then we'll perform that wrapping if we were previously up against + // the boundary that we've now hit. Otherwise, we'll stop at that boundary. + if (currentValue > maxBound) + { + if (shouldWrap) + { + currentValue = minBound; + startEndOffset = maxBound - minBound; + } + else + { + currentValue = maxBound; + break; + } + } + else if (currentValue < minBound) + { + if (shouldWrap) + { + currentValue = maxBound; + startEndOffset = minBound - maxBound; + } + else + { + currentValue = minBound; + break; + } + } + + currentColorName = ColorHelper.ToDisplayName(currentHsv.ToRgb().ToColor()); + } + + newValue = (startValue + currentValue + startEndOffset) / 2; + + // Dividing by 2 may have gotten us halfway through a single step, so we'll + // remove that half-step if it exists. + double leftoverValue = Math.Abs(newValue); + + while (leftoverValue > incrementAmount) + { + leftoverValue -= incrementAmount; + } + + newValue -= leftoverValue; + + while (newValue < minBound) + { + newValue += wrapIncrement; + } + + while (newValue > maxBound) + { + newValue -= wrapIncrement; + } + } + + return newHsv; + } + + /// + /// Converts the given raw BGRA pre-multiplied alpha pixel data into a bitmap. + /// + /// The bitmap (in raw BGRA pre-multiplied alpha pixels). + /// The pixel width of the bitmap. + /// The pixel height of the bitmap. + /// A new . + public static WriteableBitmap CreateBitmapFromPixelData( + IList bgraPixelData, + int pixelWidth, + int pixelHeight) + { + // Standard may need to change on some devices + Vector dpi = new Vector(96, 96); + + var bitmap = new WriteableBitmap( + new PixelSize(pixelWidth, pixelHeight), + dpi, + PixelFormat.Bgra8888, + AlphaFormat.Premul); + + // Warning: This is highly questionable + using (var frameBuffer = bitmap.Lock()) + { + Marshal.Copy(bgraPixelData.ToArray(), 0, frameBuffer.Address, bgraPixelData.Count); + } + + return bitmap; + } + } +} diff --git a/src/Avalonia.Controls.ColorPicker/Helpers/Hsv.cs b/src/Avalonia.Controls.ColorPicker/Helpers/Hsv.cs new file mode 100644 index 0000000000..8a425b9581 --- /dev/null +++ b/src/Avalonia.Controls.ColorPicker/Helpers/Hsv.cs @@ -0,0 +1,86 @@ +// Portions of this source file are adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under the MIT License. + +using Avalonia.Media; + +namespace Avalonia.Controls.Primitives +{ + /// + /// Contains and allows modification of Hue, Saturation and Value components. + /// + /// + /// The is a specialized struct optimized for permanence and memory: + /// + /// This is not a read-only struct like and allows editing the fields + /// Removes the alpha component unnecessary in core calculations + /// No component bounds checks or clamping is done. + /// + /// + internal struct Hsv + { + /// + /// The Hue component in the range from 0..359. + /// + public double H; + + /// + /// The Saturation component in the range from 0..1. + /// + public double S; + + /// + /// The Value component in the range from 0..1. + /// + public double V; + + /// + /// Initializes a new instance of the struct. + /// + /// The Hue component in the range from 0..360. + /// The Saturation component in the range from 0..1. + /// The Value component in the range from 0..1. + public Hsv(double h, double s, double v) + { + H = h; + S = s; + V = v; + } + + /// + /// Initializes a new instance of the struct. + /// + /// An existing to convert to . + public Hsv(HsvColor hsvColor) + { + H = hsvColor.H; + S = hsvColor.S; + V = hsvColor.V; + } + + /// + /// Converts this struct into a standard . + /// + /// The Alpha component in the range from 0..1. + /// A new representing this struct. + public HsvColor ToHsvColor(double alpha = 1.0) + { + // Clamping is done automatically in the constructor + return HsvColor.FromAhsv(alpha, H, S, V); + } + + /// + /// Returns the color model equivalent of this color. + /// + /// The equivalent color. + public Rgb ToRgb() + { + // Instantiating a Color is unfortunately necessary to use existing conversions + // Clamping is done internally in the conversion method + Color color = HsvColor.ToRgb(H, S, V); + + return new Rgb(color); + } + } +} diff --git a/src/Avalonia.Controls.ColorPicker/Helpers/IncrementAmount.cs b/src/Avalonia.Controls.ColorPicker/Helpers/IncrementAmount.cs new file mode 100644 index 0000000000..12aca593d5 --- /dev/null +++ b/src/Avalonia.Controls.ColorPicker/Helpers/IncrementAmount.cs @@ -0,0 +1,23 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under the MIT License. + +namespace Avalonia.Controls.Primitives +{ + /// + /// Defines a relative amount that a color component should be incremented. + /// + internal enum IncrementAmount + { + /// + /// A smaller change in value. + /// + Small, + + /// + /// A larger change in value. + /// + Large, + }; +} diff --git a/src/Avalonia.Controls.ColorPicker/Helpers/IncrementDirection.cs b/src/Avalonia.Controls.ColorPicker/Helpers/IncrementDirection.cs new file mode 100644 index 0000000000..df9c1e3350 --- /dev/null +++ b/src/Avalonia.Controls.ColorPicker/Helpers/IncrementDirection.cs @@ -0,0 +1,23 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under the MIT License. + +namespace Avalonia.Controls.Primitives +{ + /// + /// Defines the direction a color component should be incremented. + /// + internal enum IncrementDirection + { + /// + /// Decreasing in value towards zero. + /// + Lower, + + /// + /// Increasing in value towards positive infinity. + /// + Higher, + }; +} diff --git a/src/Avalonia.Controls.ColorPicker/Helpers/Rgb.cs b/src/Avalonia.Controls.ColorPicker/Helpers/Rgb.cs new file mode 100644 index 0000000000..72e3821c2b --- /dev/null +++ b/src/Avalonia.Controls.ColorPicker/Helpers/Rgb.cs @@ -0,0 +1,94 @@ +// Portions of this source file are adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under the MIT License. + +using Avalonia.Media; +using Avalonia.Utilities; + +namespace Avalonia.Controls.Primitives +{ + /// + /// Contains and allows modification of Red, Green and Blue components. + /// + /// + /// The is a specialized struct optimized for permanence and memory: + /// + /// This is not a read-only struct like and allows editing the fields + /// Removes the alpha component unnecessary in core calculations + /// Normalizes RGB components in the range of 0..1 to simplify calculations. + /// No component bounds checks or clamping is done. + /// + /// + internal struct Rgb + { + /// + /// The Red component in the range from 0..1. + /// + public double R; + + /// + /// The Green component in the range from 0..1. + /// + public double G; + + /// + /// The Blue component in the range from 0..1. + /// + public double B; + + /// + /// Initializes a new instance of the struct. + /// + /// The Red component in the range from 0..1. + /// The Green component in the range from 0..1. + /// The Blue component in the range from 0..1. + public Rgb(double r, double g, double b) + { + R = r; + G = g; + B = b; + } + + /// + /// Initializes a new instance of the struct. + /// + /// An existing to convert to . + public Rgb(Color color) + { + R = color.R / 255.0; + G = color.G / 255.0; + B = color.B / 255.0; + } + + /// + /// Converts this struct into a standard . + /// + /// The Alpha component in the range from 0..1. + /// A new representing this struct. + public Color ToColor(double alpha = 1.0) + { + return Color.FromArgb( + (byte)MathUtilities.Clamp(alpha * 255.0, 0x00, 0xFF), + (byte)MathUtilities.Clamp(R * 255.0, 0x00, 0xFF), + (byte)MathUtilities.Clamp(G * 255.0, 0x00, 0xFF), + (byte)MathUtilities.Clamp(B * 255.0, 0x00, 0xFF)); + } + + /// + /// Returns the color model equivalent of this color. + /// + /// The equivalent color. + public Hsv ToHsv() + { + // Instantiating an HsvColor is unfortunately necessary to use existing conversions + // Clamping must be done here as it isn't done in the conversion method (internal-use only) + HsvColor hsvColor = Color.ToHsv( + MathUtilities.Clamp(R, 0.0, 1.0), + MathUtilities.Clamp(G, 0.0, 1.0), + MathUtilities.Clamp(B, 0.0, 1.0)); + + return new Hsv(hsvColor); + } + } +} diff --git a/src/Avalonia.Controls.ColorPicker/HsvComponent.cs b/src/Avalonia.Controls.ColorPicker/HsvComponent.cs new file mode 100644 index 0000000000..1a7a13166a --- /dev/null +++ b/src/Avalonia.Controls.ColorPicker/HsvComponent.cs @@ -0,0 +1,47 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under the MIT License. + +using Avalonia.Media; + +namespace Avalonia.Controls +{ + /// + /// Defines a specific component in the HSV color model. + /// + public enum HsvComponent + { + /// + /// The Alpha component. + /// + /// + /// Also see: + /// + Alpha = 0, + + /// + /// The Hue component. + /// + /// + /// Also see: + /// + Hue = 1, + + /// + /// The Saturation component. + /// + /// + /// Also see: + /// + Saturation = 2, + + /// + /// The Value component. + /// + /// + /// Also see: + /// + Value = 3 + }; +} diff --git a/src/Avalonia.Controls.ColorPicker/Properties/AssemblyInfo.cs b/src/Avalonia.Controls.ColorPicker/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..0135541349 --- /dev/null +++ b/src/Avalonia.Controls.ColorPicker/Properties/AssemblyInfo.cs @@ -0,0 +1,8 @@ +using System.Runtime.CompilerServices; +using Avalonia.Metadata; + +[assembly: InternalsVisibleTo("Avalonia.DesignerSupport, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c1bba1142285fe0419326fb25866ba62c47e6c2b5c1ab0c95b46413fad375471232cb81706932e1cef38781b9ebd39d5100401bacb651c6c5bbf59e571e81b3bc08d2a622004e08b1a6ece82a7e0b9857525c86d2b95fab4bc3dce148558d7f3ae61aa3a234086902aeface87d9dfdd32b9d2fe3c6dd4055b5ab4b104998bd87")] + +[assembly: XmlnsDefinition("https://github.com/avaloniaui", "Avalonia.Controls")] +[assembly: XmlnsDefinition("https://github.com/avaloniaui", "Avalonia.Controls.Collections")] +[assembly: XmlnsDefinition("https://github.com/avaloniaui", "Avalonia.Controls.Primitives")] diff --git a/src/Avalonia.Controls.ColorPicker/RgbComponent.cs b/src/Avalonia.Controls.ColorPicker/RgbComponent.cs new file mode 100644 index 0000000000..c3591573bb --- /dev/null +++ b/src/Avalonia.Controls.ColorPicker/RgbComponent.cs @@ -0,0 +1,42 @@ +using Avalonia.Media; + +namespace Avalonia.Controls +{ + /// + /// Defines a specific component in the RGB color model. + /// + public enum RgbComponent + { + /// + /// The Alpha component. + /// + /// + /// Also see: + /// + Alpha = 0, + + /// + /// The Red component. + /// + /// + /// Also see: + /// + Red = 1, + + /// + /// The Green component. + /// + /// + /// Also see: + /// + Green = 2, + + /// + /// The Blue component. + /// + /// + /// Also see: + /// + Blue = 3 + }; +} diff --git a/src/Avalonia.Controls.ColorPicker/Themes/Default/ColorPreviewer.xaml b/src/Avalonia.Controls.ColorPicker/Themes/Default/ColorPreviewer.xaml new file mode 100644 index 0000000000..15e5ca1655 --- /dev/null +++ b/src/Avalonia.Controls.ColorPicker/Themes/Default/ColorPreviewer.xaml @@ -0,0 +1,86 @@ + + + + + + + + + + + + diff --git a/src/Avalonia.Controls.ColorPicker/Themes/Default/ColorSlider.xaml b/src/Avalonia.Controls.ColorPicker/Themes/Default/ColorSlider.xaml new file mode 100644 index 0000000000..19f10201a5 --- /dev/null +++ b/src/Avalonia.Controls.ColorPicker/Themes/Default/ColorSlider.xaml @@ -0,0 +1,194 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.Controls.ColorPicker/Themes/Default/ColorSpectrum.xaml b/src/Avalonia.Controls.ColorPicker/Themes/Default/ColorSpectrum.xaml new file mode 100644 index 0000000000..78e6da8aa3 --- /dev/null +++ b/src/Avalonia.Controls.ColorPicker/Themes/Default/ColorSpectrum.xaml @@ -0,0 +1,134 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.Controls.ColorPicker/Themes/Default/Default.xaml b/src/Avalonia.Controls.ColorPicker/Themes/Default/Default.xaml new file mode 100644 index 0000000000..6d2f979f6e --- /dev/null +++ b/src/Avalonia.Controls.ColorPicker/Themes/Default/Default.xaml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.Controls.ColorPicker/Themes/Fluent/ColorPreviewer.xaml b/src/Avalonia.Controls.ColorPicker/Themes/Fluent/ColorPreviewer.xaml new file mode 100644 index 0000000000..cb764a738c --- /dev/null +++ b/src/Avalonia.Controls.ColorPicker/Themes/Fluent/ColorPreviewer.xaml @@ -0,0 +1,86 @@ + + + + + + + + + + + + diff --git a/src/Avalonia.Controls.ColorPicker/Themes/Fluent/ColorSlider.xaml b/src/Avalonia.Controls.ColorPicker/Themes/Fluent/ColorSlider.xaml new file mode 100644 index 0000000000..18a081721a --- /dev/null +++ b/src/Avalonia.Controls.ColorPicker/Themes/Fluent/ColorSlider.xaml @@ -0,0 +1,194 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.Controls.ColorPicker/Themes/Fluent/ColorSpectrum.xaml b/src/Avalonia.Controls.ColorPicker/Themes/Fluent/ColorSpectrum.xaml new file mode 100644 index 0000000000..ac8e2a9c06 --- /dev/null +++ b/src/Avalonia.Controls.ColorPicker/Themes/Fluent/ColorSpectrum.xaml @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.Controls.ColorPicker/Themes/Fluent/Fluent.xaml b/src/Avalonia.Controls.ColorPicker/Themes/Fluent/Fluent.xaml new file mode 100644 index 0000000000..c25d79727f --- /dev/null +++ b/src/Avalonia.Controls.ColorPicker/Themes/Fluent/Fluent.xaml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.Controls/Converters/CornerRadiusFilterConverter.cs b/src/Avalonia.Controls/Converters/CornerRadiusFilterConverter.cs index 643c30178e..a91f143019 100644 --- a/src/Avalonia.Controls/Converters/CornerRadiusFilterConverter.cs +++ b/src/Avalonia.Controls/Converters/CornerRadiusFilterConverter.cs @@ -1,27 +1,32 @@ using System; using System.Globalization; - using Avalonia.Data.Converters; namespace Avalonia.Controls.Converters { /// /// Converts an existing CornerRadius struct to a new CornerRadius struct, - /// with filters applied to extract only the specified fields, leaving the others set to 0. + /// with filters applied to extract only the specified corners, leaving the others set to 0. /// public class CornerRadiusFilterConverter : IValueConverter { /// - /// Gets or sets the type of the filter applied to the . + /// Gets or sets the corners to filter by. + /// Only the specified corners will be included in the converted . /// - public CornerRadiusFilterKinds Filter { get; set; } + public Corners Filter { get; set; } /// - /// Gets or sets the scale multiplier applied to the . + /// Gets or sets the scale multiplier applied uniformly to each corner. /// public double Scale { get; set; } = 1; - public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + /// + public object? Convert( + object? value, + Type targetType, + object? parameter, + CultureInfo culture) { if (!(value is CornerRadius radius)) { @@ -29,13 +34,18 @@ namespace Avalonia.Controls.Converters } return new CornerRadius( - Filter.HasAllFlags(CornerRadiusFilterKinds.TopLeft) ? radius.TopLeft * Scale : 0, - Filter.HasAllFlags(CornerRadiusFilterKinds.TopRight) ? radius.TopRight * Scale : 0, - Filter.HasAllFlags(CornerRadiusFilterKinds.BottomRight) ? radius.BottomRight * Scale : 0, - Filter.HasAllFlags(CornerRadiusFilterKinds.BottomLeft) ? radius.BottomLeft * Scale : 0); + Filter.HasAllFlags(Corners.TopLeft) ? radius.TopLeft * Scale : 0, + Filter.HasAllFlags(Corners.TopRight) ? radius.TopRight * Scale : 0, + Filter.HasAllFlags(Corners.BottomRight) ? radius.BottomRight * Scale : 0, + Filter.HasAllFlags(Corners.BottomLeft) ? radius.BottomLeft * Scale : 0); } - public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + /// + public object? ConvertBack( + object? value, + Type targetType, + object? parameter, + CultureInfo culture) { throw new NotImplementedException(); } diff --git a/src/Avalonia.Controls/Converters/CornerRadiusToDoubleConverter.cs b/src/Avalonia.Controls/Converters/CornerRadiusToDoubleConverter.cs new file mode 100644 index 0000000000..6da15b61e6 --- /dev/null +++ b/src/Avalonia.Controls/Converters/CornerRadiusToDoubleConverter.cs @@ -0,0 +1,46 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; + +namespace Avalonia.Controls.Converters +{ + /// + /// Converts one corner of a to its double value. + /// + public class CornerRadiusToDoubleConverter : IValueConverter + { + /// + /// Gets or sets the specific corner of the to convert to double. + /// + public Corners Corner { get; set; } + + /// + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (!(value is CornerRadius cornerRadius)) + { + return AvaloniaProperty.UnsetValue; + } + + switch (Corner) + { + case Corners.TopLeft: + return cornerRadius.TopLeft; + case Corners.TopRight: + return cornerRadius.TopRight; + case Corners.BottomRight: + return cornerRadius.BottomRight; + case Corners.BottomLeft: + return cornerRadius.BottomLeft; + default: + return 0.0; + } + } + + /// + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/Avalonia.Controls/Converters/CornerRadiusFilterKind.cs b/src/Avalonia.Controls/Converters/Corners.cs similarity index 57% rename from src/Avalonia.Controls/Converters/CornerRadiusFilterKind.cs rename to src/Avalonia.Controls/Converters/Corners.cs index 6a9d0596be..22ac4cb5f9 100644 --- a/src/Avalonia.Controls/Converters/CornerRadiusFilterKind.cs +++ b/src/Avalonia.Controls/Converters/Corners.cs @@ -3,29 +3,33 @@ namespace Avalonia.Controls.Converters { /// - /// Defines constants that specify the filter type for a instance. + /// Defines constants that specify one or more corners of a . /// [Flags] - public enum CornerRadiusFilterKinds + public enum Corners { /// - /// No filter applied. + /// No corner. /// None, + /// - /// Filters TopLeft value. + /// The TopLeft corner. /// TopLeft = 1, + /// - /// Filters TopRight value. + /// The TopRight corner. /// TopRight = 2, + /// - /// Filters BottomLeft value. + /// The BottomLeft corner. /// BottomLeft = 4, + /// - /// Filters BottomRight value. + /// The BottomRight corner. /// BottomRight = 8 } diff --git a/src/Avalonia.Controls/Converters/EnumValueEqualsConverter.cs b/src/Avalonia.Controls/Converters/EnumValueEqualsConverter.cs new file mode 100644 index 0000000000..1a33a82ca4 --- /dev/null +++ b/src/Avalonia.Controls/Converters/EnumValueEqualsConverter.cs @@ -0,0 +1,54 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; + +namespace Avalonia.Controls.Converters +{ + /// + /// Converter that checks if an enum value is equal to the given parameter enum value. + /// + public class EnumValueEqualsConverter : IValueConverter + { + /// + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + // Note: Unlike string comparisons, null/empty is not supported + // Both 'value' and 'parameter' must exist and if both are missing they are not considered equal + if (value != null && + parameter != null) + { + Type type = value.GetType(); + + if (type.IsEnum) + { + var valueStr = value?.ToString(); + var paramStr = parameter?.ToString(); + + if (string.Equals(valueStr, paramStr, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + /* + // TODO: When .net Standard 2.0 is no longer supported the code can be changed to below + // This is a little more type safe + if (type.IsEnum && + Enum.TryParse(type, value?.ToString(), true, out object? valueEnum) && + Enum.TryParse(type, parameter?.ToString(), true, out object? paramEnum)) + { + return valueEnum == paramEnum; + } + */ + } + + return false; + } + + /// + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new System.NotImplementedException(); + } + } +} diff --git a/src/Avalonia.Controls/Converters/MarginMultiplierConverter.cs b/src/Avalonia.Controls/Converters/MarginMultiplierConverter.cs index b0c30ea11f..7931b63d8e 100644 --- a/src/Avalonia.Controls/Converters/MarginMultiplierConverter.cs +++ b/src/Avalonia.Controls/Converters/MarginMultiplierConverter.cs @@ -35,7 +35,6 @@ namespace Avalonia.Controls.Converters Bottom ? Indent * thicknessDepth.Bottom : 0); } return new Thickness(0); - } public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) diff --git a/src/Avalonia.Controls/Documents/IInlineHost.cs b/src/Avalonia.Controls/Documents/IInlineHost.cs new file mode 100644 index 0000000000..da72c207be --- /dev/null +++ b/src/Avalonia.Controls/Documents/IInlineHost.cs @@ -0,0 +1,11 @@ +using Avalonia.LogicalTree; + +namespace Avalonia.Controls.Documents +{ + internal interface IInlineHost : ILogical + { + void AddVisualChild(IControl child); + + void Invalidate(); + } +} diff --git a/src/Avalonia.Controls/Documents/Inline.cs b/src/Avalonia.Controls/Documents/Inline.cs index fdd78459c8..b400625903 100644 --- a/src/Avalonia.Controls/Documents/Inline.cs +++ b/src/Avalonia.Controls/Documents/Inline.cs @@ -2,9 +2,8 @@ using System.Collections.Generic; using System.Text; using Avalonia.Media; using Avalonia.Media.TextFormatting; -using Avalonia.Utilities; -namespace Avalonia.Controls.Documents +namespace Avalonia.Controls.Documents { /// /// Inline element. @@ -45,9 +44,9 @@ namespace Avalonia.Controls.Documents set { SetValue(BaselineAlignmentProperty, value); } } - internal abstract int BuildRun(StringBuilder stringBuilder, IList> textStyleOverrides, int firstCharacterIndex); + internal abstract void BuildTextRun(IList textRuns); - internal abstract int AppendText(StringBuilder stringBuilder); + internal abstract void AppendText(StringBuilder stringBuilder); protected TextRunProperties CreateTextRunProperties() { @@ -63,7 +62,7 @@ namespace Avalonia.Controls.Documents { case nameof(TextDecorations): case nameof(BaselineAlignment): - Invalidate(); + InlineHost?.Invalidate(); break; } } diff --git a/src/Avalonia.Controls/Documents/InlineCollection.cs b/src/Avalonia.Controls/Documents/InlineCollection.cs index 45c715c13a..a76222385e 100644 --- a/src/Avalonia.Controls/Documents/InlineCollection.cs +++ b/src/Avalonia.Controls/Documents/InlineCollection.cs @@ -12,29 +12,37 @@ namespace Avalonia.Controls.Documents [WhitespaceSignificantCollection] public class InlineCollection : AvaloniaList { + private readonly IInlineHost? _host; private string? _text = string.Empty; /// /// Initializes a new instance of the class. /// - public InlineCollection(ILogical parent) : base(0) + public InlineCollection(ILogical parent) : this(parent, null) { } + + /// + /// Initializes a new instance of the class. + /// + internal InlineCollection(ILogical parent, IInlineHost? host = null) : base(0) { + _host = host; + ResetBehavior = ResetBehavior.Remove; this.ForEachItem( x => { ((ISetLogicalParent)x).SetParent(parent); - x.Invalidated += Invalidate; - Invalidate(); + x.InlineHost = host; + host?.Invalidate(); }, x => { ((ISetLogicalParent)x).SetParent(null); - x.Invalidated -= Invalidate; - Invalidate(); + x.InlineHost = host; + host?.Invalidate(); }, - () => throw new NotSupportedException()); + () => throw new NotSupportedException()); } public bool HasComplexContent => Count > 0; @@ -96,12 +104,22 @@ namespace Avalonia.Controls.Documents } } + public void Add(IControl child) + { + var implicitRun = new InlineUIContainer(child); + + Add(implicitRun); + } + public override void Add(Inline item) { if (!HasComplexContent) { - base.Add(new Run(_text)); - + if (!string.IsNullOrEmpty(_text)) + { + base.Add(new Run(_text)); + } + _text = string.Empty; } @@ -112,11 +130,19 @@ namespace Avalonia.Controls.Documents /// Raised when an inline in the collection changes. /// public event EventHandler? Invalidated; - + /// /// Raises the event. /// - protected void Invalidate() => Invalidated?.Invoke(this, EventArgs.Empty); + protected void Invalidate() + { + if(_host != null) + { + _host.Invalidate(); + } + + Invalidated?.Invoke(this, EventArgs.Empty); + } private void Invalidate(object? sender, EventArgs e) => Invalidate(); } diff --git a/src/Avalonia.Controls/Documents/InlineUIContainer.cs b/src/Avalonia.Controls/Documents/InlineUIContainer.cs new file mode 100644 index 0000000000..5f08c23099 --- /dev/null +++ b/src/Avalonia.Controls/Documents/InlineUIContainer.cs @@ -0,0 +1,125 @@ +using System.Collections.Generic; +using System.Text; +using Avalonia.Media; +using Avalonia.Media.TextFormatting; +using Avalonia.Metadata; +using Avalonia.Utilities; + +namespace Avalonia.Controls.Documents +{ + /// + /// InlineUIContainer - a wrapper for embedded UIElements in text + /// flow content inline collections + /// + public class InlineUIContainer : Inline + { + /// + /// Defines the property. + /// + public static readonly StyledProperty ChildProperty = + AvaloniaProperty.Register(nameof(Child)); + + static InlineUIContainer() + { + BaselineAlignmentProperty.OverrideDefaultValue(BaselineAlignment.Top); + } + + /// + /// Initializes a new instance of InlineUIContainer element. + /// + /// + /// The purpose of this element is to be a wrapper for UIElements + /// when they are embedded into text flow - as items of + /// InlineCollections. + /// + public InlineUIContainer() + { + } + + /// + /// Initializes an InlineBox specifying its child UIElement + /// + /// + /// UIElement set as a child of this inline item + /// + public InlineUIContainer(IControl child) + { + Child = child; + } + + /// + /// The content spanned by this TextElement. + /// + [Content] + public IControl Child + { + get => GetValue(ChildProperty); + set => SetValue(ChildProperty, value); + } + + internal override void BuildTextRun(IList textRuns) + { + if(InlineHost == null) + { + return; + } + + ((ISetLogicalParent)Child).SetParent(InlineHost); + + InlineHost.AddVisualChild(Child); + + textRuns.Add(new InlineRun(Child, CreateTextRunProperties())); + } + + internal override void AppendText(StringBuilder stringBuilder) + { + } + + private class InlineRun : DrawableTextRun + { + public InlineRun(IControl control, TextRunProperties properties) + { + Control = control; + Properties = properties; + } + + public IControl Control { get; } + + public override TextRunProperties? Properties { get; } + + public override Size Size + { + get + { + if (!Control.IsMeasureValid) + { + Control.Measure(Size.Infinity); + } + + return Control.DesiredSize; + } + } + + public override double Baseline + { + get + { + double baseline = Size.Height; + double baselineOffsetValue = Control.GetValue(TextBlock.BaselineOffsetProperty); + + if (!MathUtilities.IsZero(baselineOffsetValue)) + { + baseline = baselineOffsetValue; + } + + return -baseline; + } + } + + public override void Draw(DrawingContext drawingContext, Point origin) + { + Control.Arrange(new Rect(origin, Size)); + } + } + } +} diff --git a/src/Avalonia.Controls/Documents/LineBreak.cs b/src/Avalonia.Controls/Documents/LineBreak.cs index 5e0cd1d387..aeb81f7313 100644 --- a/src/Avalonia.Controls/Documents/LineBreak.cs +++ b/src/Avalonia.Controls/Documents/LineBreak.cs @@ -1,9 +1,9 @@ using System; using System.Collections.Generic; using System.Text; +using Avalonia.LogicalTree; using Avalonia.Media.TextFormatting; using Avalonia.Metadata; -using Avalonia.Utilities; namespace Avalonia.Controls.Documents { @@ -20,24 +20,14 @@ namespace Avalonia.Controls.Documents { } - internal override int BuildRun(StringBuilder stringBuilder, - IList> textStyleOverrides, int firstCharacterIndex) + internal override void BuildTextRun(IList textRuns) { - var length = AppendText(stringBuilder); - - textStyleOverrides.Add(new ValueSpan(firstCharacterIndex, length, - CreateTextRunProperties())); - - return length; + textRuns.Add(new TextEndOfLine()); } - internal override int AppendText(StringBuilder stringBuilder) + internal override void AppendText(StringBuilder stringBuilder) { - var text = Environment.NewLine; - - stringBuilder.Append(text); - - return text.Length; + stringBuilder.Append(Environment.NewLine); } } } diff --git a/src/Avalonia.Controls/Documents/Run.cs b/src/Avalonia.Controls/Documents/Run.cs index 2f9ba013ed..2bd66b8a64 100644 --- a/src/Avalonia.Controls/Documents/Run.cs +++ b/src/Avalonia.Controls/Documents/Run.cs @@ -4,7 +4,6 @@ using System.Text; using Avalonia.Data; using Avalonia.Media.TextFormatting; using Avalonia.Metadata; -using Avalonia.Utilities; namespace Avalonia.Controls.Documents { @@ -51,24 +50,22 @@ namespace Avalonia.Controls.Documents set { SetValue (TextProperty, value); } } - internal override int BuildRun(StringBuilder stringBuilder, - IList> textStyleOverrides, int firstCharacterIndex) + internal override void BuildTextRun(IList textRuns) { - var length = AppendText(stringBuilder); + var text = (Text ?? "").AsMemory(); - textStyleOverrides.Add(new ValueSpan(firstCharacterIndex, length, - CreateTextRunProperties())); + var textRunProperties = CreateTextRunProperties(); - return length; + var textCharacters = new TextCharacters(text, textRunProperties); + + textRuns.Add(textCharacters); } - internal override int AppendText(StringBuilder stringBuilder) + internal override void AppendText(StringBuilder stringBuilder) { var text = Text ?? ""; stringBuilder.Append(text); - - return text.Length; } protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) @@ -78,7 +75,7 @@ namespace Avalonia.Controls.Documents switch (change.Property.Name) { case nameof(Text): - Invalidate(); + InlineHost?.Invalidate(); break; } } diff --git a/src/Avalonia.Controls/Documents/Span.cs b/src/Avalonia.Controls/Documents/Span.cs index c086997b07..bd1b4fc5e1 100644 --- a/src/Avalonia.Controls/Documents/Span.cs +++ b/src/Avalonia.Controls/Documents/Span.cs @@ -1,8 +1,8 @@ +using System; using System.Collections.Generic; using System.Text; using Avalonia.Media.TextFormatting; using Avalonia.Metadata; -using Avalonia.Utilities; namespace Avalonia.Controls.Documents { @@ -25,8 +25,7 @@ namespace Avalonia.Controls.Documents public Span() { Inlines = new InlineCollection(this); - - Inlines.Invalidated += (s, e) => Invalidate(); + Inlines.Invalidated += (s, e) => InlineHost?.Invalidate(); } /// @@ -35,61 +34,42 @@ namespace Avalonia.Controls.Documents [Content] public InlineCollection Inlines { get; } - internal override int BuildRun(StringBuilder stringBuilder, IList> textStyleOverrides, int firstCharacterIndex) + internal override void BuildTextRun(IList textRuns) { - var length = 0; - if (Inlines.HasComplexContent) { foreach (var inline in Inlines) { - var inlineLength = inline.BuildRun(stringBuilder, textStyleOverrides, firstCharacterIndex); - - firstCharacterIndex += inlineLength; - - length += inlineLength; + inline.BuildTextRun(textRuns); } } else { - if (Inlines.Text == null) + if (Inlines.Text is string text) { - return length; - } - - stringBuilder.Append(Inlines.Text); + var textRunProperties = CreateTextRunProperties(); - length = Inlines.Text.Length; + var textCharacters = new TextCharacters(text.AsMemory(), textRunProperties); - textStyleOverrides.Add(new ValueSpan(firstCharacterIndex, length, - CreateTextRunProperties())); + textRuns.Add(textCharacters); + } } - - return length; } - internal override int AppendText(StringBuilder stringBuilder) + internal override void AppendText(StringBuilder stringBuilder) { if (Inlines.HasComplexContent) { - var length = 0; - foreach (var inline in Inlines) { - length += inline.AppendText(stringBuilder); + inline.AppendText(stringBuilder); } - - return length; } - if (Inlines.Text == null) + if (Inlines.Text is string text) { - return 0; + stringBuilder.Append(text); } - - stringBuilder.Append(Inlines.Text); - - return Inlines.Text.Length; } } } diff --git a/src/Avalonia.Controls/Documents/TextElement.cs b/src/Avalonia.Controls/Documents/TextElement.cs index 450aafbfaf..f228519e60 100644 --- a/src/Avalonia.Controls/Documents/TextElement.cs +++ b/src/Avalonia.Controls/Documents/TextElement.cs @@ -1,5 +1,4 @@ -using System; -using Avalonia.Media; +using Avalonia.Media; namespace Avalonia.Controls.Documents { @@ -251,10 +250,7 @@ namespace Avalonia.Controls.Documents control.SetValue(ForegroundProperty, value); } - /// - /// Raised when the visual representation of the text element changes. - /// - public event EventHandler? Invalidated; + internal IInlineHost? InlineHost { get; set; } protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { @@ -269,14 +265,9 @@ namespace Avalonia.Controls.Documents case nameof(FontWeight): case nameof(FontStretch): case nameof(Foreground): - Invalidate(); + InlineHost?.Invalidate(); break; } } - - /// - /// Raises the event. - /// - protected void Invalidate() => Invalidated?.Invoke(this, EventArgs.Empty); } } diff --git a/src/Avalonia.Controls/ItemsControl.cs b/src/Avalonia.Controls/ItemsControl.cs index 256160a116..56b0014c05 100644 --- a/src/Avalonia.Controls/ItemsControl.cs +++ b/src/Avalonia.Controls/ItemsControl.cs @@ -518,7 +518,7 @@ namespace Avalonia.Controls } c = result; - } while (c != null && c != from); + } while (c != null && c != from && direction != NavigationDirection.First && direction != NavigationDirection.Last); return null; } diff --git a/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs b/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs index 6e9ac537f1..2f9bf0ac06 100644 --- a/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs +++ b/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs @@ -417,7 +417,7 @@ namespace Avalonia.Controls.Platform protected internal virtual void MenuOpened(object? sender, RoutedEventArgs e) { - if (e.Source == Menu) + if (e.Source is Menu) { Menu?.MoveSelection(NavigationDirection.First, true); } diff --git a/src/Avalonia.Controls/Presenters/TextPresenter.cs b/src/Avalonia.Controls/Presenters/TextPresenter.cs index d127866640..db1bbdbc6c 100644 --- a/src/Avalonia.Controls/Presenters/TextPresenter.cs +++ b/src/Avalonia.Controls/Presenters/TextPresenter.cs @@ -662,7 +662,7 @@ namespace Avalonia.Controls.Presenters caretIndex = characterHit.FirstCharacterIndex + characterHit.TrailingLength; - if (textLine.NewLineLength > 0 && caretIndex == textLine.FirstTextSourceIndex + textLine.Length) + if (textLine.TrailingWhitespaceLength > 0 && caretIndex == textLine.FirstTextSourceIndex + textLine.Length) { characterHit = new CharacterHit(caretIndex); } diff --git a/src/Avalonia.Controls/SplitButton/SplitButton.cs b/src/Avalonia.Controls/SplitButton/SplitButton.cs index 37cdefd4e5..f39064435d 100644 --- a/src/Avalonia.Controls/SplitButton/SplitButton.cs +++ b/src/Avalonia.Controls/SplitButton/SplitButton.cs @@ -228,6 +228,8 @@ namespace Avalonia.Controls /// protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { + base.OnApplyTemplate(e); + UnregisterEvents(); UnregisterFlyoutEvents(Flyout); diff --git a/src/Avalonia.Controls/TextBlock.cs b/src/Avalonia.Controls/TextBlock.cs index 3b8842fa0e..c04a62008b 100644 --- a/src/Avalonia.Controls/TextBlock.cs +++ b/src/Avalonia.Controls/TextBlock.cs @@ -14,7 +14,7 @@ namespace Avalonia.Controls /// /// A control that displays a block of text. /// - public class TextBlock : Control + public class TextBlock : Control, IInlineHost { /// /// Defines the property. @@ -155,9 +155,7 @@ namespace Avalonia.Controls /// public TextBlock() { - Inlines = new InlineCollection(this); - - Inlines.Invalidated += InlinesChanged; + Inlines = new InlineCollection(this, this); } /// @@ -211,7 +209,7 @@ namespace Avalonia.Controls } /// - /// Gets or sets the inlines. + /// Gets the inlines. /// [Content] public InlineCollection Inlines { get; } @@ -552,38 +550,41 @@ namespace Avalonia.Controls /// A object. protected virtual TextLayout CreateTextLayout(Size constraint, string? text) { - List>? textStyleOverrides = null; + var defaultProperties = new GenericTextRunProperties( + new Typeface(FontFamily, FontStyle, FontWeight, FontStretch), + FontSize, + TextDecorations, + Foreground); + + var paragraphProperties = new GenericTextParagraphProperties(FlowDirection, TextAlignment, true, false, + defaultProperties, TextWrapping, LineHeight, 0); + + ITextSource textSource; if (Inlines.HasComplexContent) { - textStyleOverrides = new List>(Inlines.Count); - - var textPosition = 0; - var stringBuilder = new StringBuilder(); + var textRuns = new List(); foreach (var inline in Inlines) { - textPosition += inline.BuildRun(stringBuilder, textStyleOverrides, textPosition); + inline.BuildTextRun(textRuns); } - text = stringBuilder.ToString(); + textSource = new InlinesTextSource(textRuns); + } + else + { + textSource = new SimpleTextSource((text ?? "").AsMemory(), defaultProperties); } return new TextLayout( - text ?? string.Empty, - new Typeface(FontFamily, FontStyle, FontWeight, FontStretch), - FontSize, - Foreground ?? Brushes.Transparent, - TextAlignment, - TextWrapping, + textSource, + paragraphProperties, TextTrimming, - TextDecorations, - FlowDirection, constraint.Width, constraint.Height, maxLines: MaxLines, - lineHeight: LineHeight, - textStyleOverrides: textStyleOverrides); + lineHeight: LineHeight); } /// @@ -592,7 +593,7 @@ namespace Avalonia.Controls protected void InvalidateTextLayout() { _textLayout = null; - + InvalidateMeasure(); } @@ -604,9 +605,9 @@ namespace Avalonia.Controls } var padding = Padding; - + _constraint = availableSize.Deflate(padding); - + _textLayout = null; InvalidateArrange(); @@ -622,9 +623,13 @@ namespace Avalonia.Controls { return finalSize; } - - _constraint = new Size(finalSize.Width, Math.Ceiling(finalSize.Height)); - + + var padding = Padding; + + var textSize = finalSize.Deflate(padding); + + _constraint = new Size(textSize.Width, Math.Ceiling(textSize.Height)); + _textLayout = null; return finalSize; @@ -660,8 +665,6 @@ namespace Avalonia.Controls case nameof (Padding): case nameof (LineHeight): case nameof (MaxLines): - - case nameof (InlinesProperty): case nameof (Text): case nameof (TextDecorations): @@ -673,9 +676,83 @@ namespace Avalonia.Controls } } - private void InlinesChanged(object? sender, EventArgs e) + private void InlinesChanged(object? sender, EventArgs e) + { + InvalidateTextLayout(); + } + + void IInlineHost.AddVisualChild(IControl child) + { + if (child.VisualParent == null) + { + VisualChildren.Add(child); + } + } + + void IInlineHost.Invalidate() { InvalidateTextLayout(); } + + private readonly struct InlinesTextSource : ITextSource + { + private readonly IReadOnlyList _textRuns; + + public InlinesTextSource(IReadOnlyList textRuns) + { + _textRuns = textRuns; + } + + public TextRun? GetTextRun(int textSourceIndex) + { + var currentPosition = 0; + + foreach (var textRun in _textRuns) + { + if(textRun.TextSourceLength == 0) + { + continue; + } + + if(currentPosition >= textSourceIndex) + { + return textRun; + } + + currentPosition += textRun.TextSourceLength; + } + + return null; + } + } + + private readonly struct SimpleTextSource : ITextSource + { + private readonly ReadOnlySlice _text; + private readonly TextRunProperties _defaultProperties; + + public SimpleTextSource(ReadOnlySlice text, TextRunProperties defaultProperties) + { + _text = text; + _defaultProperties = defaultProperties; + } + + public TextRun? GetTextRun(int textSourceIndex) + { + if (textSourceIndex > _text.Length) + { + return null; + } + + var runText = _text.Skip(textSourceIndex); + + if (runText.IsEmpty) + { + return null; + } + + return new TextCharacters(runText, _defaultProperties); + } + } } } diff --git a/src/Avalonia.Controls/Viewbox.cs b/src/Avalonia.Controls/Viewbox.cs index dd74d549bd..33a05f126d 100644 --- a/src/Avalonia.Controls/Viewbox.cs +++ b/src/Avalonia.Controls/Viewbox.cs @@ -14,7 +14,7 @@ namespace Avalonia.Controls /// Defines the property. /// public static readonly StyledProperty StretchProperty = - AvaloniaProperty.Register(nameof(Stretch), Stretch.Uniform); + AvaloniaProperty.Register(nameof(Stretch), Stretch.Uniform); /// /// Defines the property. diff --git a/src/Avalonia.Diagnostics/Avalonia.Diagnostics.csproj b/src/Avalonia.Diagnostics/Avalonia.Diagnostics.csproj index 2fb7c07b6f..adddf3f57b 100644 --- a/src/Avalonia.Diagnostics/Avalonia.Diagnostics.csproj +++ b/src/Avalonia.Diagnostics/Avalonia.Diagnostics.csproj @@ -13,6 +13,7 @@ + diff --git a/src/Avalonia.Themes.Default/Controls/DataValidationErrors.xaml b/src/Avalonia.Themes.Default/Controls/DataValidationErrors.xaml index a3a4cf4662..d7bf4bbbf1 100644 --- a/src/Avalonia.Themes.Default/Controls/DataValidationErrors.xaml +++ b/src/Avalonia.Themes.Default/Controls/DataValidationErrors.xaml @@ -1,9 +1,13 @@ - + diff --git a/src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml b/src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml index b36e0c7297..5b217e4764 100644 --- a/src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml @@ -61,8 +61,8 @@ - - + + diff --git a/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/StaticResourceExtension.cs b/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/StaticResourceExtension.cs index db33b88cc3..add97a660b 100644 --- a/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/StaticResourceExtension.cs +++ b/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/StaticResourceExtension.cs @@ -39,17 +39,11 @@ namespace Avalonia.Markup.Xaml.MarkupExtensions targetType = setter.Property.PropertyType; } - // Look upwards though the ambient context for IResourceHosts and IResourceProviders + // Look upwards though the ambient context for IResourceNodes // which might be able to give us the resource. - foreach (var e in stack.Parents) + foreach (var parent in stack.Parents) { - object value; - - if (e is IResourceHost host && host.TryGetResource(ResourceKey, out value)) - { - return ColorToBrushConverter.Convert(value, targetType); - } - else if (e is IResourceProvider provider && provider.TryGetResource(ResourceKey, out value)) + if (parent is IResourceNode node && node.TryGetResource(ResourceKey, out var value)) { return ColorToBrushConverter.Convert(value, targetType); } @@ -58,7 +52,11 @@ namespace Avalonia.Markup.Xaml.MarkupExtensions if (provideTarget.TargetObject is IControl target && provideTarget.TargetProperty is PropertyInfo property) { - DelayedBinding.Add(target, property, x => GetValue(x, targetType)); + // This is stored locally to avoid allocating closure in the outer scope. + var localTargetType = targetType; + var localInstance = this; + + DelayedBinding.Add(target, property, x => localInstance.GetValue(x, localTargetType)); return AvaloniaProperty.UnsetValue; } diff --git a/src/Markup/Avalonia.Markup.Xaml/Styling/StyleInclude.cs b/src/Markup/Avalonia.Markup.Xaml/Styling/StyleInclude.cs index 607b552c28..fa4a27fc50 100644 --- a/src/Markup/Avalonia.Markup.Xaml/Styling/StyleInclude.cs +++ b/src/Markup/Avalonia.Markup.Xaml/Styling/StyleInclude.cs @@ -60,7 +60,7 @@ namespace Avalonia.Markup.Xaml.Styling } } - bool IResourceNode.HasResources => (Loaded as IResourceProvider)?.HasResources ?? false; + bool IResourceNode.HasResources => Loaded?.HasResources ?? false; IReadOnlyList IStyle.Children => _loaded ?? Array.Empty(); @@ -86,9 +86,9 @@ namespace Avalonia.Markup.Xaml.Styling public bool TryGetResource(object key, out object? value) { - if (!_isLoading && Loaded is IResourceProvider p) + if (!_isLoading) { - return p.TryGetResource(key, out value); + return Loaded.TryGetResource(key, out value); } value = null; diff --git a/src/Skia/Avalonia.Skia/TextShaperImpl.cs b/src/Skia/Avalonia.Skia/TextShaperImpl.cs index a0890262e7..ebaa247da8 100644 --- a/src/Skia/Avalonia.Skia/TextShaperImpl.cs +++ b/src/Skia/Avalonia.Skia/TextShaperImpl.cs @@ -1,6 +1,5 @@ using System; using System.Globalization; -using Avalonia.Media; using Avalonia.Media.TextFormatting; using Avalonia.Media.TextFormatting.Unicode; using Avalonia.Platform; @@ -59,7 +58,7 @@ namespace Avalonia.Skia var glyphIndex = (ushort)sourceInfo.Codepoint; - var glyphCluster = (int)sourceInfo.Cluster; + var glyphCluster = (int)(sourceInfo.Cluster); var glyphAdvance = GetGlyphAdvance(glyphPositions, i, textScale); diff --git a/src/Windows/Avalonia.Win32/Interop/UnmanagedMethods.cs b/src/Windows/Avalonia.Win32/Interop/UnmanagedMethods.cs index 9d53691597..b1e4d8ca01 100644 --- a/src/Windows/Avalonia.Win32/Interop/UnmanagedMethods.cs +++ b/src/Windows/Avalonia.Win32/Interop/UnmanagedMethods.cs @@ -914,9 +914,9 @@ namespace Avalonia.Win32.Interop [DllImport("user32.dll")] public static extern bool EnumDisplayMonitors(IntPtr hdc, IntPtr lprcClip, MonitorEnumDelegate lpfnEnum, IntPtr dwData); - + public delegate bool MonitorEnumDelegate(IntPtr hMonitor, IntPtr hdcMonitor, ref Rect lprcMonitor, IntPtr dwData); - + [DllImport("user32.dll", SetLastError = true)] public static extern IntPtr GetDC(IntPtr hWnd); @@ -1007,7 +1007,7 @@ namespace Avalonia.Win32.Interop public static uint GetWindowLong(IntPtr hWnd, int nIndex) { - if(IntPtr.Size == 4) + if (IntPtr.Size == 4) { return GetWindowLong32b(hWnd, nIndex); } @@ -1034,7 +1034,7 @@ namespace Avalonia.Win32.Interop return (uint)SetWindowLong64b(hWnd, nIndex, new IntPtr((uint)value)).ToInt32(); } } - + public static IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr handle) { if (IntPtr.Size == 4) @@ -1068,14 +1068,14 @@ namespace Avalonia.Win32.Interop [DllImport("user32.dll")] public static extern bool InvalidateRect(IntPtr hWnd, RECT* lpRect, bool bErase); - - + + [DllImport("user32.dll")] public static extern bool ValidateRect(IntPtr hWnd, IntPtr lpRect); [DllImport("user32.dll")] public static extern bool IsWindow(IntPtr hWnd); - + [DllImport("user32.dll")] public static extern bool IsWindowEnabled(IntPtr hWnd); @@ -1102,22 +1102,25 @@ namespace Avalonia.Win32.Interop [DllImport("user32")] public static extern IntPtr GetMessageExtraInfo(); - + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode, EntryPoint = "RegisterClassExW")] public static extern ushort RegisterClassEx(ref WNDCLASSEX lpwcx); [DllImport("user32.dll")] public static extern void RegisterTouchWindow(IntPtr hWnd, int flags); - + [DllImport("user32.dll")] public static extern bool ReleaseCapture(); + [DllImport("user32.dll", SetLastError = true)] + public static extern uint RegisterWindowMessage(string lpString); + [DllImport("user32.dll")] public static extern bool ScreenToClient(IntPtr hWnd, ref POINT lpPoint); [DllImport("user32.dll", SetLastError = true)] public static extern IntPtr GetActiveWindow(); - + [DllImport("user32.dll", SetLastError = true)] public static extern IntPtr SetActiveWindow(IntPtr hWnd); @@ -1304,7 +1307,7 @@ namespace Avalonia.Win32.Interop [DllImport("kernel32.dll", SetLastError = true)] public static extern IntPtr LoadLibrary(string fileName); - + [DllImport("kernel32.dll", SetLastError = true)] public static extern IntPtr LoadLibraryEx(string fileName, IntPtr hFile, int flags); @@ -1348,7 +1351,7 @@ namespace Avalonia.Win32.Interop [DllImport("user32.dll")] public static extern IntPtr MonitorFromWindow(IntPtr hwnd, MONITOR dwFlags); - + [DllImport("user32", EntryPoint = "GetMonitorInfoW", ExactSpelling = true, CharSet = CharSet.Unicode)] [return: MarshalAs(UnmanagedType.Bool)] public static extern bool GetMonitorInfo([In] IntPtr hMonitor, ref MONITORINFO lpmi); @@ -1356,14 +1359,14 @@ namespace Avalonia.Win32.Interop [DllImport("user32")] public static extern unsafe bool GetTouchInputInfo( IntPtr hTouchInput, - uint cInputs, + uint cInputs, TOUCHINPUT* pInputs, - int cbSize + int cbSize ); - + [DllImport("user32")] public static extern bool CloseTouchInputHandle(IntPtr hTouchInput); - + [return: MarshalAs(UnmanagedType.Bool)] [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode, EntryPoint = "PostMessageW")] public static extern bool PostMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam); @@ -1372,7 +1375,7 @@ namespace Avalonia.Win32.Interop public static extern int SetDIBitsToDevice(IntPtr hdc, int XDest, int YDest, uint dwWidth, uint dwHeight, int XSrc, int YSrc, uint uStartScan, uint cScanLines, IntPtr lpvBits, [In] ref BITMAPINFOHEADER lpbmi, uint fuColorUse); - + [DllImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] public static extern bool CloseHandle(IntPtr hObject); @@ -1387,27 +1390,27 @@ namespace Avalonia.Win32.Interop [DllImport("gdi32.dll")] public static extern int ChoosePixelFormat(IntPtr hdc, ref PixelFormatDescriptor pfd); - + [DllImport("gdi32.dll")] public static extern int DescribePixelFormat(IntPtr hdc, ref PixelFormatDescriptor pfd); [DllImport("gdi32.dll")] public static extern int SetPixelFormat(IntPtr hdc, int iPixelFormat, ref PixelFormatDescriptor pfd); - - + + [DllImport("gdi32.dll")] public static extern int DescribePixelFormat(IntPtr hdc, int iPixelFormat, int bytes, ref PixelFormatDescriptor pfd); - + [DllImport("gdi32.dll")] public static extern bool SwapBuffers(IntPtr hdc); [DllImport("opengl32.dll")] public static extern IntPtr wglCreateContext(IntPtr hdc); - + [DllImport("opengl32.dll")] public static extern bool wglDeleteContext(IntPtr context); - + [DllImport("opengl32.dll")] public static extern bool wglMakeCurrent(IntPtr hdc, IntPtr context); @@ -1428,9 +1431,9 @@ namespace Avalonia.Win32.Interop uint dwMaximumSizeLow, string lpName); - [DllImport("msvcrt.dll", EntryPoint="memcpy", SetLastError = false, CallingConvention=CallingConvention.Cdecl)] - public static extern IntPtr CopyMemory(IntPtr dest, IntPtr src, UIntPtr count); - + [DllImport("msvcrt.dll", EntryPoint = "memcpy", SetLastError = false, CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr CopyMemory(IntPtr dest, IntPtr src, UIntPtr count); + [DllImport("ole32.dll", CharSet = CharSet.Auto, ExactSpelling = true)] public static extern HRESULT RegisterDragDrop(IntPtr hwnd, IntPtr target); @@ -1472,10 +1475,10 @@ namespace Avalonia.Win32.Interop [DllImport("dwmapi.dll")] public static extern void DwmFlush(); - + [DllImport("dwmapi.dll")] public static extern bool DwmDefWindowProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam, ref IntPtr plResult); - + [DllImport("dwmapi.dll")] public static extern void DwmEnableBlurBehindWindow(IntPtr hwnd, ref DWM_BLURBEHIND blurBehind); @@ -1542,8 +1545,8 @@ namespace Avalonia.Win32.Interop throw new Exception("RtlGetVersion failed!"); } } - - [DllImport("kernel32", EntryPoint="WaitForMultipleObjectsEx", SetLastError = true, CharSet = CharSet.Auto)] + + [DllImport("kernel32", EntryPoint = "WaitForMultipleObjectsEx", SetLastError = true, CharSet = CharSet.Auto)] private static extern int IntWaitForMultipleObjectsEx(int nCount, IntPtr[] pHandles, bool bWaitAll, int dwMilliseconds, bool bAlertable); public const int WAIT_FAILED = unchecked((int)0xFFFFFFFF); @@ -1551,7 +1554,7 @@ namespace Avalonia.Win32.Interop internal static int WaitForMultipleObjectsEx(int nCount, IntPtr[] pHandles, bool bWaitAll, int dwMilliseconds, bool bAlertable) { int result = IntWaitForMultipleObjectsEx(nCount, pHandles, bWaitAll, dwMilliseconds, bAlertable); - if(result == WAIT_FAILED) + if (result == WAIT_FAILED) { throw new Win32Exception(); } @@ -1699,7 +1702,7 @@ namespace Avalonia.Win32.Interop DrawLeftBorder = 0x20, DrawTopBorder = 0x40, DrawRightBorder = 0x80, - DrawBottomBorder = 0x100, + DrawBottomBorder = 0x100, } [StructLayout(LayoutKind.Sequential)] @@ -1767,9 +1770,9 @@ namespace Avalonia.Win32.Interop MDT_ANGULAR_DPI = 1, MDT_RAW_DPI = 2, MDT_DEFAULT = MDT_EFFECTIVE_DPI - } + } - public enum ClipboardFormat + public enum ClipboardFormat { /// /// Text format. Each line ends with a carriage return/linefeed (CR-LF) combination. A null character signals the end of the data. Use this format for ANSI text. @@ -1820,7 +1823,7 @@ namespace Avalonia.Win32.Interop public int X; public int Y; } - + public struct SIZE { public int X; @@ -2021,7 +2024,7 @@ namespace Avalonia.Win32.Interop OFN_NOREADONLYRETURN = 0x00008000, OFN_OVERWRITEPROMPT = 0x00000002 } - + public enum HRESULT : uint { S_FALSE = 0x0001, diff --git a/src/Windows/Avalonia.Win32/TrayIconImpl.cs b/src/Windows/Avalonia.Win32/TrayIconImpl.cs index 6484ae6c54..771ab232da 100644 --- a/src/Windows/Avalonia.Win32/TrayIconImpl.cs +++ b/src/Windows/Avalonia.Win32/TrayIconImpl.cs @@ -24,6 +24,7 @@ namespace Avalonia.Win32 private readonly Win32NativeToManagedMenuExporter _exporter; private static readonly Dictionary s_trayIcons = new Dictionary(); private bool _disposedValue; + private static readonly uint WM_TASKBARCREATED = UnmanagedMethods.RegisterWindowMessage("TaskbarCreated"); public TrayIconImpl() { @@ -44,6 +45,18 @@ namespace Avalonia.Win32 { s_trayIcons[wParam.ToInt32()].WndProc(hWnd, msg, wParam, lParam); } + + if (msg == WM_TASKBARCREATED) + { + foreach (var tray in s_trayIcons.Values) + { + if (tray._iconAdded) + { + tray.UpdateIcon(true); + tray.UpdateIcon(); + } + } + } } public void SetIcon(IWindowIconImpl? icon) @@ -145,7 +158,7 @@ namespace Avalonia.Win32 private enum CustomWindowsMessage : uint { WM_TRAYICON = WindowsMessage.WM_APP + 1024, - WM_TRAYMOUSE = WindowsMessage.WM_USER + 1024 + WM_TRAYMOUSE = WindowsMessage.WM_USER + 1024, } private class TrayIconMenuFlyoutPresenter : MenuFlyoutPresenter, IStyleable diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs index 0e0ca7cd25..3d36395c3a 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs @@ -6,6 +6,7 @@ using System.Collections.Specialized; using System.ComponentModel; using System.Linq; using System.Reactive.Disposables; +using System.Threading.Tasks; using Avalonia.Collections; using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; @@ -1614,6 +1615,50 @@ namespace Avalonia.Controls.UnitTests.Primitives target.MoveSelection(NavigationDirection.Next, true); } + [Fact(Timeout = 2000)] + public async Task MoveSelection_Does_Not_Hang_With_No_Focusable_Controls_And_Moving_Selection_To_The_First_Item() + { + var target = new TestSelector + { + Template = Template(), + Items = new[] + { + new ListBoxItem { Focusable = false }, + new ListBoxItem(), + } + }; + + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); + + // Timeout in xUnit doesen't work with synchronous methods so we need to apply hack below. + // https://github.com/xunit/xunit/issues/2222 + await Task.Run(() => target.MoveSelection(NavigationDirection.First, true)); + Assert.Equal(-1, target.SelectedIndex); + } + + [Fact(Timeout = 2000)] + public async Task MoveSelection_Does_Not_Hang_With_No_Focusable_Controls_And_Moving_Selection_To_The_Last_Item() + { + var target = new TestSelector + { + Template = Template(), + Items = new[] + { + new ListBoxItem(), + new ListBoxItem { Focusable = false }, + } + }; + + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); + + // Timeout in xUnit doesen't work with synchronous methods so we need to apply hack below. + // https://github.com/xunit/xunit/issues/2222 + await Task.Run(() => target.MoveSelection(NavigationDirection.Last, true)); + Assert.Equal(-1, target.SelectedIndex); + } + [Fact] public void MoveSelection_Does_Select_Disabled_Controls() { diff --git a/tests/Avalonia.LeakTests/Avalonia.LeakTests.csproj b/tests/Avalonia.LeakTests/Avalonia.LeakTests.csproj index 0c663e1a8f..a00b24bdd7 100644 --- a/tests/Avalonia.LeakTests/Avalonia.LeakTests.csproj +++ b/tests/Avalonia.LeakTests/Avalonia.LeakTests.csproj @@ -9,6 +9,7 @@ + diff --git a/tests/Avalonia.RenderTests/Media/TextFormatting/TextLayoutTests.cs b/tests/Avalonia.RenderTests/Media/TextFormatting/TextLayoutTests.cs index 6ed4ba0d4a..b668f4d39e 100644 --- a/tests/Avalonia.RenderTests/Media/TextFormatting/TextLayoutTests.cs +++ b/tests/Avalonia.RenderTests/Media/TextFormatting/TextLayoutTests.cs @@ -1,11 +1,8 @@ using Avalonia.Media; -using Avalonia.Platform; using System; -using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Runtime.InteropServices; -using System.Text; using System.Threading.Tasks; using Avalonia.Controls; using Avalonia.Media.TextFormatting; diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs index e9bc792be3..a47638d2ec 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs @@ -70,12 +70,12 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting } } } - + [Fact] public void Should_Get_Next_Caret_CharacterHit_Bidi() { const string text = "אבג 1 ABC"; - + using (Start()) { var defaultProperties = new GenericTextRunProperties(Typeface.Default); @@ -90,7 +90,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var clusters = new List(); - foreach (var textRun in textLine.TextRuns.OrderBy(x=> x.Text.Start)) + foreach (var textRun in textLine.TextRuns.OrderBy(x => x.Text.Start)) { var shapedRun = (ShapedTextCharacters)textRun; @@ -98,7 +98,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting shapedRun.ShapedBuffer.GlyphClusters.Reverse() : shapedRun.ShapedBuffer.GlyphClusters); } - + var nextCharacterHit = new CharacterHit(0, clusters[1] - clusters[0]); foreach (var cluster in clusters) @@ -122,7 +122,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting public void Should_Get_Previous_Caret_CharacterHit_Bidi() { const string text = "אבג 1 ABC"; - + using (Start()) { var defaultProperties = new GenericTextRunProperties(Typeface.Default); @@ -137,7 +137,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var clusters = new List(); - foreach (var textRun in textLine.TextRuns.OrderBy(x=> x.Text.Start)) + foreach (var textRun in textLine.TextRuns.OrderBy(x => x.Text.Start)) { var shapedRun = (ShapedTextCharacters)textRun; @@ -147,13 +147,13 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting } clusters.Reverse(); - + var nextCharacterHit = new CharacterHit(text.Length - 1); foreach (var cluster in clusters) { var currentCaretIndex = nextCharacterHit.FirstCharacterIndex + nextCharacterHit.TrailingLength; - + Assert.Equal(cluster, currentCaretIndex); nextCharacterHit = textLine.GetPreviousCaretCharacterHit(nextCharacterHit); @@ -168,7 +168,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting Assert.Equal(lastCharacterHit.TrailingLength, nextCharacterHit.TrailingLength); } } - + [InlineData("𐐷𐐷𐐷𐐷𐐷")] [InlineData("01234567🎉\n")] [InlineData("𐐷1234")] @@ -324,7 +324,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting } } - Assert.Equal(currentDistance,textLine.GetDistanceFromCharacterHit(new CharacterHit(s_multiLineText.Length))); + Assert.Equal(currentDistance, textLine.GetDistanceFromCharacterHit(new CharacterHit(s_multiLineText.Length))); } } @@ -371,7 +371,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting yield return CreateData("01234 01234", 58, TextTrimming.WordEllipsis, "01234\u2026"); yield return CreateData("01234", 9, TextTrimming.CharacterEllipsis, "\u2026"); yield return CreateData("01234", 2, TextTrimming.CharacterEllipsis, ""); - + object[] CreateData(string text, double width, TextTrimming mode, string expected) { return new object[] @@ -424,7 +424,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting { var defaultProperties = new GenericTextRunProperties(Typeface.Default); var textSource = new DrawableRunTextSource(); - + var formatter = new TextFormatterImpl(); var textLine = @@ -471,7 +471,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting Assert.Equal(4, textLine.TextRuns.Count); - var currentHit = textLine.GetPreviousCaretCharacterHit(new CharacterHit(3,1)); + var currentHit = textLine.GetPreviousCaretCharacterHit(new CharacterHit(3, 1)); Assert.Equal(3, currentHit.FirstCharacterIndex); Assert.Equal(0, currentHit.TrailingLength); @@ -552,11 +552,11 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting switch (textSourceIndex) { case 0: - return new CustomDrawableRun(); + return new CustomDrawableRun(); case 1: return new TextCharacters(new ReadOnlySlice(Text.AsMemory(), 1, 1, 1), new GenericTextRunProperties(Typeface.Default)); case 2: - return new CustomDrawableRun(); + return new CustomDrawableRun(); case 3: return new TextCharacters(new ReadOnlySlice(Text.AsMemory(), 3, 1, 3), new GenericTextRunProperties(Typeface.Default)); default: @@ -564,14 +564,14 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting } } } - + private class CustomDrawableRun : DrawableTextRun { public override Size Size => new(14, 14); public override double Baseline => 14; public override void Draw(DrawingContext drawingContext, Point origin) { - + } } @@ -587,29 +587,29 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var shapedTextRuns = textLine.TextRuns.Cast().ToList(); var lastCluster = -1; - + foreach (var textRun in shapedTextRuns) { var shapedBuffer = textRun.ShapedBuffer; var currentClusters = shapedBuffer.GlyphClusters.ToList(); - foreach (var currentCluster in currentClusters) + foreach (var currentCluster in currentClusters) { if (lastCluster == currentCluster) { continue; } - + glyphClusters.Add(currentCluster); lastCluster = currentCluster; } } - + return glyphClusters; } - + private static List BuildRects(TextLine textLine) { var rects = new List(); @@ -624,11 +624,11 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting foreach (var textRun in shapedTextRuns) { var shapedBuffer = textRun.ShapedBuffer; - + for (var index = 0; index < shapedBuffer.GlyphAdvances.Count; index++) { var currentCluster = shapedBuffer.GlyphClusters[index]; - + var advance = shapedBuffer.GlyphAdvances[index]; if (lastCluster != currentCluster) @@ -642,10 +642,10 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting rects.Remove(rect); rect = rect.WithWidth(rect.Width + advance); - + rects.Add(rect); } - + currentX += advance; lastCluster = currentCluster; @@ -655,8 +655,65 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting return rects; } + + [Fact] + public void Should_Get_TextBounds_Mixed() + { + using (Start()) + { + var defaultProperties = new GenericTextRunProperties(Typeface.Default); + var text = "0123".AsMemory(); + var shaperOption = new TextShaperOptions(Typeface.Default.GlyphTypeface, 10, 0, CultureInfo.CurrentCulture); + + var firstRun = new ShapedTextCharacters(TextShaper.Current.ShapeText(new ReadOnlySlice(text, 1, text.Length), shaperOption), defaultProperties); + + var textRuns = new List + { + new CustomDrawableRun(), + firstRun, + new CustomDrawableRun(), + new ShapedTextCharacters(TextShaper.Current.ShapeText(new ReadOnlySlice(text, text.Length + 2, text.Length), shaperOption), defaultProperties), + new CustomDrawableRun(), + new ShapedTextCharacters(TextShaper.Current.ShapeText(new ReadOnlySlice(text, text.Length * 2 + 3, text.Length), shaperOption), defaultProperties) + }; + + var textSource = new FixedRunsTextSource(textRuns); + + var formatter = new TextFormatterImpl(); + + var textLine = + formatter.FormatLine(textSource, 0, double.PositiveInfinity, + new GenericTextParagraphProperties(defaultProperties)); + + var textBounds = textLine.GetTextBounds(0, text.Length * 3 + 3); + + Assert.Equal(1, textBounds.Count); + Assert.Equal(textLine.WidthIncludingTrailingWhitespace, textBounds.Sum(x => x.Rectangle.Width)); + + textBounds = textLine.GetTextBounds(0, 1); + + Assert.Equal(1, textBounds.Count); + Assert.Equal(14, textBounds[0].Rectangle.Width); + + textBounds = textLine.GetTextBounds(0, firstRun.Text.Length + 1); + + Assert.Equal(1, textBounds.Count); + Assert.Equal(firstRun.Size.Width + 14, textBounds[0].Rectangle.Width); + + textBounds = textLine.GetTextBounds(1, firstRun.Text.Length); + + Assert.Equal(1, textBounds.Count); + Assert.Equal(firstRun.Size.Width, textBounds[0].Rectangle.Width); + + textBounds = textLine.GetTextBounds(1, firstRun.Text.Length + 1); + + Assert.Equal(1, textBounds.Count); + Assert.Equal(firstRun.Size.Width + 14, textBounds[0].Rectangle.Width); + } + } + [Fact] - public void Should_Get_TextBounds() + public void Should_Get_TextBounds_BiDi() { using (Start()) { @@ -673,7 +730,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting new ShapedTextCharacters(TextShaper.Current.ShapeText(new ReadOnlySlice(text, text.Length * 3, text.Length), ltrOptions), defaultProperties) }; - + var textSource = new FixedRunsTextSource(textRuns); var formatter = new TextFormatterImpl(); @@ -700,12 +757,16 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting public TextRun? GetTextRun(int textSourceIndex) { + var currentPosition = 0; + foreach (var textRun in _textRuns) { - if(textRun.Text.Start == textSourceIndex) + if (currentPosition == textSourceIndex) { return textRun; } + + currentPosition += textRun.TextSourceLength; } return null;