Selection highlight and focus on WPF TextBox
For version 1.0 naracea uses WPF TextBox as a base for text editing control. It is simple, it does what is needed to accomplish all infinite undo buffer features and it is already in .NET framework. And in some aspects it really… errr… doesn’t meet expectations. Main problem with the control is that it is impossible to do any custom painting on it without huge amount of code and some dirty tricks (all implementations of custom painting on WPF TextBox set text and background to transparent and then do all the formatting and painting one more time).
For v1.0 I don’t need syntax highlighting and text collapsing etc. This will come later, when all the 1.0 work is done. But I want my nice find and replace which shows matched text as selection, and WPF TextBox fails even in this simple task.
If I remember it right, WinForms TextBox has feature which allows user to set whether selection highlight should be visible even when control lost focus. There is no such thing for WPF TextBox. On stackoverflow you can find some advices to pretend focus lost event was handled when it actually wasn’t, but this approach results in this:
When selection is made before TextBox loses focus, it stays there until focus is gained again, and selection made programmatically by setting SelectionStart and SelectionLength properties is not visible. And since drawing highlight rectangle would require all those dirty hacks, I was looking for other solution.
And here it is: I use adorners. I add AdornerDecorator around Grid which holds TextEditor (which is my own class derived from TextBox), and by overriding couple of got-focus/lost-focus methods I paint selection highlight over the TextEditor on adorner layer.
Here is my TextEditor (shorten for brevity):
public partial class TextEditor : System.Windows.Controls.TextBox { public SelectionHighlightAdorner SelectionHighlightAdorner { get; set; } public bool ShouldSuppressSelectionHighlightAdorner { get; private set; } private void UpdateSelectionHighlightAdorner() { this.ShouldSuppressSelectionHighlightAdorner = false; this.SelectionHighlightAdorner.InvalidateVisual(); } protected override void OnPropertyChanged(DependencyPropertyChangedEventArgs e) { base.OnPropertyChanged(e); if( this.SelectionHighlightAdorner != null ) { switch( e.Property.Name ) { case "FontFamily": this.ShouldSuppressSelectionHighlightAdorner = true; break; case "FontSize": this.ShouldSuppressSelectionHighlightAdorner = true; break; default: this.ShouldSuppressSelectionHighlightAdorner = false; break; } this.SelectionHighlightAdorner.InvalidateVisual(); } } protected override void OnLostKeyboardFocus(KeyboardFocusChangedEventArgs e) { base.OnLostKeyboardFocus(e); this.UpdateSelectionHighlightAdorner(); } protected override void OnGotKeyboardFocus(KeyboardFocusChangedEventArgs e) { base.OnGotKeyboardFocus(e); this.UpdateSelectionHighlightAdorner(); } protected override void OnSelectionChanged(System.Windows.RoutedEventArgs e) { base.OnSelectionChanged(e); this.UpdateSelectionHighlightAdorner(); e.Handled = true; } }
(Disabling FontFamily and FontSize is needed when ribbon preview on font is ongoing.)
The adorner itself:
public class SelectionHighlightAdorner : Adorner { TextEditor _editor; public SelectionHighlightAdorner(TextEditor editor) : base(editor) { _editor = editor; _editor.SelectionHighlightAdorner = this; } protected override void OnRender(DrawingContext drawingContext) { if( _editor.SelectionLength > 0 && !_editor.IsKeyboardFocused && !_editor.ShouldSuppressSelectionHighlightAdorner ) { drawingContext.PushClip(new RectangleGeometry(new Rect(0, 0, _editor.ActualWidth, _editor.ActualHeight))); int firstCharIndex = _editor.SelectionStart; int lastCharIndex = firstCharIndex + _editor.SelectionLength; var firstCharRect = _editor.GetRectFromCharacterIndex(firstCharIndex); var lastCharRect = _editor.GetRectFromCharacterIndex(lastCharIndex); var highlightGeometry = new GeometryGroup(); if( firstCharRect.Top == lastCharRect.Top ) { // single line selection highlightGeometry.Children.Add(new RectangleGeometry(new Rect(firstCharRect.TopLeft, lastCharRect.BottomRight))); } else { int firstVisibleLine = _editor.GetFirstVisibleLineIndex(); int lastVisibleLine = _editor.GetLastVisibleLineIndex(); if( _editor.GetLineIndexFromCharacterIndex(firstCharIndex) < firstVisibleLine ) { firstCharIndex = _editor.GetCharacterIndexFromLineIndex(firstVisibleLine - 1); firstCharRect = _editor.GetRectFromCharacterIndex(firstCharIndex); } if( _editor.GetLineIndexFromCharacterIndex(lastCharIndex) > lastVisibleLine ) { lastCharIndex = _editor.GetCharacterIndexFromLineIndex(lastVisibleLine + 1); lastCharRect = _editor.GetRectFromCharacterIndex(lastCharIndex); } var lineHeight = firstCharRect.Height; var lineCount = (int)Math.Round( (lastCharRect.Top - firstCharRect.Top ) / lineHeight); var lineLeft = firstCharRect.Left; var lineTop = firstCharRect.Top; var currentCharIndex = firstCharIndex; for( int i = 0; i <= lineCount; i++ ) { var lineIndex = _editor.GetLineIndexFromCharacterIndex(currentCharIndex); var firstLineCharIndex = _editor.GetCharacterIndexFromLineIndex(lineIndex); var lineLength = _editor.GetLineLength(lineIndex); var lastLineCharIndex = firstLineCharIndex + lineLength - 1; if( lastLineCharIndex > lastCharIndex ) { lastLineCharIndex = lastCharIndex; } var lastLineCharRect = _editor.GetRectFromCharacterIndex(lastLineCharIndex); var lineWidth = lastLineCharRect.Right - lineLeft; if( Math.Round(lineWidth) <= 0 ) { lineWidth = 5; } highlightGeometry.Children.Add(new RectangleGeometry(new Rect(lineLeft, lineTop, lineWidth, lineHeight))); currentCharIndex = firstLineCharIndex + lineLength; var nextLineFirstCharRect = _editor.GetRectFromCharacterIndex(currentCharIndex); lineLeft = nextLineFirstCharRect.Left; lineTop = nextLineFirstCharRect.Top; } } drawingContext.PushOpacity(0.4); drawingContext.DrawGeometry(SystemColors.HighlightBrush, null, highlightGeometry); } } }
This implementation does some optimizations on how much of text needs to be adorned and also reproduces original selection highlight as much as possible (including multiline selections).
Putting it together:
textEditorHolder.Children.Add(textEditor); var layer = System.Windows.Documents.AdornerLayer.GetAdornerLayer(textEditor); layer.Add(new SelectionHighlightAdorner(textEditor));
And the XAML snippet:
<AdornerDecorator> <Grid Name="textEditorHolder" MinWidth="100" HorizontalAlignment="Stretch"/> </AdornerDecorator>
It is probably not the nicest solution, but until I replace WPF TextBox with something more powerful, it does the job pretty well: