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:
(Not so) Ugly truth
There is no nice way to say it, so I’ll say it straight: naracea consumes memory. Lots of memory, considering it is a text editor. Yes. It is true.
Now, let me explain a bit. When I started working on the editor, I was considering how much memory usage is OK, and I did some calculations of how much RAM will it take to keep the whole history of longer document. After being afraid of the results for a while, I realized that the question is wrong. The right question is: what is the cost of losing some text for me, and the cost of rewriting it. And looking on the problem from this side, I’ve found, that couple of extra megabytes is really, really cheap.
And this is what I’m thinking since then. The memory is cheap. It is so cheap, that you don’t need to think about it. You can buy couple of extra gigabytes of RAM for the price of the dinner, and similar is true for the disk space (well, it is not so true when thinking about SSD drives, but even those are getting cheaper as I type).
I’m regularly testing naracea’s performance on couple of large documents (mostly lorem ipsum with lots of copy & cut & paste as changes), and for my reference document (around 700 thousands of single character changes), it takes about 200MB of RAM to keep all the changes in memory. There is also some more memory consumed when history searches are ongoing, but for document of this size it doesn’t add too much to the overall memory footprint. And here is what I think: 200MB it is nothing. My computer has 8 gigs of RAM, pretty much every laptop you can buy today has at least 3GB of RAM. The only low-memory PCs today are some very cheap netbooks, and yes, people use them to do lots of on-the-road editing and writing, but even there 200MB is almost nothing. Right now I have a tab open in Chrome which uses twice as much memory and I do not find anything bad or scary about it.
So it seems that times when memory consumption was important are over.
On the other hand, saving the document to the file is a completely different story. From the very beginning I knew naracea will need its own proprietary file format. I’ve tried several different persistence strategies (I’ll write about the long and sad story of naracea’s file format evolution in one of the future posts), and what I ended up with for several reasons is XML. The XML it not known as the most succinct format on the planet, and files are very different from memory structures. Files tend to be moved around in emails, on USB sticks and so on, so for file it makes lot of sense to make is as small as possible. Therefore the whole document in XML is compressed while it is being saved, with the compression level set so it gives reasonable file sizes at minimum performance penalty.
So the saved reference document with 700k changes takes around 3.5MB of the disk space. That means approximately 5 bytes for one character in the editor. And I think that’s reasonable price for the features I get from the editor.
Find and replace
Designing find and replace for naracea was so much fun for me.
You know, I’ve always felt somehow dissatisfied with how searching works in most text editors. There are usually two things: a) find next/find previous and b) find all. And once you edit something in front of the text you have searched for, you must search again, because search results are not being updated. Let’s face it: every search engine struggling with limited browser abilities have better UI for search results than most of desktop text editors backed with all those fine tuned UI frameworks.
So after a short period when I was considering going the usual way, I decided to try to do something little more user friendly with search:
What we have here is seemingly the usual set of next/prev/all buttons, including some replace buttons (because I believe finding and replacing are related so much in text editing, that they should never be split in two windows).
As you can see, results contain a lot of additional information. There is text surrounding matched term, so it is easier to see which result is the right one and there is information about position in the text. Also document and branch where the match was found are there. And as the finishing touch, there is replace button on each result item, so you can replace the matched text right there, without moving the mouse pointer to the top of the window.
Also: searching for something else (or in some other document or document’s branch) doesn’t necessarily clear the results. It is possible to have listing of several searches in the results list. Clicking on the result will take you to document, branch and text position of the match.
But what happens if we insert or delete some characters in the text? Right, the search results get updated as the text changes, so even after you edited the document, the results are still pointing to that one occurrence of the text we have searched for.
This seems more like what I would like to have in all text editors with find and replace.
When this was finished, I’ve started thinking how timeline, while useful, is just not enough to make all those stored changes really useful. To be really able to find something in history of the document there needs to be a way to search it.
And here it is: by selecting “Search the text and its history” in combobox above the replace all and find all buttons, you can search the whole history of the document. Of course, it is not possible to replace anything, because as we know history cannot be changed, but using this feature you can easily find whatever text you’ve written though the whole existence of the document:
Of course, for long documents, it takes some time to go through all the changes and find all occurrences of the word or sentence, so the searching of the history runs asynchronously, and it is possible to write more text while the search is ongoing. The search always starts at the current change and the changes are rewound one by one and the rewound document is then searched for the search term.
So this is how search and replace works in naracea. There are couple of interesting scenarios which are enabled by the history searches, but I’ll keep these for future posts.