TextBlock з підсвічуванням тексту (WPF)

Привіт Хабр! Я створив контрол на основі TextBlock з можливістю підсвічування тексту. Для початку наведу приклад його використання, потім опишу, як він створювався.

Приклад використання контрола
<local:HighlightTextBlock TextWrapping="Wrap">
<local:HighlightTextBlock.HighlightRules>
<local:HighlightRule HightlightedText="{Binding Filter, Source={x:Reference thisWindow}}">
<local:HighlightRule.Highlights>
<local:HighlightBackgroung Brush="Yellow"/>
<local:HighlightForeground Brush="Black"/>
</local:HighlightRule.Highlights>
</local:HighlightRule>
</local:HighlightTextBlock.HighlightRules>
<Run FontWeight="Bold">Property:</Run>
<Run Text="{Binding Property}"/>
</local:HighlightTextBlock>


Початок розробки
Знадобилося мені підсвітити текст TextBlock, введений в рядок пошуку. На перший погляд завдання здалася простий. Прийшло в голову розділити текст на 3 елемента Run, які б передавали в конвертер весь текст, рядок пошуку і своє положення (1/2/3). Середній Run має Backgroung.

Не встиг я приступити до реалізації, як прийшла в голову думка, що варіантів може бути кілька. А значить такий підхід не підходить.

Була ще думка формувати Xaml «на льоту», парсити його за допомогою XamlReader і кидати в TextBlock. Але ця думка теж відразу відпала, бо пахне.

Наступною (остаточної) ідеєю стало створити систему правил підсвічування і прикрутити її до TextBlock. Тут 2 варіанти: свій контрол з блекджеком і дівчатками на основі TextBlock або AttachedProperty. Після недовгих роздумів, я вирішив, що все-таки краще створити окремий контрол, тому що функціонал підсвічування може накласти деякі обмеження на функціональність самого TextBlock, а розрулювати це простіше, якщо від нього унаследоваться.

Исходники готового контрола
Отже, приступимо. Відразу попереджу, що контрол я робив в тому ж проекті, де збирався тестувати першу ідею, тому не звертайте увагу на неймспейсы. До розуму такі речі я доведу вже, коли буду включати контрол в основний проект (або буду викладати на гитхаб).

В Xaml розмітці контрола все чисто, за винятком обробника події Loaded

<TextBlock x:Class="WpfApplication18.HighlightTextBlock"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Loaded="TextBlock_Loaded">
</TextBlock>

Переходимо до коду:

Заголовок спойлера
public partial class HighlightTextBlock : TextBlock
{
// Тут зберігається сериализованное оригінальне наповнення TextBlock 
// (підсвічування накладається на оригінал і потім вже підставляється в TextBlock)
string _content;

// Це словник для правил підсвічування і відповідних їм черг завдань
Словник<HighlightRule, TaskQueue> _ruleTasks;

/// <summary>
/// Колекція правил підсвічування
/ / / < /summary>
public HighlightRulesCollection HighlightRules
{
get
{
return (HighlightRulesCollection)GetValue(HighlightRulesProperty);
}
set
{
SetValue(HighlightRulesProperty, value);
}
}

public static readonly DependencyProperty HighlightRulesProperty =
DependencyProperty.Register("HighlightRules", typeof(HighlightRulesCollection), typeof(HighlightTextBlock), new FrameworkPropertyMetadata(null) { PropertyChangedCallback = HighlightRulesChanged });


static void HighlightRulesChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e)
{
var col = e.NewValue as HighlightRulesCollection;
var tb = sender as HighlightTextBlock;
if (col != null && tb != null)
{
col.CollectionChanged += tb.HighlightRules_CollectionChanged;
foreach (var rule in col)
{
rule.HighlightTextChanged += tb.Rule_HighlightTextChanged;
}
}
}

public HighlightTextBlock()
{
_ruleTasks = new Dictionary<HighlightRule, TaskQueue>();
HighlightRules = new HighlightRulesCollection();
InitializeComponent();
}

