MultilineEditControl

June 24, 2026 · View on GitHub

A full-featured multiline text editor with syntax highlighting, pluggable gutter system, find/replace, undo/redo, word wrap, and extensive keyboard/mouse interaction.

Overview

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)

Quick Start

var editor = Controls.MultilineEdit()
    .WithContent("Hello, World!")
    .WithLineNumbers()
    .WithHighlightCurrentLine()
    .WithAutoIndent()
    .OnContentChanged((s, content) => { /* handle changes */ })
    .Build();

window.AddControl(editor);

Builder API

Create a MultilineEditControlBuilder through the Controls factory:

var builder = Controls.MultilineEdit("optional initial content");

Content Methods

.WithContent(string content)              // Set initial content as single string
.WithContentLines(params string[] lines)  // Set initial content from lines
.WithContentLines(IEnumerable<string> lines)

Layout Methods

.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)

Wrap Mode Methods

.WithWrapMode(WrapMode mode)              // Set wrap mode directly
.NoWrap()                                 // No wrapping, horizontal scroll
.WrapWords()                              // Wrap at word boundaries
.WrapCharacters()                         // Wrap at character boundaries

Scrollbar Methods

.WithVerticalScrollbar(ScrollbarVisibility visibility)   // Auto, Always, Never
.WithHorizontalScrollbar(ScrollbarVisibility visibility)

Color Methods

.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)

Editor Feature Methods

.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

Extensibility Methods

.WithSyntaxHighlighter(ISyntaxHighlighter highlighter) // Attach syntax coloring
.WithGutterRenderer(IGutterRenderer renderer)          // Add custom gutter renderer

Event Methods

.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

Standard Methods

.WithName(string name)
.WithTag(object tag)
.Visible(bool visible = true)
.Enabled(bool enabled = true)
.Disabled()
.Build()

Properties

Content & State

PropertyTypeDefaultDescription
Contentstring""Get/set full text content with line breaks
LineCountint1Total number of source lines (read-only)
CurrentLineint1Cursor line number, 1-based (read-only)
CurrentColumnint1Cursor column number, 1-based (read-only)
IsEditingboolfalseWhether the control is in text editing mode
ReadOnlyboolfalseAllow navigation/selection but prevent modifications
IsModifiedboolfalseWhether content changed since last MarkAsSaved()
IsEnabledbooltrueWhether the control accepts any interaction
OverwriteModeboolfalseInsert vs overwrite mode (toggled by Insert key)
PlaceholderTextstring?nullText shown when empty and not editing
MaxLengthint?nullMaximum total character count (null = unlimited)

Layout & Display

PropertyTypeDefaultDescription
ViewportHeightint10Number of visible lines
WrapModeWrapModeWrapText wrapping: NoWrap, Wrap, WrapWords
TabSizeint4Spaces per tab (1-8)
UndoLimitint100Maximum undo history depth
AutoIndentboolfalseCopy leading whitespace on Enter
ShowLineNumbersboolfalseDisplay line numbers in gutter
ShowWhitespaceboolfalseShow spaces as middle dots (·)
HighlightCurrentLineboolfalseHighlight the cursor's line
ShowEditingHintsboolfalseShow "Enter to edit" / "Esc to exit" hints
EscapeExitsEditModebooltrueWhether Escape leaves edit mode

Scrolling

PropertyTypeDefaultDescription
VerticalScrollbarVisibilityScrollbarVisibilityAutoWhen to show vertical scrollbar
HorizontalScrollbarVisibilityScrollbarVisibilityAutoWhen to show horizontal scrollbar
VerticalScrollOffsetint0Lines scrolled from top (read-only)
HorizontalScrollOffsetint0Columns scrolled from left (read-only)

Colors

PropertyTypeDefaultDescription
BackgroundColorColorThemeBackground when not focused
ForegroundColorColorThemeForeground when not focused
FocusedBackgroundColorColorThemeBackground when focused
FocusedForegroundColorColorThemeForeground when focused
BorderColorColorWhiteBorder outline color
SelectionBackgroundColorColorThemeSelected text background
SelectionForegroundColorColorThemeSelected text foreground
ScrollbarColorColorThemeScrollbar track color
ScrollbarThumbColorColorThemeScrollbar handle color
LineNumberColorColorGreyLine number gutter foreground
CurrentLineHighlightColorColorGrey11Current line background

Extensibility

PropertyTypeDefaultDescription
SyntaxHighlighterISyntaxHighlighter?nullSyntax coloring provider
GutterRenderersIReadOnlyList<IGutterRenderer>[]Registered gutter renderers
LineHighlightsDictionary<int, Color>{}Per-line background colors (0-based index)

Events

EventArgumentsDescription
ContentChangedstringFires when text content changes
CursorPositionChanged(int Line, int Column)Fires on cursor movement (1-based)
SelectionChangedstringFires on selection change (selected text or empty)
EditingModeChangedboolFires when entering/leaving edit mode
OverwriteModeChangedboolFires when Insert key toggles overwrite mode
MatchCountChangedintFires when find/replace match count changes
GutterClickGutterClickEventArgsFires when user clicks the gutter area
GotFocusEventArgsFires when control receives keyboard focus
LostFocusEventArgsFires when control loses keyboard focus
MouseClickMouseEventArgsFires on left-click
MouseDoubleClickMouseEventArgsFires on double-click (selects word)
MouseRightClickMouseEventArgsFires on right-click (context menu hook)

Content Manipulation API

// 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;

Find and Replace API

// 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

Keyboard Support

Browse Mode (focused, not editing)

KeyAction
EnterEnter edit mode
Arrow KeysScroll content
Page Up/DownPage scroll
HomeScroll to document top
EndScroll to document bottom

Edit Mode

KeyAction
Arrow KeysMove cursor
Ctrl+Left/RightWord boundary navigation
Home/EndLine start/end
Ctrl+Home/EndDocument start/end
Page Up/DownMove cursor by viewport height
Shift+ArrowsExtend selection
Shift+Page Up/DownExtend selection by page
Shift+Home/EndSelect to line start/end
Ctrl+Shift+Home/EndSelect to document start/end
BackspaceDelete character before cursor
DeleteDelete character after cursor
Ctrl+BackspaceDelete word before cursor
Ctrl+DeleteDelete word after cursor
TabInsert tab spaces
Shift+TabRemove indentation
EnterInsert new line (with auto-indent if enabled)
Ctrl+ASelect all
Ctrl+CCopy selection to clipboard
Ctrl+XCut selection to clipboard
Ctrl+VPaste from clipboard
Ctrl+ZUndo
Ctrl+YRedo
Ctrl+DDuplicate current line(s)
Alt+UpMove line(s) up
Alt+DownMove line(s) down
InsertToggle overwrite mode
EscapeExit edit mode (if EscapeExitsEditMode is true)

Copy/paste works locally and over SSH. The editor implements IPasteTarget, so Ctrl+V and 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.

Mouse Support

InteractionAction
Left clickPosition cursor, enter edit mode
Double-clickSelect word at click position
Right-clickFire MouseRightClick event (for context menus)
Click + dragSelect text range
Gutter clickFire GutterClick event with source line index
Scrollbar thumb dragScroll content smoothly
Scrollbar track clickPage up/down
Scrollbar arrowsScroll by single line

Extensibility

Syntax Highlighting

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.

Custom Gutter Renderers

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 into
  • X, Y — coordinates for this renderer's area
  • SourceLineIndex — 0-based line index (-1 if beyond content)
  • IsFirstWrappedSegment — false for continuation rows from wrapping
  • IsCursorLine — whether this row contains the cursor
  • HasFocus — whether the editor has keyboard focus
  • ForegroundColor, BackgroundColor — current editor colors
  • TotalLineCount — 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.

Per-Line Highlights

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 highlights

Examples

Simple Note Editor

var editor = Controls.MultilineEdit()
    .WithViewportHeight(15)
    .WrapWords()
    .WithEditingHints()
    .WithPlaceholder("Start typing...")
    .Build();

Code Editor with Syntax Highlighting

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();

Read-Only Log Viewer

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);
    }
});

Editor with Status Bar and Find

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")

IDE-Style Editor with Custom Gutter

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);
    }
};

Best Practices

  1. Use VerticalAlignment.Fill for editors that should expand to fill their container — avoids hardcoding viewport height.

  2. Use NoWrap() for code editors — code is typically not word-wrapped. Use WrapWords() for prose/notes/logs.

  3. Set EscapeExitsEditMode(false) for IDE-style editors where Escape is needed for dismissing dialogs or canceling operations.

  4. Use AppendContent() for streaming data — it's thread-safe and auto-scrolls to the bottom, ideal for log viewers.

  5. Implement ISyntaxHighlighter with state tracking — use SyntaxLineState subclasses to handle multi-line constructs (block comments, multi-line strings). The cache ensures only modified lines are re-tokenized.

  6. Combine gutter renderers for rich editor chrome — line numbers + breakpoints + git diff markers can all coexist in the gutter.

  7. Use MarkAsSaved() and IsModified to track unsaved changes and prompt before closing.

Unicode & wide characters

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.

See Also


Back to Controls | Back to Main Documentation