A full-featured multiline text editor with syntax highlighting, pluggable gutter system, find/replace, undo/redo, word wrap, and extensive keyboard/mouse interaction.
The MultilineEditControl is one of SharpConsoleUI's most powerful controls — a complete text editing component comparable to editors found in production IDEs and developer tools. It supports two interaction modes: browse mode (scroll/navigate with arrow keys) and edit mode (full text editing with Enter to activate, Escape to exit).
The control features a pluggable gutter system where multiple renderers (line numbers, breakpoint markers, diff indicators) can be stacked side-by-side. A pluggable syntax highlighting interface lets you provide language-specific colorization with full support for multi-line constructs like block comments and string literals. The token and wrapping caches are automatically invalidated on content changes for efficient re-rendering.
Thread-safe content access makes it safe to append log output or streaming data from background tasks. The built-in find and replace system supports plain text and regex matching with match highlighting, navigation, and batch replacement. A full undo/redo stack tracks all edits with cursor position restoration.
See also: PromptControl (single-line input)
var editor = Controls.MultilineEdit()
.WithContent("Hello, World!")
.WithLineNumbers()
.WithHighlightCurrentLine()
.WithAutoIndent()
.OnContentChanged((s, content) => { /* handle changes */ })
.Build();
window.AddControl(editor);Create a MultilineEditControlBuilder through the Controls factory:
var builder = Controls.MultilineEdit("optional initial content");.WithContent(string content) // Set initial content as single string
.WithContentLines(params string[] lines) // Set initial content from lines
.WithContentLines(IEnumerable<string> lines).WithViewportHeight(int height) // Visible lines (default: 10)
.WithWidth(int width) // Control width in characters
.WithMargin(int left, int top, int right, int bottom)
.WithMargin(int uniform) // Uniform margin on all sides
.WithAlignment(HorizontalAlignment alignment)
.Centered() // Center horizontally
.WithVerticalAlignment(VerticalAlignment alignment)
.WithStickyPosition(StickyPosition position).WithWrapMode(WrapMode mode) // Set wrap mode directly
.NoWrap() // No wrapping, horizontal scroll
.WrapWords() // Wrap at word boundaries
.WrapCharacters() // Wrap at character boundaries.WithVerticalScrollbar(ScrollbarVisibility visibility) // Auto, Always, Never
.WithHorizontalScrollbar(ScrollbarVisibility visibility).WithColors(Color foreground, Color background)
.WithFocusedColors(Color foreground, Color background)
.WithSelectionColors(Color foreground, Color background)
.WithScrollbarColors(Color trackColor, Color thumbColor)
.WithBorderColor(Color color)
.WithBackgroundColor(Color color)
.WithForegroundColor(Color color)
.WithLineNumberColor(Color color)
.WithCurrentLineHighlightColor(Color color).WithLineNumbers(bool show = true) // Show line number gutter
.WithHighlightCurrentLine(bool highlight = true)
.WithShowWhitespace(bool show = true) // Visible space markers (·)
.WithEditingHints(bool show = true) // "Enter to edit" / "Esc to exit"
.WithAutoIndent(bool autoIndent = true) // Inherit indentation on Enter
.WithOverwriteMode(bool overwrite = true) // Insert vs overwrite toggle
.WithTabSize(int tabSize) // Spaces per Tab (1-8, default: 4)
.WithMaxLength(int maxLength) // Maximum character count
.WithUndoLimit(int limit) // Undo history depth (default: 100)
.WithPlaceholder(string text) // Placeholder when empty
.WithEscapeExitsEditMode(bool exits = true) // false for IDE-style editors
.AsReadOnly(bool readOnly = true) // Navigate/select but no editing
.IsEditing(bool isEditing = true) // Start in edit mode.WithSyntaxHighlighter(ISyntaxHighlighter highlighter) // Attach syntax coloring
.WithGutterRenderer(IGutterRenderer renderer) // Add custom gutter renderer.OnContentChanged(EventHandler<string> handler)
.OnContentChanged(WindowEventHandler<string> handler) // With window access
.OnCursorPositionChanged(EventHandler<(int Line, int Column)> handler)
.OnSelectionChanged(EventHandler<string> handler)
.OnEditingModeChanged(EventHandler<bool> handler)
.OnOverwriteModeChanged(EventHandler<bool> handler)
.OnGotFocus(EventHandler handler)
.OnGotFocus(WindowEventHandler<EventArgs> handler) // With window access
.OnLostFocus(EventHandler handler)
.OnLostFocus(WindowEventHandler<EventArgs> handler) // With window access.WithName(string name)
.WithTag(object tag)
.Visible(bool visible = true)
.Enabled(bool enabled = true)
.Disabled()
.Build()| Property | Type | Default | Description |
|---|---|---|---|
Content |
string |
"" |
Get/set full text content with line breaks |
LineCount |
int |
1 |
Total number of source lines (read-only) |
CurrentLine |
int |
1 |
Cursor line number, 1-based (read-only) |
CurrentColumn |
int |
1 |
Cursor column number, 1-based (read-only) |
IsEditing |
bool |
false |
Whether the control is in text editing mode |
ReadOnly |
bool |
false |
Allow navigation/selection but prevent modifications |
IsModified |
bool |
false |
Whether content changed since last MarkAsSaved() |
IsEnabled |
bool |
true |
Whether the control accepts any interaction |
OverwriteMode |
bool |
false |
Insert vs overwrite mode (toggled by Insert key) |
PlaceholderText |
string? |
null |
Text shown when empty and not editing |
MaxLength |
int? |
null |
Maximum total character count (null = unlimited) |
| Property | Type | Default | Description |
|---|---|---|---|
ViewportHeight |
int |
10 |
Number of visible lines |
WrapMode |
WrapMode |
Wrap |
Text wrapping: NoWrap, Wrap, WrapWords |
TabSize |
int |
4 |
Spaces per tab (1-8) |
UndoLimit |
int |
100 |
Maximum undo history depth |
AutoIndent |
bool |
false |
Copy leading whitespace on Enter |
ShowLineNumbers |
bool |
false |
Display line numbers in gutter |
ShowWhitespace |
bool |
false |
Show spaces as middle dots (·) |
HighlightCurrentLine |
bool |
false |
Highlight the cursor's line |
ShowEditingHints |
bool |
false |
Show "Enter to edit" / "Esc to exit" hints |
EscapeExitsEditMode |
bool |
true |
Whether Escape leaves edit mode |
| Property | Type | Default | Description |
|---|---|---|---|
VerticalScrollbarVisibility |
ScrollbarVisibility |
Auto |
When to show vertical scrollbar |
HorizontalScrollbarVisibility |
ScrollbarVisibility |
Auto |
When to show horizontal scrollbar |
VerticalScrollOffset |
int |
0 |
Lines scrolled from top (read-only) |
HorizontalScrollOffset |
int |
0 |
Columns scrolled from left (read-only) |
| Property | Type | Default | Description |
|---|---|---|---|
BackgroundColor |
Color |
Theme | Background when not focused |
ForegroundColor |
Color |
Theme | Foreground when not focused |
FocusedBackgroundColor |
Color |
Theme | Background when focused |
FocusedForegroundColor |
Color |
Theme | Foreground when focused |
BorderColor |
Color |
White |
Border outline color |
SelectionBackgroundColor |
Color |
Theme | Selected text background |
SelectionForegroundColor |
Color |
Theme | Selected text foreground |
ScrollbarColor |
Color |
Theme | Scrollbar track color |
ScrollbarThumbColor |
Color |
Theme | Scrollbar handle color |
LineNumberColor |
Color |
Grey |
Line number gutter foreground |
CurrentLineHighlightColor |
Color |
Grey11 |
Current line background |
| Property | Type | Default | Description |
|---|---|---|---|
SyntaxHighlighter |
ISyntaxHighlighter? |
null |
Syntax coloring provider |
GutterRenderers |
IReadOnlyList<IGutterRenderer> |
[] |
Registered gutter renderers |
LineHighlights |
Dictionary<int, Color> |
{} |
Per-line background colors (0-based index) |
| Event | Arguments | Description |
|---|---|---|
ContentChanged |
string |
Fires when text content changes |
CursorPositionChanged |
(int Line, int Column) |
Fires on cursor movement (1-based) |
SelectionChanged |
string |
Fires on selection change (selected text or empty) |
EditingModeChanged |
bool |
Fires when entering/leaving edit mode |
OverwriteModeChanged |
bool |
Fires when Insert key toggles overwrite mode |
MatchCountChanged |
int |
Fires when find/replace match count changes |
GutterClick |
GutterClickEventArgs |
Fires when user clicks the gutter area |
GotFocus |
EventArgs |
Fires when control receives keyboard focus |
LostFocus |
EventArgs |
Fires when control loses keyboard focus |
MouseClick |
MouseEventArgs |
Fires on left-click |
MouseDoubleClick |
MouseEventArgs |
Fires on double-click (selects word) |
MouseRightClick |
MouseEventArgs |
Fires on right-click (context menu hook) |
// Get/set content
string text = editor.GetContent();
editor.SetContent("new content");
editor.SetContentLines(new List<string> { "line 1", "line 2" });
// Append content (thread-safe, auto-scrolls to end)
editor.AppendContent("new text\n");
editor.AppendContentLines(new List<string> { "log entry 1", "log entry 2" });
// Insert at cursor position
editor.InsertText("inserted text");
editor.DeleteCharsBefore(5);
// Cursor navigation
editor.GoToLine(42); // Jump to line (1-based)
editor.GoToEnd(); // Jump to document end
editor.EnsureCursorVisible(); // Scroll cursor into view
// Selection
string selected = editor.GetSelectedText();
editor.SelectRange(0, 0, 5, 10); // Select range (0-based)
editor.ClearSelection();
// Undo/redo
editor.MarkAsSaved(); // Reset IsModified tracking
bool modified = editor.IsModified;// Search
int matches = editor.Find("pattern"); // Plain text search
int matches = editor.Find("pattern", caseSensitive: true);
int matches = editor.Find(@"\d+", useRegex: true); // Regex search
// Navigate matches
editor.FindNext(); // Jump to next match (wraps around)
editor.FindPrevious(); // Jump to previous match (wraps around)
// Replace
editor.Replace("replacement"); // Replace current match, advance to next
int count = editor.ReplaceAll("replacement"); // Replace all matches
// Query state
string? term = editor.SearchTerm;
int matchCount = editor.MatchCount;
int currentIdx = editor.CurrentMatchIndex; // 0-based, -1 if none
bool active = editor.HasActiveSearch;
// Clear
editor.ClearFind(); // Remove all match highlighting| Key | Action |
|---|---|
| Enter | Enter edit mode |
| Arrow Keys | Scroll content |
| Page Up/Down | Page scroll |
| Home | Scroll to document top |
| End | Scroll to document bottom |
| Key | Action |
|---|---|
| Arrow Keys | Move cursor |
| Ctrl+Left/Right | Word boundary navigation |
| Home/End | Line start/end |
| Ctrl+Home/End | Document start/end |
| Page Up/Down | Move cursor by viewport height |
| Shift+Arrows | Extend selection |
| Shift+Page Up/Down | Extend selection by page |
| Shift+Home/End | Select to line start/end |
| Ctrl+Shift+Home/End | Select to document start/end |
| Backspace | Delete character before cursor |
| Delete | Delete character after cursor |
| Ctrl+Backspace | Delete word before cursor |
| Ctrl+Delete | Delete word after cursor |
| Tab | Insert tab spaces |
| Shift+Tab | Remove indentation |
| Enter | Insert new line (with auto-indent if enabled) |
| Ctrl+A | Select all |
| Ctrl+C | Copy selection to clipboard |
| Ctrl+X | Cut selection to clipboard |
| Ctrl+V | Paste from clipboard |
| Ctrl+Z | Undo |
| Ctrl+Y | Redo |
| Ctrl+D | Duplicate current line(s) |
| Alt+Up | Move line(s) up |
| Alt+Down | Move line(s) down |
| Insert | Toggle overwrite mode |
| Escape | Exit edit mode (if EscapeExitsEditMode is true) |
Copy/paste works locally and over SSH. The editor implements
IPasteTarget, soCtrl+Vand terminal-native (bracketed) paste both insert atomically. See Clipboard, Copy & Paste for how copy reaches the local clipboard over SSH (OSC 52) and how to configure it.
| Interaction | Action |
|---|---|
| Left click | Position cursor, enter edit mode |
| Double-click | Select word at click position |
| Right-click | Fire MouseRightClick event (for context menus) |
| Click + drag | Select text range |
| Gutter click | Fire GutterClick event with source line index |
| Scrollbar thumb drag | Scroll content smoothly |
| Scrollbar track click | Page up/down |
| Scrollbar arrows | Scroll by single line |
The library ships with 12 built-in highlighters (C#, JSON, JavaScript, CSS, HTML, XML, YAML, Razor, Dockerfile, Solution, Diff, Markdown). Resolve one from the SyntaxHighlighters registry instead of writing your own:
using SharpConsoleUI.Highlighting;
var editor = Controls.MultilineEdit()
.WithSyntaxHighlighter(SyntaxHighlighters.For("csharp")) // or "json", "yaml", "diff", ...
.WithLineNumbers()
.Build();SyntaxHighlighters.For(lang) is case-insensitive and accepts aliases (e.g. cs, js, yml); it returns null for an unknown language. You can also SyntaxHighlighters.Register(...) a custom highlighter so it is available everywhere — see the Syntax Highlighting guide.
To implement ISyntaxHighlighter yourself for language-specific colorization:
public interface ISyntaxHighlighter
{
(IReadOnlyList<SyntaxToken> Tokens, SyntaxLineState EndState)
Tokenize(string line, int lineIndex, SyntaxLineState startState);
}
// SyntaxToken specifies a colored span within a line
public readonly record struct SyntaxToken(
int StartIndex, // 0-based character position
int Length, // Number of characters
Color ForegroundColor
);
// SyntaxLineState carries parser state between lines
// Subclass for language-specific state (e.g. "inside block comment")
public record SyntaxLineState
{
public static readonly SyntaxLineState Initial = new();
}Usage:
var editor = Controls.MultilineEdit()
.WithSyntaxHighlighter(new CSharpSyntaxHighlighter())
.WithLineNumbers()
.Build();The token cache is automatically managed — when content changes, only affected lines and their successors are re-tokenized.
Implement IGutterRenderer to add custom content in the left gutter area (breakpoint markers, diff indicators, fold toggles, etc.):
public interface IGutterRenderer
{
int GetWidth(int totalLineCount); // How many columns this renderer needs
void Render(in GutterRenderContext context, int width); // Paint one row
}GutterRenderContext provides:
Buffer— the character buffer to paint intoX,Y— coordinates for this renderer's areaSourceLineIndex— 0-based line index (-1 if beyond content)IsFirstWrappedSegment— false for continuation rows from wrappingIsCursorLine— whether this row contains the cursorHasFocus— whether the editor has keyboard focusForegroundColor,BackgroundColor— current editor colorsTotalLineCount— total source lines in document
Example — breakpoint gutter:
public class BreakpointGutterRenderer : IGutterRenderer
{
private readonly HashSet<int> _breakpoints = new();
public int GetWidth(int totalLineCount) => 2;
public void Render(in GutterRenderContext context, int width)
{
char marker = _breakpoints.Contains(context.SourceLineIndex) ? '●' : ' ';
Color fg = _breakpoints.Contains(context.SourceLineIndex) ? Color.Red : context.ForegroundColor;
context.Buffer.SetNarrowCell(context.X, context.Y, marker, fg, context.BackgroundColor);
context.Buffer.SetNarrowCell(context.X + 1, context.Y, ' ', fg, context.BackgroundColor);
}
public void ToggleBreakpoint(int line) {
if (!_breakpoints.Add(line)) _breakpoints.Remove(line);
}
}Usage:
var bpRenderer = new BreakpointGutterRenderer();
var editor = Controls.MultilineEdit()
.WithLineNumbers() // Line numbers at index 0
.WithGutterRenderer(bpRenderer) // Breakpoints after line numbers
.Build();
editor.GutterClick += (s, e) => {
if (e.SourceLineIndex >= 0)
bpRenderer.ToggleBreakpoint(e.SourceLineIndex);
};Multiple renderers are painted left-to-right in registration order.
Set background colors on individual source lines programmatically:
editor.SetLineHighlight(5, Color.DarkRed); // Highlight line 5 (0-based)
editor.SetLineHighlight(10, Color.DarkGreen);
editor.SetLineHighlight(5, null); // Clear highlight on line 5
editor.ClearLineHighlights(); // Clear all highlightsvar editor = Controls.MultilineEdit()
.WithViewportHeight(15)
.WrapWords()
.WithEditingHints()
.WithPlaceholder("Start typing...")
.Build();var editor = Controls.MultilineEdit()
.WithContent(sourceCode)
.NoWrap()
.WithLineNumbers()
.WithHighlightCurrentLine()
.WithAutoIndent()
.WithTabSize(4)
.WithSyntaxHighlighter(new CSharpSyntaxHighlighter())
.WithVerticalAlignment(VerticalAlignment.Fill)
.WithEscapeExitsEditMode(false)
.OnCursorPositionChanged((s, pos) =>
{
statusBar.SetLines($"Ln {pos.Line}, Col {pos.Column}");
})
.Build();var logViewer = Controls.MultilineEdit()
.AsReadOnly()
.WrapWords()
.WithVerticalScrollbar(ScrollbarVisibility.Always)
.WithColors(Color.Green, Color.Black)
.Build();
// Append from background thread (thread-safe)
Task.Run(async () => {
while (true)
{
logViewer.AppendContent($"[{DateTime.Now:HH:mm:ss}] Event\n");
await Task.Delay(1000);
}
});var editor = Controls.MultilineEdit()
.WithLineNumbers()
.WithHighlightCurrentLine()
.WithShowWhitespace()
.OnCursorPositionChanged((s, pos) =>
{
status.SetLines($"Ln {pos.Line}, Col {pos.Column} | {editor.LineCount} lines");
})
.Build();
// Find usage
int matches = editor.Find("TODO", caseSensitive: false);
// Navigate: editor.FindNext(), editor.FindPrevious()
// Replace: editor.Replace("DONE"), editor.ReplaceAll("DONE")var breakpoints = new BreakpointGutterRenderer();
var editor = Controls.MultilineEdit()
.WithContent(sourceCode)
.NoWrap()
.WithLineNumbers()
.WithGutterRenderer(breakpoints)
.WithHighlightCurrentLine()
.WithAutoIndent()
.WithSyntaxHighlighter(new CSharpSyntaxHighlighter())
.WithEscapeExitsEditMode(false)
.Build();
editor.GutterClick += (s, e) =>
{
if (e.SourceLineIndex >= 0)
{
breakpoints.ToggleBreakpoint(e.SourceLineIndex);
editor.Container?.Invalidate(Invalidation.Relayout);
}
};-
Use
VerticalAlignment.Fillfor editors that should expand to fill their container — avoids hardcoding viewport height. -
Use
NoWrap()for code editors — code is typically not word-wrapped. UseWrapWords()for prose/notes/logs. -
Set
EscapeExitsEditMode(false)for IDE-style editors where Escape is needed for dismissing dialogs or canceling operations. -
Use
AppendContent()for streaming data — it's thread-safe and auto-scrolls to the bottom, ideal for log viewers. -
Implement
ISyntaxHighlighterwith state tracking — useSyntaxLineStatesubclasses to handle multi-line constructs (block comments, multi-line strings). The cache ensures only modified lines are re-tokenized. -
Combine gutter renderers for rich editor chrome — line numbers + breakpoints + git diff markers can all coexist in the gutter.
-
Use
MarkAsSaved()andIsModifiedto track unsaved changes and prompt before closing.
The editor is display-width aware. Word wrap (WrapWords()), character wrap (WrapCharacters()),
horizontal scrolling, and the horizontal scrollbar all measure in terminal columns, not UTF-16
code units — so wide glyphs are handled correctly:
- CJK (Chinese / Japanese / Korean) characters occupy 2 columns each; wrap and scroll break on column boundaries, never mid-glyph.
- Emoji (surrogate pairs such as 📦) and VS16-widened symbols (⚙️) count as 2 columns.
- Combining marks (e.g.
a+ U+0301) add 0 columns to the base character. - Typing an emoji is safe even between its two surrogate code units (the transient lone-surrogate state is treated as a width-1 placeholder and never throws).
The horizontal scrollbar appears when a line's display width exceeds the viewport, even if its character count would fit. Copying a selection that spans wide characters returns exactly the selected glyphs.
- Syntax Highlighting - Built-in highlighters, the registry, and custom registration
- PromptControl - Single-line text input
- ListControl - Scrollable item list