// Обробник події на зміну колекції правил підсвічування
void HighlightRules_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
switch (e.Action)
{
case System.Collections.Specialized.NotifyCollectionChangedAction.Add:
foreach (HighlightRule rule in e.NewItems)
{
_ruleTasks.Add(rule, new TaskQueue(1));
SubscribeRuleNotifies(rule);
BeginHighlight(rule);
}
break;
case System.Collections.Specialized.NotifyCollectionChangedAction.Remove:
foreach (HighlightRule rule in e.OldItems)
{
rule.HightlightedText = string.Empty;
_ruleTasks.Remove(rule);
UnsubscribeRuleNotifies(rule);
}
break;
case System.Collections.Specialized.NotifyCollectionChangedAction.Reset:
foreach (HighlightRule rule in e.OldItems)
{
rule.HightlightedText = string.Empty;
_ruleTasks.Remove(rule);
UnsubscribeRuleNotifies(rule);
}
break;
}
}

// Підписка на події правила підсвічування
void SubscribeRuleNotifies(HighlightRule rule)
{
rule.HighlightTextChanged += Rule_HighlightTextChanged;
}

// Відписка від подій правила підсвічування
void UnsubscribeRuleNotifies(HighlightRule rule)
{
rule.HighlightTextChanged -= Rule_HighlightTextChanged;
}

// Обробник події, яка спрацьовує, коли текст для підсвічування змінився
void Rule_HighlightTextChanged(object sender, HighlightTextChangedEventArgs e)
{
BeginHighlight((HighlightRule)sender);
}

// Тут запускається механізм підсвічування в створеному мною диспетчері завдань.
// Зміст в тому, що якщо текст вводиться/стирається занадто швидко,
// попередня підсвічування не встигне закінчити роботу, тому нова підсвічування
// додається в чергу. Якщо в черзі вже що то є, то це видаляється з черги
// і вставляється нова задача. Для кожного правила чергу своя.
void BeginHighlight(HighlightRule rule)
{
_ruleTasks[rule].Add(new Action(() => Highlight(rule)));
}

