Created
May 8, 2023 21:28
-
-
Save TimPaterson/3194bd3db1f34b2e1bfc412cc28b17d7 to your computer and use it in GitHub Desktop.
SelectableTextBlock: A WPF TextBlock control with selectable text
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System; | |
using System.Collections.Generic; | |
using System.Text; | |
using System.Windows; | |
using System.Windows.Controls; | |
using System.Windows.Documents; | |
using System.Windows.Input; | |
using System.Windows.Media; | |
namespace S100debugger; | |
static class TextBlockExtension | |
{ | |
const int LinebreakLength = 2; | |
public static TextPointer GetPositionFromIndex(this TextBlock textBlock, int index) | |
{ | |
int curIndex = 0; | |
int length; | |
TextPointer pointer; | |
pointer = textBlock.ContentStart; | |
while (curIndex < index && pointer.CompareTo(textBlock.ContentEnd) < 0) | |
{ | |
TextPointerContext context = pointer.GetPointerContext(LogicalDirection.Forward); | |
switch (context) | |
{ | |
case TextPointerContext.Text: | |
length = pointer.GetTextRunLength(LogicalDirection.Forward); | |
if (length + curIndex > index) | |
{ | |
// found it in this run | |
return pointer.GetPositionAtOffset(index - curIndex); | |
} | |
curIndex += length; | |
break; | |
case TextPointerContext.ElementStart: | |
DependencyObject element = pointer.GetAdjacentElement(LogicalDirection.Forward); | |
if (element is LineBreak) | |
curIndex += LinebreakLength; | |
break; | |
} | |
pointer = pointer.GetNextContextPosition(LogicalDirection.Forward); | |
} | |
return pointer; | |
} | |
public static int GetIndexFromPosition(this TextBlock textBlock, TextPointer position) | |
{ | |
return new TextRange(textBlock.ContentStart, position).Text.Length; | |
} | |
} | |
class SelectableTextBlock : TextBlock | |
{ | |
#region Constructor | |
public SelectableTextBlock() : base() | |
{ | |
m_selectionStart = ContentStart; | |
m_selectionEnd = ContentStart; | |
m_selectedRange = new(ContentStart, ContentStart); | |
} | |
#endregion | |
#region Private Fields & Properties | |
readonly TextRange m_selectedRange; | |
TextPointer m_selectionStart; | |
TextPointer m_selectionEnd; | |
Point m_startPoint; | |
bool m_isSelecting = false; | |
#endregion | |
#region Public Properties & Events | |
public string SelectedText | |
{ | |
get => m_selectedRange.Text; | |
set | |
{ | |
m_selectedRange.Text = value; | |
m_selectionStart = m_selectedRange.Start; | |
m_selectionEnd = m_selectedRange.End; | |
SetSelected(m_selectedRange); | |
} | |
} | |
public TextRange SelectedRange => m_selectedRange; | |
public int SelectionStart | |
{ | |
get => new TextRange(ContentStart, m_selectedRange.Start).Text.Length; | |
set | |
{ | |
int length = SelectionLength; | |
SetNormal(m_selectedRange); | |
m_selectionStart = this.GetPositionFromIndex(value); | |
m_selectionEnd = this.GetPositionFromIndex(value + length); | |
m_selectedRange.Select(m_selectionStart, m_selectionEnd); | |
SetSelected(m_selectedRange); | |
} | |
} | |
public int SelectionLength | |
{ | |
get => m_selectedRange.Text.Length; | |
set | |
{ | |
int index = SelectionStart; | |
SetNormal(m_selectedRange); | |
m_selectionEnd = this.GetPositionFromIndex(value + index); | |
m_selectionStart = m_selectedRange.Start; | |
m_selectedRange.Select(m_selectionStart, m_selectionEnd); | |
SetSelected(m_selectedRange); | |
} | |
} | |
// Set this property to 1/2 the character width (obviously simplest | |
// if using a fixed-pitch font). It ensures the first character | |
// remains in the selection. | |
public double CharWidthAdjustment { get; set; } = 0; | |
public Brush SelectedBackground { get; set; } = SystemColors.HighlightBrush; | |
public Brush SelectedForeground { get; set; } = SystemColors.HighlightTextBrush; | |
public event EventHandler? SelectionChanging; | |
public event EventHandler? SelectionChanged; | |
#endregion | |
#region Private Methods | |
private void SetSelected(TextRange range) | |
{ | |
range.ApplyPropertyValue(ForegroundProperty, SelectedForeground); | |
range.ApplyPropertyValue(BackgroundProperty, SelectedBackground); | |
} | |
private void SetNormal(TextRange range) | |
{ | |
range.ApplyPropertyValue(ForegroundProperty, Foreground); | |
range.ApplyPropertyValue(BackgroundProperty, Background); | |
} | |
private void EndSelection() | |
{ | |
if (m_isSelecting) | |
{ | |
m_isSelecting = false; | |
SelectionChanged?.Invoke(this, EventArgs.Empty); | |
} | |
} | |
#endregion | |
#region Protected Method Overrides | |
protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e) | |
{ | |
base.OnMouseLeftButtonDown(e); | |
SetNormal(m_selectedRange); | |
m_isSelecting = true; | |
m_startPoint = e.GetPosition(this); | |
m_selectionStart = GetPositionFromPoint(m_startPoint, true); | |
m_selectionEnd = m_selectionStart; | |
m_selectedRange.Select(m_selectionStart, m_selectionEnd); | |
SelectionChanging?.Invoke(this, EventArgs.Empty); | |
} | |
protected override void OnMouseMove(MouseEventArgs e) | |
{ | |
base.OnMouseMove(e); | |
if (!m_isSelecting) | |
return; | |
if (e.LeftButton != MouseButtonState.Pressed) | |
{ | |
EndSelection(); | |
return; | |
} | |
TextPointer end = GetPositionFromPoint(e.GetPosition(this), true); | |
if (m_selectionEnd.CompareTo(end) == 0) | |
return; | |
SetNormal(m_selectedRange); | |
// Adjust start depending on selection direction | |
Point start = m_startPoint; | |
if (m_selectionStart.CompareTo(end) > 0) | |
start.X += CharWidthAdjustment; | |
else | |
start.X -= CharWidthAdjustment; | |
m_selectionStart = GetPositionFromPoint(start, true); | |
m_selectionEnd = end; | |
m_selectedRange.Select(m_selectionStart, m_selectionEnd); | |
SetSelected(m_selectedRange); | |
SelectionChanging?.Invoke(this, EventArgs.Empty); | |
} | |
protected override void OnMouseLeftButtonUp(MouseButtonEventArgs e) | |
{ | |
base.OnMouseLeftButtonUp(e); | |
EndSelection(); | |
} | |
protected override void OnMouseLeave(MouseEventArgs e) | |
{ | |
base.OnMouseLeave(e); | |
EndSelection(); | |
} | |
#endregion | |
} | |
class TextBlockRunBuilder | |
{ | |
readonly List<Inline> m_range = new(); | |
readonly StringBuilder m_sb = new(); | |
public void Append(string text) => m_sb.Append(text); | |
public void Append(char ch) => m_sb.Append(ch); | |
public void Append(char ch, int count) => m_sb.Append(ch, count); | |
public void AppendLine() => m_sb.AppendLine(); | |
public void Append(char ch, Brush foreground, Brush? background = null) | |
{ | |
Append(ch.ToString(), foreground, background); | |
} | |
public void Append(string text, Brush foreground, Brush? background = null) | |
{ | |
if (m_sb.Length > 0) | |
{ | |
m_range.Add(new Run(m_sb.ToString())); | |
m_sb.Clear(); | |
} | |
Run run = new(text); | |
run.Foreground = foreground; | |
if (background != null) | |
run.Background = background; | |
m_range.Add(run); | |
} | |
public void SetTextBlock(TextBlock block) | |
{ | |
if (m_sb.Length > 0) | |
{ | |
m_range.Add(new Run(m_sb.ToString())); | |
m_sb.Clear(); | |
} | |
block.Inlines.Clear(); | |
block.Inlines.AddRange(m_range); | |
m_range.Clear(); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment