// RichTextKit // Copyright © 2019-2020 Topten Software. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); you may // not use this product except in compliance with the License. You may obtain // a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the // License for the specific language governing permissions and limitations // under the License. using SkiaSharp; using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; using Topten.RichTextKit.Utils; namespace Topten.RichTextKit { /// /// Represents a laid out line of text. /// public class TextLine { /// /// Constructs a new TextLine. /// public TextLine() { } /// /// Gets the set of text runs comprising this line. /// /// /// Font runs are order logically (ie: in code point index order) /// but may have unordered 's when right to /// left text is in use. /// public IReadOnlyList Runs => RunsInternal; /// /// Gets the text block that owns this line. /// public TextBlock TextBlock { get; internal set; } /// /// Gets the next line in this text block, or null if this is the last line. /// public TextLine NextLine { get { int index = (TextBlock.Lines as List).IndexOf(this); if (index < 0 || index + 1 >= TextBlock.Lines.Count) return null; return TextBlock.Lines[index + 1]; } } /// /// Gets the previous line in this text block, or null if this is the first line. /// public TextLine PreviousLine { get { int index = (TextBlock.Lines as List).IndexOf(this); if (index <= 0) return null; return TextBlock.Lines[index - 1]; } } /// /// Gets the y-coordinate of the top of this line, relative to the top of the text block. /// public float YCoord { get; internal set; } /// /// Gets the base line of this line (relative to ) /// public float BaseLine { get; internal set; } /// /// Gets the maximum magnitude ascent of all font runs in this line. /// /// /// The ascent is reported as a negative value from the base line. /// public float MaxAscent { get; internal set; } /// /// Gets the maximum descent of all font runs in this line. /// /// /// The descent is reported as a positive value from the base line. /// public float MaxDescent { get; internal set; } /// /// Gets the text height of this line. /// /// /// The text height of a line is the sum of the ascent and desent. /// public float TextHeight => -MaxAscent + MaxDescent; /// /// Gets the height of this line /// /// /// The height of a line is based on the font and /// value of all runs in this line. /// public float Height { get; internal set; } /// /// The width of the content on this line, excluding trailing whitespace and overhang. /// public float Width { get; internal set; } /// /// Paint this line /// /// The paint context internal void Paint(PaintTextContext ctx) { foreach (var r in Runs) { r.PaintBackground(ctx); } foreach (var r in Runs) { r.Paint(ctx); } } /// /// Code point index of start of this line /// public int Start { get { var pl = PreviousLine; return PreviousLine == null ? 0 : PreviousLine.End; } } /// /// The length of this line in codepoints /// public int Length => End - Start; /// /// The code point index of the first character after this line /// public int End { get { // Get the last run that's not an ellipsis var lastRun = this.Runs.LastOrDefault(x => x.RunKind != FontRunKind.Ellipsis); // If last run found, then it's the end of the run, other wise it's the start index return lastRun == null ? Start : lastRun.End; } } /// /// Hit test this line, working out the cluster the x position is over /// and closest to. /// /// /// This method only populates the code point indicies in the returned result /// and the line indicies will be -1 /// /// The xcoord relative to the text block public HitTestResult HitTest(float x) { var htr = new HitTestResult(); htr.OverLine = -1; htr.ClosestLine = -1; HitTest(x, ref htr); return htr; } /// /// Hit test this line, working out the cluster the x position is over /// and closest to. /// /// The xcoord relative to the text block /// HitTestResult to be filled out internal void HitTest(float x, ref HitTestResult htr) { // Working variables float closestXPosition = 0; int closestCodePointIndex = -1; if (Runs.Count > 0) { // If caret is beyond the end of the line... var lastRun = Runs[Runs.Count - 1]; if ((lastRun.Direction == TextDirection.LTR && x >= lastRun.XCoord + lastRun.Width) || (lastRun.Direction == TextDirection.RTL && x < lastRun.XCoord)) { // Special handling for clicking after a soft line break ('\n') in which case // the caret should be positioned before the new line character, not after it // as this would cause the cursor to appear on the next line). if (lastRun.RunKind == FontRunKind.TrailingWhitespace || lastRun.RunKind == FontRunKind.Ellipsis) { if (lastRun.CodePoints.Length > 0 && (lastRun.CodePoints[lastRun.CodePoints.Length - 1] == '\n') || (lastRun.CodePoints[lastRun.CodePoints.Length - 1] == 0x2029) ) { htr.ClosestCodePointIndex = lastRun.End - 1; return; } } } } // Check all runs foreach (var r in Runs) { // Ignore ellipsis runs if (r.RunKind == FontRunKind.Ellipsis) continue; if (x < r.XCoord) { // Before the run... updateClosest(r.XCoord, r.Direction == TextDirection.LTR ? r.Start : r.End, r.Direction); } else if (x >= r.XCoord + r.Width) { // After the run... updateClosest(r.XCoord + r.Width, r.Direction == TextDirection.RTL ? r.Start : r.End, r.Direction); } else { // Inside the run for (int i = 0; i < r.Clusters.Length;) { // Get the xcoord of this cluster var codePointIndex = r.Clusters[i]; var xcoord1 = r.GetXCoordOfCodePointIndex(codePointIndex); // Find the code point of the next cluster var j = i; while (j < r.Clusters.Length && r.Clusters[j] == r.Clusters[i]) j++; // Get the xcoord of other side of this cluster int codePointIndexOther; if (r.Direction == TextDirection.LTR) { if (j == r.Clusters.Length) { codePointIndexOther = r.End; } else { codePointIndexOther = r.Clusters[j]; } } else { if (i > 0) { codePointIndexOther = r.Clusters[i - 1]; } else { codePointIndexOther = r.End; } } // Gethte xcoord of the other side of the cluster var xcoord2 = r.GetXCoordOfCodePointIndex(codePointIndexOther); // Ensure order correct for easier in-range check if (xcoord1 > xcoord2) { var temp = xcoord1; xcoord1 = xcoord2; xcoord2 = temp; } // On the character? if (x >= xcoord1 && x < xcoord2) { // Store this as the cluster the point is over htr.OverCodePointIndex = codePointIndex; // Don't move to the rhs (or lhs) of a line break if (r.CodePoints[codePointIndex - r.Start] == '\n') { htr.ClosestCodePointIndex = codePointIndex; } else { // Work out if position is closer to the left or right side of the cluster if (x < (xcoord1 + xcoord2) / 2) { htr.ClosestCodePointIndex = r.Direction == TextDirection.LTR ? codePointIndex : codePointIndexOther; } else { htr.ClosestCodePointIndex = r.Direction == TextDirection.LTR ? codePointIndexOther : codePointIndex; } } if (htr.ClosestCodePointIndex == End) { htr.AltCaretPosition = true; } return; } // Move to the next cluster i = j; } } } // Store closest character htr.ClosestCodePointIndex = closestCodePointIndex; if (htr.ClosestCodePointIndex == End) { htr.AltCaretPosition = true; } // Helper for updating closest caret position void updateClosest(float xPosition, int codePointIndex, TextDirection dir) { if (closestCodePointIndex == -1 || Math.Abs(xPosition - x) < Math.Abs(closestXPosition - x)) { closestXPosition = xPosition; closestCodePointIndex = codePointIndex; } } } internal void UpdateOverhang(float right, ref float leftOverhang, ref float rightOverhang) { foreach (var r in Runs) { r.UpdateOverhang(right, ref leftOverhang, ref rightOverhang); } } /// /// Internal List of runs /// internal List RunsInternal = new List(); internal static ThreadLocal> Pool = new ThreadLocal>(() => new ObjectPool() { Cleaner = (r) => { r.TextBlock = null; r.RunsInternal.Clear(); } }); } }