// Механізм підсвічування
void Highlight(HighlightRule rule)
{
// Якщо передали не існуюче правило, залишаємо процедуру
if (rule == null)
return;

// Так як правила у нас задаються в Xaml коді, вони будуть належати основному потоку, в якому крутиться форма,
// тому деякі властивості можна дістати/покласти тільки таким чином
ObservableCollection<Highlight> highlights = null;
Application.Current.Dispatcher.Invoke(new ThreadStart(() =>
{
highlights = rule.Highlights;
}));

// Навіть якщо існує правило, але в ньому не вказано, ніж підсвічувати, залишаємо процедуру підсвічування
if (highlights.Count == 0)
return;

// Ще ряд умов для виходу з процедури підсвічування
var exitFlag = false;
exitFlag = exitFlag || string.IsNullOrWhiteSpace(_content);
Application.Current.Dispatcher.Invoke(new ThreadStart(() =>
{
exitFlag = exitFlag || Inlines.IsReadOnly || Inlines.Count == 0 || 
HighlightRules == null || HighlightRules.Count == 0;
}));

if (exitFlag)
return;

// Створимо параграф. Всі маніпуляції будемо проводити всередині нього, тому що виділити що-небудь
// безпосередньо у TextBlock не можна, якщо це виділення зачіпає кілька елементів
var par = new Paragraph();

// Парсим _content, в якому у нас сериализованный Span з оригінальним вмістом TextBlock'a.
var parsedSp = (Span)XamlReader.Parse(_content);

// Сам Span нам не потрібен, тому зливаємо всі його вміст в параграф
par.Inlines.AddRange(parsedSp.Inlines.ToArray());

// Позначаємо стартову позицію (просто для зручності) і висмикуємо з TextBlock'a голий текст. 
// Шукати входження шуканого рядка будемо саме в ньому
var firstPos = par.ContentStart;
var curText = string.Empty;
Application.Current.Dispatcher.Invoke(new ThreadStart(() =>
{
curText = Text;
}));

// Висмикуємо з основного потоку текст для підсвічування
var hlText = string.Empty;
Application.Current.Dispatcher.Invoke(new ThreadStart(() =>
{
hlText = rule.HightlightedText;
}));

// Якщо текст для підсвічування не порожній і його довжина не перевищує довжину тексту, в якому шукаємо, 
// то продовжимо, інакше просто виведемо в кінці оригінал
if (!string.IsNullOrEmpty(hlText) && hlText.Length <= curText.Length)
{
// Висмикуємо в основному потоці з правила властивість IgnoreCase.
// Вирішив логіку оставиьт в основному потоці, тому що навантаження операції дуже низька
// і не варто мого поту :)
var comparison = StringComparison.CurrentCulture;
Application.Current.Dispatcher.Invoke(new ThreadStart(() =>
{
comparison = rule.IgnoreCase ? StringComparison.CurrentCultureIgnoreCase : StringComparison.CurrentCulture;
}));

// Формуємо список індексів, звідки починаються входження шуканого рядка в тексті
var indexes = new List < int>();
var ind = curText.IndexOf(hlText, comparison);
while (ind > -1)
{
indexes.Add(ind);
ind = curText.IndexOf(hlText, ind + hlText.Length, StringComparison.CurrentCultureIgnoreCase);
}

TextPointer lastEndPosition = null;
// Проходимо по всіх індексах початку входження рядка пошуку в текст
foreach (var index in indexes)
{
// Ця змінна потрібна була в моїх соисканиях найкращого місця для початку пошуку,
// адже індекс положення string не відповідає реальному стану TextPointer'a.
// Пошук триває, тому змінну я залишив.
var curIndex = index;

// Починаємо пошук з останньою знайденою позиції або переміщуємо TextPointer вперед 
// на значення індексу входження підрядка в текст
var pstart = lastEndPosition ?? firstPos.GetInsertionPosition(LogicalDirection.Forward).GetPositionAtOffset(curIndex);

// startInd є довжиною тексту між початковим TextPointer і поточною точкою початку підсвічування
var startInd = new TextRange(pstart, firstPos.GetInsertionPosition(LogicalDirection.Forward)).Text.Length;

// В результаті нам потрібно, щоб startInd дорівнював curIndex
while (startInd != curIndex)
{
// Якщо чесно, мені неще не зустрічалися випадки, коли я обганяв startInd обганяв curIndex, однак
// вирішив залишити просування назад на випадок більш оптимізованого алгоритму пошуку
if (startInd < curIndex)
{
// Зміщуємо точку початку підсвічування на різницю curIndex - startInd
var newpstart = pstart.GetPositionAtOffset(curIndex - startInd);

// Іноді TextPointer виявляється між \r \n в цьому випадку початок підсвічування
// зрушується вперед. Щоб цього уникнути, рухаємо його в наступну позицію для вставки
if (newpstart.GetPointerContext(LogicalDirection.Forward) == TextPointerContext.ElementEnd)
newpstart = newpstart.GetInsertionPosition(LogicalDirection.Forward);

var len = new TextRange(pstart, newpstart).Text.Length;
startInd += len;
pstart = newpstart;
}
else
{
var newpstart = pstart.GetPositionAtOffset(curIndex - startInd);
var len = new TextRange(pstart, newpstart).Text.Length;
startInd -= len;
pstart = newpstart;
}
}

// Шукаємо кінцеву точку підсвічування аналогічним способом, як для початкової
var pend = pstart.GetPositionAtOffset(hlText.Length);
var delta = new TextRange(pstart, pend).Text.Length;
while (delta != hlText.Length)
{
if (delta < hlText.Length)
{
var newpend = pend.GetPositionAtOffset(hlText.Length - delta);
var len = new TextRange(pend, newpend).Text.Length;
delta += len;
pend = newpend;
}
else
{
var newpend = pend.GetPositionAtOffset(hlText.Length - delta);
var len = new TextRange(pend, newpend).Text.Length;
delta -= len;
pend = newpend;
}
}

// На жаль, запропонованим способом не вдається розділити Hyperlink.
// Швидше за все це доведеться робити вручну, але поки такої необхідності немає, 
// тому, якщо початкової або кінцевої частиною підсвічування ми ріжемо гіперпосилання,
// то просто зрушуємо ці позиції. Загалом посилання або повністю попадає в підсвітку,
// або не потрапляє зовсім
var sHyp = (pstart?.Parent as Inline)?.Parent as Hyperlink;
var eHyp = (pend?.Parent as Inline)?.Parent as Hyperlink;
if (sHyp != null)
pstart = pstart.GetNextContextPosition(LogicalDirection.Forward);

if (eHyp != null)
pend = pend.GetNextContextPosition(LogicalDirection.Backward);

// Ну а тут застосовуємо до виділення підсвічування.
if (pstart.GetOffsetToPosition(pend) > 0)
{
var sp = new Span(pstart, pend);
foreach (var hl in highlights)
hl.SetHighlight(sp);
}
lastEndPosition = pend;
}
}

// Тут сериализуем цей параграф і в основному потоці поміщаємо його вміст в TextBlock
var parStr = XamlWriter.Save(par);
Application.Current.Dispatcher.BeginInvoke(new ThreadStart(() =>
{
Inlines.Clear();
Inlines.AddRange(((Paragraph)XamlReader.Parse(parStr)).Inlines.ToArray());
})).Wait();
}

