diff --git a/samples/AvalonEdit.Sample/AvalonEdit/renderingPipeline.png b/samples/AvalonEdit.Sample/AvalonEdit/renderingPipeline.png new file mode 100644 index 0000000000..9e7b7e308d Binary files /dev/null and b/samples/AvalonEdit.Sample/AvalonEdit/renderingPipeline.png differ diff --git a/samples/AvalonEdit.Sample/ColorizeAvalonEdit.cs b/samples/AvalonEdit.Sample/ColorizeAvalonEdit.cs index ee0c6df71b..ebed3d2998 100644 --- a/samples/AvalonEdit.Sample/ColorizeAvalonEdit.cs +++ b/samples/AvalonEdit.Sample/ColorizeAvalonEdit.cs @@ -44,7 +44,8 @@ namespace AvalonEdit.Sample // This lambda gets called once for every VisualLineElement // between the specified offsets. Typeface tf = element.TextRunProperties.Typeface; - // Replace the typeface with a modified version of the same typeface + // Replace the typeface with a modified version of + // the same typeface element.TextRunProperties.SetTypeface(new Typeface( tf.FontFamily, FontStyles.Italic, diff --git a/samples/AvalonEdit.Sample/article.html b/samples/AvalonEdit.Sample/article.html index 5b017cd610..88219fe178 100644 --- a/samples/AvalonEdit.Sample/article.html +++ b/samples/AvalonEdit.Sample/article.html @@ -19,7 +19,7 @@ scripts and style sheets.
-So, what is the model of a text editor that has support for complex features like syntax highlighting and folding?
+Would you expect to be able to access collapsed text using the document model, given that the text is folded away?
+Is the syntax highlighting part of the model?
+
+
In my quest for a good representation of the model, I decided on a radical strategy:
+if it's not a char
, it's not in the model!
+
+
The main class of the model is ICSharpCode.AvalonEdit.Document.TextDocument
.
+Basically, the document is a StringBuilder
with events.
+However, the Document
namespace also contains several features that are useful to applications working with the text editor.
+
+
In the text editor, all three controls (TextEditor
, TextArea
, TextView
) have a Document
property pointing to the TextDocument
instance.
+You can change the Document
property to bind the editor to another document; but please only do so on the outermost control (usually TextEditor
), it will inform its child controls about that change.
+Changing the document only on a child control would leave the outer controls confused.
+
+
Simplified definition of TextDocument
:
+
public sealed class TextDocument : ITextSource
+{
+ public event EventHandler UpdateStarted;
+ public event EventHandler<DocumentChangeEventArgs> Changing;
+ public event EventHandler<DocumentChangeEventArgs> Changed;
+ public event EventHandler TextChanged;
+ public event EventHandler UpdateFinished;
+
+ public TextAnchor CreateAnchor(int offset);
+ public ITextSource CreateSnapshot();
+
+ public IList<DocumentLine> Lines { get; }
+ public DocumentLine GetLineByNumber(int number);
+ public DocumentLine GetLineByOffset(int offset);
+ public TextLocation GetLocation(int offset);
+ public int GetOffset(int line, int column);
+
+ public char GetCharAt(int offset);
+ public string GetText(int offset, int length);
+
+ public void BeginUpdate();
+ public bool IsInUpdate { get; }
+ public void EndUpdate();
+
+ public void Insert(int offset, string text);
+ public void Remove(int offset, int length);
+ public void Replace(int offset, int length, string text);
+
+ public string Text { get; set; }
+ public int LineCount { get; }
+ public int TextLength { get; }
+ public UndoStack UndoStack { get; }
+}
+
+Offsets usually represent the position between two characters.
+The first offset at the start of the document is 0; the offset after the first char
in the document is 1.
+The last valid offset is document.TextLength
, representing the end of the document.
+
+
This is exactly the same as the 'index' parameter used by methods in the .NET String
or StringBuilder
classes.
+Offsets are used because they are dead simple. To all text between offset 10 and offset 30,
+simply call document.GetText(10, 20)
– just like String.Substring
, AvalonEdit usually uses Offset / Length
pairs to refer to text segments.
+
+
To easily pass such segments around, AvalonEdit defines the ISegment
interface:
+
public interface ISegment
+{
+ int Offset { get; }
+ int Length { get; } // must be non-negative
+ int EndOffset { get; } // must return Offset+Length
+}
+All TextDocument
methods taking Offset/Length parameters also have an overload taking an ISegment
instance – I have just removed those from the code listing above to make it easier to read.
+
+struct
called TextLocation
for those.
+
+The document provides the methods GetLocation
and GetOffset
to convert between offsets and TextLocation
s.
+Those are convenience methods built on top of the DocumentLine
class.
+
+
The TextDocument.Lines
collection contains one DocumentLine
instance for every line in the document.
+This collection is read-only to user code and is automatically updated to always* reflect the current document content.
+
+
Internally, the DocumentLine
instances are arranged in a binary tree that allows for both efficient updates and lookup.
+Converting between offset and line number is possible in O(lg N) time, and the data structure also updates all offsets in O(lg N) whenever text is inserted/removed.
+
+
+
* tiny exception: it is possible to see the line collection in an inconsistent state inside ILineTracker
callbacks. Don't use ILineTracker
+unless you know what you are doing!
+
+
BeginUpdate() +
UpdateStarted
event is raisedInsert() / Remove() / Replace() +
Changing
event is raisedTextAnchor.Deleted
events are raised if anchors were in the deleted text portionChanged
event is raisedEndUpdate() +
TextChanged
event is raisedTextLengthChanged
event is raisedLineCountChanged
event is raisedUpdateFinished
event is raisedIf the insert/remove/replace methods are called without a call to BeginUpdate()
, they will call
+BeginUpdate()
and EndUpdate()
to ensure no change happens outside of UpdateStarted
/UpdateFinished
.
+
+
There can be multiple document changes between the BeginUpdate()
and EndUpdate()
calls.
+In this case, the events associated with EndUpdate
will be raised only once after the whole document update is done.
+
+
The UndoStack
listens to the UpdateStarted
and UpdateFinished
events to group
+all changes into a single undo step.
+
+
Sure, you could listen to the TextDocument.Changed
event and call GetNewOffset
on the DocumentChangeEventArgs
to translate
+the offset, but that gets tedious; especially when your object is short-lived and you have to deal with deregistering the event handler at the correct point of time.
+
+
A much simpler solution is to use the TextAnchor
class. Usage:
+
TextAnchor anchor = document.CreateAnchor(offset);
+ChangeMyDocument();
+int newOffset = anchor.Offset;
+
+The document will automatically update all text anchors; and because it uses weak references to do so, the GC can simply collect the anchor object when you don't need it anymore. + +
Moreover, the document is able to efficiently update a large number of anchors without having to look at each anchor object individually. Updating the offsets of all anchors
+usually only takes time logarithmic to the number of anchors. Retrieving the TextAnchor.Offset
property also runs in O(lg N).
+
+
When a piece of text containing an anchor is removed; that anchor will be deleted. First, the TextAnchor.IsDeleted
property is set to true on all deleted anchors, then the
+TextAnchor.Deleted
events are raised. You cannot retrieve the offset from an anchor that has been deleted.
+
+
This deletion behavior might be useful when using anchors for building a bookmark feature, but in other cases you want to still be able to use the anchor. For those cases, set TextAnchor.SurviveDeletion = true
.
+
+
Note that anchor movement is ambiguous if text is inserted exactly at the anchor's location. Does the anchor stay before the inserted text, or does it move after it?
+The property TextAnchor.MovementType
will be used to determine which of these two options the anchor will choose. The default value is AnchorMovementType.BeforeInsertion
.
+
+
If you want to track a segment, you can use the AnchorSegment
class which implements ISegment
using two text anchors.
+
+
Sometimes it is useful to store a list of segments and be able to efficiently find all segments overlapping with some other segment.
+Example: you might want to store a large number of compiler warnings and render squiggly underlines only for those that are in the visible region of the document.
+
+
The TextSegmentCollection
serves this purpose. Connected to a document, it will automatically update the offsets of all TextSegment
instances inside the collection;
+but it also has the useful methods FindOverlappingSegments
and FindFirstSegmentWithStartAfter
.
+The underlying data structure is a hybrid between the one used for text anchors and an interval tree, so it is able to do both jobs quite fast.
+
+
The TextDocument
class is not thread-safe. It expects to have a single owner thread and will throw an InvalidOperationException
when accessed from another thread.
+
+
However, there is a single method that is thread-safe: CreateSnapshot()
+It returns an immutable snapshot of the document, and may be safely called even when the owner thread is concurrently modifying the document.
+This is very useful for features like a background parser that is running on its own thread.
+The overload CreateSnapshot(out ChangeTrackingCheckpoint)
also returns a ChangeTrackingCheckpoint
for the document snapshot.
+Once you have two checkpoints, you can call GetChangesTo
to retrieve the complete list of document changes that happened between those versions of the document.
+
+
+
Did you learn anything interesting/fun/annoying while writing the code? Did you +do anything particularly clever or wild or zany? + +
Keep a running update of any changes or improvements you've made here. + +
Note: although my sample code is provided under the MIT license, ICSharpCode.AvalonEdit itself is provided under the terms of the GNU LGPL. + + +
The ICSharpCode.AvalonEdit.Rendering.TextView
class is the heart of AvalonEdit.
+It takes care of getting the document onto the screen.
+
+
To do this in an extensible way, the TextView
uses its own kind of model: the VisualLine
.
+Visual lines are created only for the visible part of the document.
+
The rendering process looks like this:
+
+The last step in the pipeline is the conversion to one or more System.Windows.Media.TextFormatting.TextLine
instances. WPF then takes care of the actual text rendering.
+
+
TextView
needs to construct visual lines (usually before rendering), it first
+determines which DocumentLine
is the top-most visible line in the currently viewed region.
+From there, it starts to build visual lines and also immediately does the conversion to TextLine
(word-wrapping).
+The process stops once the viewed document region is filled.
+
+The resulting visual lines (and TextLine
s) will be cached and reused in future rendering passes.
+When the user scrolls down, only the visual lines coming into view are created, the rest is reused.
+
+The TextView.Redraw
methods are used to remove visual lines from the cache.
+AvalonEdit will redraw automatically on the affected lines when the document is changed; and will invalidate the whole cache
+when any editor options are changed. You will only have to call Redraw
manually if you write extensions to the visual line creation process
+that maintain their own data source. For example, the FoldingManager
invokes Redraw
whenever text sections are expanded or collapsed.
+
+Calling Redraw
does not cause immediate recreation of the lines.
+They are just removed from the cache so that the next rendering step will recreate them.
+All redraw methods will enqueue a new rendering step, using the WPF Dispatcher with a low priority.
+
+
DocumentLength
measured in characters as well as a logical length called VisualLength
.
+For normal text elements, the two lengths are identical; but some elements like fold markers may have a huge document length, yet a logical length of 1.
+On the other hand, some elements that are simply inserted by element generators may have a document length of 0, but still need a logical length of at least 1 to allow
+addressing elements inside the visual line.
+
+The VisualColumn
is a position inside a visual line as measured by the logical length of elements. It is counted starting from 0 at the begin of the visual line.
+Also, inside visual lines, instead of normal offsets to the text document; relative offsets are used.
+Absolute offset = relative offset + VisualLine.FirstDocumentLine.Offset
+This means that offsets inside the visual line do not have to be adjusted when text is inserted or removed in front of the visual line; we simply rely on the document
+automatically updating DocumentLine.Offset
.
+
+The main job of a visual line element is to implement the CreateTextRun
method.
+This method should return a System.Windows.Media.TextFormatting.TextRun
instance that can be rendered using the TextLine
class.
+
+Visual line elements can also handle mouse clicks and control how the caret should move. The mouse click handling might suffice as a light-weight alternative
+to embedding inline UIElement
s in the visual lines.
+
+
VisualLineElementGenerator
in the TextView.ElementGenerators
collection.
+This allows you to add custom VisualLineElements
.
+Using the InlineObjectElement
class, you can even put interactive WPF controls (anything derived from UIElement
) into the text document.
+
+For all document text not consumed by element generators, AvalonEdit will create VisualLineText
elements.
+
+Usually, the construction of the visual line will stop at the end of the DocumentLine
. However, if some VisualLineElementGenerator
+creates an element that's longer than the rest of the line, construction of the visual line may resume in another DocumentLine
.
+Currently, only the FoldingElementGenerator
can cause one visual line to span multiple DocumentLine
s.
+
+
+
+Here is the full source code for a class that implements embedding images into AvalonEdit: +
public class ImageElementGenerator : VisualLineElementGenerator
+{
+ readonly static Regex imageRegex = new Regex(@"<img src=""([\.\/\w\d]+)""/?>",
+ RegexOptions.IgnoreCase);
+ readonly string basePath;
+
+ public ImageElementGenerator(string basePath)
+ {
+ if (basePath == null)
+ throw new ArgumentNullException("basePath");
+ this.basePath = basePath;
+ }
+
+ Match FindMatch(int startOffset)
+ {
+ // fetch the end offset of the VisualLine being generated
+ int endOffset = CurrentContext.VisualLine.LastDocumentLine.EndOffset;
+ TextDocument document = CurrentContext.Document;
+ string relevantText = document.GetText(startOffset, endOffset - startOffset);
+ return imageRegex.Match(relevantText);
+ }
+
+ /// Gets the first offset >= startOffset where the generator wants to construct
+ /// an element.
+ /// Return -1 to signal no interest.
+ public override int GetFirstInterestedOffset(int startOffset)
+ {
+ Match m = FindMatch(startOffset);
+ return m.Success ? (startOffset + m.Index) : -1;
+ }
+
+ /// Constructs an element at the specified offset.
+ /// May return null if no element should be constructed.
+ public override VisualLineElement ConstructElement(int offset)
+ {
+ Match m = FindMatch(offset);
+ // check whether there's a match exactly at offset
+ if (m.Success && m.Index == 0) {
+ BitmapImage bitmap = LoadBitmap(m.Groups[1].Value);
+ if (bitmap != null) {
+ Image image = new Image();
+ image.Source = bitmap;
+ image.Width = bitmap.PixelWidth;
+ image.Height = bitmap.PixelHeight;
+ // Pass the length of the match to the 'documentLength' parameter
+ // of InlineObjectElement.
+ return new InlineObjectElement(m.Length, image);
+ }
+ }
+ return null;
+ }
+
+ BitmapImage LoadBitmap(string fileName)
+ {
+ // TODO: add some kind of cache to avoid reloading the image whenever the
+ // VisualLine is reconstructed
+ try {
+ string fullFileName = Path.Combine(basePath, fileName);
+ if (File.Exists(fullFileName)) {
+ BitmapImage bitmap = new BitmapImage(new Uri(fullFileName));
+ bitmap.Freeze();
+ return bitmap;
+ }
+ } catch (ArgumentException) {
+ // invalid filename syntax
+ } catch (IOException) {
+ // other IO error
+ }
+ return null;
+ }
+}
+
+
+The base classes ColorizingTransformer
and DocumentColorizingTransformer
help with this task
+by providing helper methods for colorizing that split up visual line elements where necessary. The difference between
+the two classes is that one works using visual columns whereas the other one uses offsets into the document.
+
+Here is an example DocumentColorizingTransformer
that highlights the word 'AvalonEdit' using bold font:
+
public class ColorizeAvalonEdit : DocumentColorizingTransformer
+{
+ protected override void ColorizeLine(DocumentLine line)
+ {
+ int lineStartOffset = line.Offset;
+ string text = CurrentContext.Document.GetText(line);
+ int start = 0;
+ int index;
+ while ((index = text.IndexOf("AvalonEdit", start)) >= 0) {
+ base.ChangeLinePart(
+ lineStartOffset + index, // startOffset
+ lineStartOffset + index + 10, // endOffset
+ (VisualLineElement element) => {
+ // This lambda gets called once for every VisualLineElement
+ // between the specified offsets.
+ Typeface tf = element.TextRunProperties.Typeface;
+ // Replace the typeface with a modified version of
+ // the same typeface
+ element.TextRunProperties.SetTypeface(new Typeface(
+ tf.FontFamily,
+ FontStyles.Italic,
+ FontWeights.Bold,
+ tf.Stretch
+ ));
+ });
+ start = index + 1; // search for next occurrence
+} } }
+
+
+AvalonEdit contains the class BackgroundGeometryBuilder
that helps with this task.
+You can use the static BackgroundGeometryBuilder.GetRectsForSegment
to fetch a list of rectangles that
+contain text from the specified segment (you will get one rectangle per TextLine
);
+or you can use the instance methods to build a PathGeometry
for the text's outline.
+AvalonEdit also internally uses this geometry builder to create the selection with the rounded corners.
+
+Inside SharpDevelop, the first option (getting list of rectangles) is used to render the squiggly red line that for compiler errors, +while the second option is used to produce nice-looking breakpoint markers. + +
TextArea
class is handling user input and executing the appropriate actions.
+Both the caret and the selection are controlled by the TextArea
.
+
+You can customize the text area by modifying the TextArea.DefaultInputHandler
by adding new or replacing existing
+WPF input bindings in it. You can also set TextArea.ActiveInputHandler
to something different than the default
+to switch the text area into another mode. You could use this to implement an "incremental search" feature, or even a VI emulator.
+
+The text area has the useful LeftMargins
property - use it to add controls to the left of the text view that look like
+they're inside the scroll viewer, but don't actually scroll. The AbstractMargin
base class contains some useful code
+to detect when the margin is attached/detaching from a text view; or when the active document changes. However, you're not forced to use it;
+any UIElement
can be used as margin.
+
+
VisualLineElementGenerator
takes care of the collapsed sections in the text document; and a custom margin draws the plus and minus
+buttons.
+
+That's exactly how folding is implemented in AvalonEdit. However, to make it a bit easier to use; the static FoldingManager.Install
+method will create and register the necessary parts automatically.
+
+All that's left for you is to regularly call FoldingManager.UpdateFoldings
with the list of foldings you want to provide.
+
+Here is the full code required to enable folding: +
foldingManager = FoldingManager.Install(textEditor.TextArea);
+foldingStrategy = new XmlFoldingStrategy();
+foldingStrategy.UpdateFoldings(foldingManager, textEditor.Document);
+If you want the folding markers to update when the text is changed, you have to repeat the foldingStrategy.UpdateFoldings
call regularly.
+
+The sample application to this article also contains the BraceFoldingStrategy
that folds using { and }.
+However, it is a very simple implementation and does not handle { and } inside strings or comments correctly.
+
+
Did you learn anything interesting/fun/annoying while writing the code? Did you +do anything particularly clever or wild or zany? + +
Keep a running update of any changes or improvements you've made here. + +
Note: although my sample code is provided under the MIT license, ICSharpCode.AvalonEdit itself is provided under the terms of the GNU LGPL. + + +