void TextBlock_Loaded(object sender, RoutedEventArgs e)
{
// Тут смикаємо наповнення TextBlock'a і сериализуем його в рядок,
// щоб накочувати підсвічування завжди на оригінал.
// Це краще винести в окремий потік, але поки що і так зійде.
var sp = Span new();
sp.Inlines.AddRange(Inlines.ToArray());
var tr = new TextRange(sp.ContentStart, sp.ContentEnd);
using (var stream = new MemoryStream())
{
tr.Save(stream, DataFormats.Xaml);
stream.Position = 0;
using(var reader = new StreamReader(stream))
{
_content = reader.ReadToEnd();
}
}
Inlines.AddRange(sp.Inlines.ToArray());

// Запускаємо підсвічування для всіх правил
foreach (var rule in HighlightRules)
BeginHighlight(rule);
}
}


Я не буду тут описувати код, тому що коментарі, на мій погляд, надлишкові.

Ось код черги завдань:

Заголовок спойлера
public class TaskQueue
{
Task _worker;
Queue<Action> _queue;
int _maxTasks;
bool _deleteOld;
object _lock = new object();

public TaskQueue(int maxTasks, bool deleteOld = true)
{
if (maxTasks < 1)
throw new ArgumentException("TaskQueue: максимальне число завдань повинно бути більше 0");
_maxTasks = maxTasks;
_deleteOld = deleteOld;
_queue = new Queue<Action>(maxTasks);
}

public bool Add(Action action)
{
if (_queue.Count() < _maxTasks)
{
_queue.Enqueue(action);
DoWorkAsync();
return true;
}
if (_deleteOld)
{
_queue.Dequeue();
return Add(action);
}
return false;
}

void DoWorkAsync()
{
if(_queue.Count>0)
_worker = Task.Factory.StartNew(DoWork);
}

void DoWork()
{
lock (_lock)
{
if (_queue.Count > 0)
{
var currentTask = Task.Factory.StartNew(_queue.Dequeue());
currentTask.Wait();
DoWorkAsync();
}
}
}
}



Тут все досить просто. Надходить нова задача. Якщо в черзі є місце, то вона поміщається в чергу. Інакше, якщо поле _deleteOld == true, то видаляємо наступну задачу (найбільш пізню) і поміщаємо нову, інакше повертаємо false (завдання додано).

Ось код колекції правил. По ідеї, можна було обійтися ObservableCollection, але від цієї колекції в подальшому може знадобитися додатковий функціонал.

Заголовок спойлера
public class HighlightRulesCollection : DependencyObject, INotifyCollectionChanged, ICollectionViewFactory, IList, IList<HighlightRule>
{
ObservableCollection<HighlightRule> _items;

public HighlightRulesCollection()
{
_items = new ObservableCollection<HighlightRule>();
_items.CollectionChanged += _items_CollectionChanged;
}

public HighlightRule this[int index]
{
get
{
return ((IList<HighlightRule>)_items)[index];
}

set
{
((IList<HighlightRule>)_items)[index] = value;
}
}

object IList.this[int index]
{
get
{
return ((IList)_items)[index];
}

set
{
((IList)_items)[index] = value;
}
}

public int Count
{
get
{
return ((IList<HighlightRule>)_items).Count;
}
}

public bool IsFixedSize
{
get
{
return ((IList)_items).IsFixedSize;
}
}

public bool IsReadOnly
{
get
{
return ((IList<HighlightRule>)_items).IsReadOnly;
}
}

public bool IsSynchronized
{
get
{
return ((IList)_items).IsSynchronized;
}
}

public object SyncRoot
{
get
{
return ((IList)_items).SyncRoot;
}
}

public event NotifyCollectionChangedEventHandler CollectionChanged;

public int Add(object value)
{
return ((IList)_items).Add(value);
}

public void Add(HighlightRule item)
{
((IList<HighlightRule>)_items).Add(item);
}

public void Clear()
{
((IList<HighlightRule>)_items).Clear();
}

public bool Contains(object value)
{
return ((IList)_items).Contains(value);
}

public bool Contains(HighlightRule item)
{
return ((IList<HighlightRule>)_items).Contains(item);
}

public void CopyTo(Array array, int index)
{
((IList)_items).CopyTo(array, index);
}

public void CopyTo(HighlightRule[] array, int arrayIndex)
{
((IList<HighlightRule>)_items).CopyTo(array, arrayIndex);
}

public ICollectionView CreateView()
{
return new CollectionView(_items);
}

public IEnumerator<HighlightRule> GetEnumerator()
{
return ((IList<HighlightRule>)_items).GetEnumerator();
}

public int IndexOf(object value)
{
return ((IList)_items).IndexOf(value);
}

public int IndexOf(HighlightRule item)
{
return ((IList<HighlightRule>)_items).IndexOf(item);
}

public void Insert(int index, object value)
{
((IList)_items).Insert(index, value);
}

public void Insert(int index, HighlightRule item)
{
((IList<HighlightRule>)_items).Insert(index, item);
}

public void Remove(object value)
{
((IList)_items).Remove(value);
}

public bool Remove(HighlightRule item)
{
return ((IList<HighlightRule>)_items).Remove(item);
}

public void RemoveAt(int index)
{
((IList<HighlightRule>)_items).RemoveAt(index);
}

IEnumerator IEnumerable.GetEnumerator()
{
return ((IList<HighlightRule>)_items).GetEnumerator();
}

void _items_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
{
CollectionChanged?.Invoke(this, e);
}

}


Ось код правила підсвічування:

Заголовок спойлера
public class HighlightRule : DependencyObject
{
public delegate void HighlightTextChangedEventHandler(object sender, HighlightTextChangedEventArgs e);

public event HighlightTextChangedEventHandler HighlightTextChanged;

public HighlightRule()
{
Highlights = new ObservableCollection<Highlight>();
}

/// <summary>
/// Текст, який потрібно підсвітити
/ / / < /summary>
public string HightlightedText
{
get { return (string)GetValue(HightlightedTextProperty); }
set { SetValue(HightlightedTextProperty, value); }
}

public static readonly DependencyProperty HightlightedTextProperty =
DependencyProperty.Register("HightlightedText", typeof(string), typeof(HighlightRule), new FrameworkPropertyMetadata(string.Empty, HighlightPropertyChanged));

public static void HighlightPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var me = d as HighlightRule;
if (me != null)
me.HighlightTextChanged?.Invoke(me, new HighlightTextChangedEventArgs((string)e.OldValue, (string)e.NewValue));
}

/// <summary>
/// Ігнорувати регістр? 
/ / / < /summary>
public bool IgnoreCase
{
get { return (bool)GetValue(IgnoreCaseProperty); }
set { SetValue(IgnoreCaseProperty, value); }
}

public static readonly DependencyProperty IgnoreCaseProperty =
DependencyProperty.Register("IgnoreCase", typeof(bool), typeof(HighlightRule), new PropertyMetadata(true));


/// <summary>
/// Колекція підсвічувань
/ / / < /summary>
public ObservableCollection<Highlight> Highlights
{
get
{
return (ObservableCollection<Highlight>)GetValue(HighlightsProperty);
}
set { SetValue(HighlightsProperty, value); }
}

public static readonly DependencyProperty HighlightsProperty =
DependencyProperty.Register("Highlights", typeof(ObservableCollection<Highlight>), typeof(HighlightRule), new PropertyMetadata(null));


}

public class HighlightTextChangedEventArgs : EventArgs
{
public string OldText { get; }

public string новий текст { get; }

public HighlightTextChangedEventArgs(string oldText,string новий текст)
{
OldText = oldText;
Новий текст = новий текст;
}
}


Ніякої логіки тут немає майже, тому без коментарів.

Ось абстрактний клас для підсвічування:

public abstract class Highlight : DependencyObject
{
public abstract void SetHighlight(Span span);

public abstract void SetHighlight(TextRange range);
}

Мені на даний момент відомо два способи підсвітити фрагмент. Через Span і через TextRange. Поки що обраний спосіб залізно прописаний в коді у процедурі підсвічування, але надалі я планую зробити це опціонально.

Ось спадкоємець для підсвічування фону
public class HighlightBackgroung : Highlight
{
public override void SetHighlight(Span span)
{
Brush brush = null;
Application.Current.Dispatcher.BeginInvoke(new ThreadStart(() =>
{
brush = Brush;
})).Wait();
span.Background = brush;
}

public override void SetHighlight(TextRange range)
{
Brush brush = null;
Application.Current.Dispatcher.BeginInvoke(new ThreadStart(() =>
{
brush = Brush;
})).Wait();
range.ApplyPropertyValue(TextElement.BackgroundProperty, brush);
}

/// <summary>
/// Кисть для підсвічування фону
/ / / < /summary>
public Brush Brush
{
get
{
return (Brush)GetValue(BrushProperty);
}
set { SetValue(BrushProperty, value); }
}

public static readonly DependencyProperty BrushProperty =
DependencyProperty.Register("Brush", typeof(Brush), typeof(HighlightBackgroung), new PropertyMetadata(Brushes.Transparent));


}


Ну тут нема чого коментувати, крім безпеки потоків. Справа в тому, що примірник повинен крутитися в основному потоці, а метод може бути викликаний звідки завгодно.

А це код підсвічування кольором тексту
public class HighlightForeground : Highlight
{
public override void SetHighlight(Span span)
{
Brush brush = null;
Application.Current.Dispatcher.BeginInvoke(new ThreadStart(() =>
{
brush = Brush;
})).Wait();
span.Foreground = brush;
}

public override void SetHighlight(TextRange range)
{
Brush brush = null;
Application.Current.Dispatcher.BeginInvoke(new ThreadStart(() =>
{
brush = Brush;
})).Wait();
range.ApplyPropertyValue(TextElement.ForegroundProperty, brush);
}

/// <summary>
/// Кисть для кольору тексту
/ / / < /summary>
public Brush Brush
{
get { return (Brush)GetValue(BrushProperty); }
set { SetValue(BrushProperty, value); }
}

public static readonly DependencyProperty BrushProperty =
DependencyProperty.Register("Brush", typeof(Brush), typeof(HighlightForeground), new PropertyMetadata(Brushes.Black));
}


Висновок
Ну ось мабуть і все. Хотілося б почути вашу думку.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.