
Ultra-fast hardware-accelerated text and code editor component with persistent red-black rope buffer, Skia direct-canvas rendering, multi-cursor support, hierarchical theming, language tooling, folding, and smart undo history.
Kortex is an ultra-fast, hardware-accelerated code and text editor component built for Kotlin Multiplatform and Compose Multiplatform (targeting Desktop JVM, Android, iOS, and WebAssembly / WASM-JS).
Engineered from the ground up for high throughput, sub-millisecond input latency, and locked 60+ FPS scrolling across
massive documents, Kortex bypasses standard composable layout tree overhead by coupling a persistent Red-Black Rope
text buffer (Krope) directly with a custom virtualized Skia/Skiko direct-canvas
rendering pipeline and a deterministic unidirectional MVI state reducer.
Standard text composables in declarative frameworks (like Jetpack Compose BasicTextField or BasicTextField2)
represent text through nested UI nodes, complex layout measurers, and frequent heap allocations during typing or
scrolling. When handling source files with tens of thousands of lines, multi-cursors, syntax highlighting, or rapid
typing, this architecture inevitably suffers from GC pauses, frame drops, and latency spikes.
Kortex eliminates this overhead:
Canvas.(EditorState, EditorAction) -> EditorResult).Paragraph, TextLine, and Paint objects are managed by thread-safe,
size-bounded LRU caches.drawWithCache, nativeCanvas, SkikoSurfaceView).Alt + Click, Alt + Drag, and VS
Code-style Select Next Match (Ctrl+D / Cmd+D) with automatic overlap coalescing.keyword.control automatically falls back to keyword).Up/Down, Enter/Tab, Escape).DocumentationProvider (debounced, resolved against the caret offset).FoldMap): Cumulative line interval trees delivering $O (\log K)$ binary search
conversions between document lines and visual lines.Ctrl on
Linux/Windows, Cmd on macOS) and read-only protections.flowchart LR
subgraph CORE[":kortex-core<br/>headless"]
E1["EditorEngine<br/>EditorState · Caret · Selection<br/>FoldMap · HistoryManager<br/>EditorReducer"]
end
subgraph CORE_COMPOSE[":kortex-core-compose"]
E2["SkiaCanvas<br/>EditorFont · FontManager<br/>EditorScrollController<br/>EditorInputArea · Keymaps<br/>KortexEditor"]
end
subgraph CODE[":kortex-code"]
E3["CodeEditorEngine<br/>LanguageAnalysisService<br/>Token · Diagnostics<br/>Completion · Documentation"]
end
subgraph CODE_COMPOSE[":kortex-code-compose"]
E4["KortexCodeEditor<br/>syntax layers · folding markers<br/>column ruler<br/>completion overlay"]
end
KROPE[("Krope<br/>RopeTextBuffer")]
UI(["Compose UI"])
UI --> CODE_COMPOSE
CODE_COMPOSE --> CODE
CODE_COMPOSE --> CORE_COMPOSE
CODE --> CORE
CORE_COMPOSE --> CORE
CORE --> KROPEStateFlow<EditorState> or StateFlow<CodeEditorState>.
UI gestures emit actions, which reducers map to state updates and side-effect commands (EditorCommand.ApplyText,
Undo, Redo).TextOperation.Data to RopeTextBuffer. Edit deltas
(TextEdit.Data) map back through surviving selections and carets without shifting or desynchronizing editor state.Paragraph, TextLine, and Paint prevent GC pressure and
allocations during continuous 60 FPS scrolling and fast typing.| Cache | Default capacity | Key |
|---|---|---|
ParagraphCache |
1000 | (text, font, textColor) |
CodeParagraphCache |
1000 | (line, text, tokens, font, syntaxTheme) |
TextLineCache |
1000 |
(text, font) — used for gutter line numbers |
PaintCache |
1000 | (color, isAntiAlias, mode, strokeWidth) |
All caches implement LruCache and are closed automatically when the composable leaves composition (DisposableEffect
in rememberLayerFactory / rememberCodeLayerFactory).
| Module | Maven Artifact | Description |
|---|---|---|
:kortex-core |
io.github.numq.kortex:core:<version> |
Headless editor primitives: EditorEngine, EditorState, Caret, Selection, FoldMap, HistoryManager, and functional reducers. Zero Compose dependencies. |
:kortex-core-compose |
io.github.numq.kortex:core-compose:<version> |
Skia canvas rendering integration (SkiaCanvas), EditorFont, FontManager, EditorScrollController, EditorInputArea, keymaps, and default context menus. |
:kortex-code |
io.github.numq.kortex:code:<version> |
Code abstractions: CodeEditorEngine, LanguageAnalysisService, tokenization models, diagnostics, occurrence tracking, and completion. |
:kortex-code-compose |
io.github.numq.kortex:code-compose:<version> |
High-level KortexCodeEditor composable, syntax highlighting layers, folding markers, column ruler, and code completion overlays. |
Kortex leverages Kotlin Multiplatform and Skiko/Skia bindings:
| Platform | Target Identifier | Architecture / Runtime |
|---|---|---|
| JVM (Desktop) | jvm |
macOS, Windows, Linux (Java 17+) |
| Android | android |
API 24+ via SkikoSurfaceView
|
| iOS |
iosX64, iosArm64, iosSimulatorArm64
|
Native Skia canvas rendering |
| WebAssembly | wasmJs |
Modern browsers supporting WebAssembly & WebGL/WebGPU |
Add mavenCentral() to your repositories:
repositories {
mavenCentral()
}Add dependencies to your build.gradle.kts in your multiplatform commonMain or platform source sets:
dependencies {
// Core text manipulation engine (Headless)
implementation("io.github.numq.kortex:core:1.0.0")
// Compose Multiplatform basic Skia text editor
implementation("io.github.numq.kortex:core-compose:1.0.0")
// Code editor engine & language analysis abstractions
implementation("io.github.numq.kortex:code:1.0.0")
// Complete Compose Multiplatform IDE code editor (Includes syntax, folding, completions)
implementation("io.github.numq.kortex:code-compose:1.0.0")
// Krope buffer dependency (Required for buffer initialization)
implementation("io.github.numq.krope:text:1.1.1")
}Requires
:kortex-core-compose(and:kortex-coretransitively).
If you need a lightweight, high-performance text buffer editor without code-specific tooling:
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import io.github.numq.kortex.core.EditorEngine
import io.github.numq.kortex.core.compose.KortexEditor
import io.github.numq.kortex.core.compose.font.EditorFont
import io.github.numq.kortex.core.compose.theme.EditorTheme
import io.github.numq.krope.core.Encoding
import io.github.numq.krope.text.RopeTextBuffer
import io.github.numq.krope.text.TextLineEnding
import org.jetbrains.skia.FontMgr
import org.jetbrains.skia.FontStyle
import org.jetbrains.skia.Typeface
@Composable
fun SimpleEditor() {
val scope = rememberCoroutineScope()
val buffer = remember {
RopeTextBuffer(
initialText = "Welcome to Kortex Plain Text Editor!",
initialLineEnding = TextLineEnding.LF,
initialEncoding = Encoding.UTF8,
enablePooling = true
)
}
val engine = remember(buffer) { EditorEngine(scope = scope, buffer = buffer) }
val font = remember {
val typeface = FontMgr.default.matchFamilyStyle("JetBrains Mono", FontStyle.NORMAL)
?: FontMgr.default.matchFamilyStyle(null, FontStyle.NORMAL)
?: Typeface.makeEmpty()
EditorFont(typeface = typeface, size = 14f, lineSpacing = 1.2f)
}
val theme = remember {
EditorTheme.fromColorScheme(androidx.compose.material3.MaterialTheme.colorScheme)
}
KortexEditor(engine = engine, font = font, theme = theme, modifier = Modifier.fillMaxSize())
}Requires
:kortex-code-compose(pulls:kortex-codeand:kortex-core-composetransitively).
Setting up KortexCodeEditor with line numbers, code folding, scrollbars, and syntax rendering:
import androidx.compose.foundation.HorizontalScrollbar
import androidx.compose.foundation.VerticalScrollbar
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import io.github.numq.kortex.code.CodeEditorEngine
import io.github.numq.kortex.code.analysis.LanguageAnalysisService
import io.github.numq.kortex.code.compose.KortexCodeEditor
import io.github.numq.kortex.code.compose.theme.CodeEditorTheme
import io.github.numq.kortex.core.EditorEngine
import io.github.numq.kortex.core.compose.font.EditorFont
import io.github.numq.kortex.core.compose.scroll.rememberEditorScrollController
import io.github.numq.kortex.core.compose.scrollbar.rememberHorizontalScrollbarAdapter
import io.github.numq.kortex.core.compose.scrollbar.rememberVerticalScrollbarAdapter
import io.github.numq.krope.core.Encoding
import io.github.numq.krope.text.RopeTextBuffer
import io.github.numq.krope.text.TextLineEnding
import org.jetbrains.skia.FontMgr
import org.jetbrains.skia.FontStyle
import org.jetbrains.skia.Typeface
@Composable
fun FullCodeEditorScreen() {
val scope = rememberCoroutineScope()
// 1. Persistent Krope buffer
val buffer = remember {
RopeTextBuffer(
initialText = """
package example
fun main() {
println("Hello from Kortex Skia Code Editor!")
}
""".trimIndent(),
initialLineEnding = TextLineEnding.LF,
initialEncoding = Encoding.UTF8,
enablePooling = true
)
}
// 2. Instantiate engines
val editorEngine = remember(buffer) { EditorEngine(scope = scope, buffer = buffer) }
val codeEngine = remember(editorEngine) {
CodeEditorEngine(
scope = scope,
engine = editorEngine,
analysisService = object : LanguageAnalysisService {} // Plug custom analysis here
)
}
// 3. Configure typography & theme
val font = remember {
val typeface = FontMgr.default.matchFamilyStyle("JetBrains Mono", FontStyle.NORMAL)
?: FontMgr.default.matchFamilyStyle(null, FontStyle.NORMAL)
?: Typeface.makeEmpty()
EditorFont(typeface = typeface, size = 14f, lineSpacing = 1.25f)
}
val isDark = isSystemInDarkTheme()
val colorScheme = if (isDark) darkColorScheme() else lightColorScheme()
val theme = remember(colorScheme, isDark) { CodeEditorTheme.fromColorScheme(colorScheme, isDark) }
val scrollController = rememberEditorScrollController()
MaterialTheme(colorScheme = colorScheme) {
Box(modifier = Modifier.fillMaxSize()) {
KortexCodeEditor(
engine = codeEngine,
font = font,
theme = theme,
scrollController = scrollController,
columnRuler = 80,
modifier = Modifier.fillMaxSize()
)
// Desktop vertical scrollbar
if (scrollController.canScrollVertically) {
VerticalScrollbar(
adapter = rememberVerticalScrollbarAdapter(scrollController),
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight()
)
}
// Desktop horizontal scrollbar aligned with text content area
if (scrollController.canScrollHorizontally) {
val density = LocalDensity.current
val gutterPadding = with(density) { scrollController.gutterWidth.toDp() }
HorizontalScrollbar(
adapter = rememberHorizontalScrollbarAdapter(scrollController),
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.padding(start = gutterPadding)
)
}
}
}
}To equip Kortex with syntax highlighting, diagnostics, autocompletion, and hover information, provide an implementation
of LanguageAnalysisService:
val customLanguageService = object : LanguageAnalysisService {
override val tokenProvider = TokenProvider { snapshot, range ->
// Return Map<Int, List<Token>> (Key = Line Index)
// Executed asynchronously in background (debounced at 50ms)
parseSyntaxTokens(snapshot, range)
}
override val diagnosticsProvider = DiagnosticsProvider { snapshot ->
// Return List<CodeIssue>
// Executed asynchronously in background (debounced at 300ms)
runLinter(snapshot)
}
override val completionProvider = CompletionProvider { snapshot, offset ->
// Return List<CodeSuggestion>
// Triggered via Ctrl+Space / Cmd+Space or typing triggers
computeAutocompletions(snapshot, offset)
}
override val documentationProvider = DocumentationProvider { snapshot, offset ->
// Return CodeDocumentation(content, range)
computeHoverDocumentation(snapshot, offset)
}
}Debounce intervals (50ms for tokens, 300ms for diagnostics) are fixed in
CodeEditorEngine. Providers run onDispatchers.Defaultand receive the latestTextSnapshotrevision only - stale results are dropped viadistinctUntilChangedBy(TextSnapshot::revision).
Tokens are mapped by zero-based line indices using TextRange and TokenType:
// A TokenProvider receives `range = null` (whole document) or a specific TextRange
// for incremental re-tokenization after an edit.
val token = Token(
range = TextRange(
start = TextPosition(line = 0, column = 0),
end = TextPosition(line = 0, column = 7)
),
type = TokenType.KEYWORD
)Built-in token types include:
TokenType.KEYWORD, TokenType.KEYWORD_CONTROL
TokenType.TYPE, TokenType.TYPE_BUILTIN
TokenType.FUNCTION, TokenType.FUNCTION_METHOD
TokenType.VARIABLE, TokenType.VARIABLE_PARAMETER
TokenType.STRING, TokenType.NUMBER
TokenType.COMMENT, TokenType.COMMENT_LINE, TokenType.COMMENT_BLOCK
TokenType.OPERATOR, TokenType.PUNCTUATION, TokenType.PUNCTUATION_BRACKET, TokenType.PUNCTUATION_DELIMITER
TokenType.MARKUP_LINK, TokenType.DEFAULT
Issues are rendered as smooth, high-precision sine-wave underline paths on the Skia canvas:
val compilerError = CodeIssue.Error(
range = TextRange(
start = TextPosition(line = 1, column = 4),
end = TextPosition(line = 1, column = 16)
),
message = "Unresolved reference: unresolvedMethod",
source = "Compiler",
code = "E0042"
)Available issue types: CodeIssue.Error, CodeIssue.Warning, CodeIssue.Information, CodeIssue.Hint,
CodeIssue.Unknown.
val suggestion = CodeSuggestion(
label = "calculateMetrics(buffer)",
kind = CodeSuggestion.Kind.FUNCTION,
text = "calculateMetrics()",
detail = "Result",
documentation = "Analyzes the persistent rope buffer and computes structural metrics."
)If range is provided, applying the suggestion replaces that range (useful for partial word completion); otherwise
it inserts text at the caret anchor. This is handled by CodeEditorAction.Completion.Apply →
TextOperation.Data.Single.Replace / .Insert.
Popup controls when visible:
DirectionDown / DirectionUp: Navigate items.Enter / Tab: Apply selected completion.Escape: Dismiss overlay.val docs = CodeDocumentation(
content = "RopeTextBuffer: Thread-safe, balanced Red-Black tree buffer.",
// `range` is optional - omit it to attach documentation to the whole document
range = TextRange(
start = TextPosition(line = 0, column = 5),
end = TextPosition(line = 0, column = 19)
)
)Kortex themes use a hierarchical dot-separated resolver. When styling keyword.control, if no explicit style exists, it
automatically inherits from keyword:
val mySyntaxTheme = SyntaxTheme.build(
defaultText = Color(0xFFABB2BF)
) {
put(TokenType.KEYWORD.name, Color(0xFFC678DD))
put(TokenType.KEYWORD_CONTROL.name, Color(0xFFE06C75))
put(TokenType.FUNCTION.name, Color(0xFF61AFEF))
put(TokenType.STRING.name, Color(0xFF98C379))
put(TokenType.NUMBER.name, Color(0xFFD19A66))
put(TokenType.COMMENT.name, Color(0xFF5C6370))
}
val editorTheme = CodeEditorTheme(
editor = EditorTheme.fromColorScheme(colorScheme),
syntax = mySyntaxTheme,
diagnostics = DiagnosticsTheme(
error = Color(0xFFE06C75),
warning = Color(0xFFE5C07B),
info = Color(0xFF61AFEF),
hint = Color(0xFF5C6370)
),
occurrences = OccurrencesTheme(
matchBackground = Color(0xFF3E4451).copy(alpha = 0.5f),
currentMatchBackground = Color(0xFF528BFF).copy(alpha = 0.4f)
),
folding = FoldingTheme(
iconColor = Color(0xFF61AFEF)
),
ruler = Color(0xFF3E4451).copy(alpha = 0.3f)
)Extend or customize keyboard behaviors using buildEditorKeymap:
val myCustomKeymap = buildEditorKeymap(baseKeymap = DefaultCodeEditorKeymap.create(codeEngine)) {
// Quick Save: Ctrl+S / Cmd+S
bind(Key.S, primary = true, description = "Save File") {
val content = state.snapshot.text
saveToFile(content)
}
// Move current line up: Alt+Up
bind(Key.DirectionUp, alt = true, requiresEditable = true, description = "Move Line Up") {
dispatch(EditAction.MoveLineUp)
}
// Format document: Ctrl+Alt+L
bind(Key.L, primary = true, alt = true, requiresEditable = true, description = "Reformat Code") {
formatSource()
}
}| Shortcut (PC / Mac) | Action Description |
|---|---|
Ctrl+A / Cmd+A
|
Select entire document |
Ctrl+D / Cmd+D
|
Select word / Add next occurrence match (Multi-Cursor) |
Alt + Click / Drag |
Add independent cursor / rectangular selection drag |
Ctrl+Z / Cmd+Z
|
Undo |
Ctrl+Shift+Z / Cmd+Shift+Z
|
Redo |
Ctrl+C / Ctrl+X / Ctrl+V
|
Copy / Cut / Paste (works with multi-cursors) |
Ctrl+Shift+D |
Duplicate current line or selection |
Alt + Up / Down |
Move current line or block up / down |
Tab / Shift+Tab
|
Indent / Unindent selected block or insert soft tabs |
Ctrl+/ / Cmd+/
|
Toggle line comment (// ) |
Ctrl+Space |
Request IntelliSense code completion |
Ctrl+Backspace / Ctrl+Delete
|
Delete word left / right |
Home / End
|
Smart move to indent start / line end |
Ctrl+Home / Ctrl+End
|
Move to document start / document end |
PageUp / PageDown
|
Viewport page scrolling with caret tracking |
Escape |
Dismiss completion popup / collapse extra carets |
Kortex easily renders files with hundreds of thousands of lines by rendering only the visible viewport slice:
val foldMap = state.foldMap
val visualLineIndex = foldMap.documentToVisualLine(actualDocumentLine)
val documentLineIndex = foldMap.visualToDocumentLine(visualLineIndex)FoldMap manages collapsed regions using interval trees with $O (\log K)$ mapping.KortexCodeEditor wires gutter clicks to ViewportAction.ToggleFolding(line) when a
FoldingRegion starts on that line. KortexEditor (plain text) exposes a generic onGutterClick(line, offset)
callback instead — you decide what a gutter click means.Typing individual characters does not create hundreds of micro undo entries. HistoryManager automatically merges edits
when:
Insert + Insert or Delete + Delete).Executing Undo/Redo restores both buffer contents and exact caret/selection states.
Add multiple cursors programmatically or interactively:
// Add cursor at specific location
editorEngine.dispatch(CaretAction.AddCursor(TextPosition(line = 10, column = 4)))
// Add next match to selection
editorEngine.dispatch(SelectionAction.SelectNextMatch)SelectNextMatch behaves like VS Code's Ctrl+D:
primary.range.end, wrapping to the start of the document.Ctrl+D advances to the next distinct
occurrence.Selection.normalize().FORWARD / BACKWARD).Kortex provides adapters for Compose Desktop scrollbars:
EditorScrollController.asVerticalScrollbarAdapter() / rememberVerticalScrollbarAdapter(...)
EditorScrollController.asHorizontalScrollbarAdapter() / rememberHorizontalScrollbarAdapter(...)
A built-in Material 3 right-click context menu handles Cut, Copy, Paste, Undo, Redo, and Select All out of the box.
| Class / Interface | Responsibility |
|---|---|
EditorEngine |
Coordinates text buffer operations, history tracking, and reducer dispatch loops. |
CodeEditorEngine |
Extends EditorEngine with asynchronous debounced language analysis pipelines. |
EditorConfig |
Immutable editor preferences: tabSize, useSoftTabs, lineSeparator (derived tabString). |
EditorState |
Immutable snapshot of text snapshot, selections, collapsed ranges, and config. |
CodeEditorState |
Extends EditorState with syntax tokens, lint diagnostics, and completion state. |
EditorFont |
Thread-safe Skia typeface wrapper managing font collections, ascents, descents, and paragraph builders. |
EditorScrollController |
Stable controller handling viewport offsets, bounds, and auto-scrolling to carets. |
FoldMap |
High-performance coordinate mapper between logical lines and visible lines. |
EditorConfig is passed into EditorState.config and can be updated at runtime via
EditorAction.UpdateConfig(EditorConfig(tabSize = 2, useSoftTabs = true)).
CodeEditorState.CompletionState is a sealed hierarchy: Hidden or
Visible(suggestions: List<CodeSuggestion>, selectedIndex: Int, anchorPosition: TextPosition). The selectedItem
property resolves the currently highlighted suggestion.
EditAction:
Insert, Paste, Enter, Tab, Untab, Backspace, Delete, WordDeleteLeft, WordDeleteRight, MoveLineUp,
MoveLineDown, Duplicate, JoinLines
CaretAction: Move, AddCursor, MoveLeft, MoveRight, MoveUp, MoveDown, MoveWordLeft,MoveWordRight,
MoveLineStart, MoveLineEnd, MoveDocStart, MoveDocEnd.SelectionAction: Clear, SelectAll, SelectWordAt, SelectLine, SelectRange, AddSelection,
SetSelections, SelectNextMatch.ViewportAction: ToggleFolding, UpdateCollapsedLines, UpdateFoldingRegions.HistoryAction: Undo, Redo.CodeEditorAction: Core, Edit.ToggleComment, External.UpdateSyntax, External.UpdateAnalysis,
Completion.Show, Completion.Apply, Completion.Dismiss, Completion.SelectNext, Completion.SelectPrevious.
EditActionandCaretActionmutate the buffer or selection directly.ViewportActiononly rebuildsFoldMap(buffer is untouched).HistoryActionis routed asEditorCommand.Undo/Redoand replays the inverseTextOperation.Datarecorded byHistoryManager.
Apache-2.0 License - see LICENSE file for details.
Kortex is an ultra-fast, hardware-accelerated code and text editor component built for Kotlin Multiplatform and Compose Multiplatform (targeting Desktop JVM, Android, iOS, and WebAssembly / WASM-JS).
Engineered from the ground up for high throughput, sub-millisecond input latency, and locked 60+ FPS scrolling across
massive documents, Kortex bypasses standard composable layout tree overhead by coupling a persistent Red-Black Rope
text buffer (Krope) directly with a custom virtualized Skia/Skiko direct-canvas
rendering pipeline and a deterministic unidirectional MVI state reducer.
Standard text composables in declarative frameworks (like Jetpack Compose BasicTextField or BasicTextField2)
represent text through nested UI nodes, complex layout measurers, and frequent heap allocations during typing or
scrolling. When handling source files with tens of thousands of lines, multi-cursors, syntax highlighting, or rapid
typing, this architecture inevitably suffers from GC pauses, frame drops, and latency spikes.
Kortex eliminates this overhead:
Canvas.(EditorState, EditorAction) -> EditorResult).Paragraph, TextLine, and Paint objects are managed by thread-safe,
size-bounded LRU caches.drawWithCache, nativeCanvas, SkikoSurfaceView).Alt + Click, Alt + Drag, and VS
Code-style Select Next Match (Ctrl+D / Cmd+D) with automatic overlap coalescing.keyword.control automatically falls back to keyword).Up/Down, Enter/Tab, Escape).DocumentationProvider (debounced, resolved against the caret offset).FoldMap): Cumulative line interval trees delivering $O (\log K)$ binary search
conversions between document lines and visual lines.Ctrl on
Linux/Windows, Cmd on macOS) and read-only protections.flowchart LR
subgraph CORE[":kortex-core<br/>headless"]
E1["EditorEngine<br/>EditorState · Caret · Selection<br/>FoldMap · HistoryManager<br/>EditorReducer"]
end
subgraph CORE_COMPOSE[":kortex-core-compose"]
E2["SkiaCanvas<br/>EditorFont · FontManager<br/>EditorScrollController<br/>EditorInputArea · Keymaps<br/>KortexEditor"]
end
subgraph CODE[":kortex-code"]
E3["CodeEditorEngine<br/>LanguageAnalysisService<br/>Token · Diagnostics<br/>Completion · Documentation"]
end
subgraph CODE_COMPOSE[":kortex-code-compose"]
E4["KortexCodeEditor<br/>syntax layers · folding markers<br/>column ruler<br/>completion overlay"]
end
KROPE[("Krope<br/>RopeTextBuffer")]
UI(["Compose UI"])
UI --> CODE_COMPOSE
CODE_COMPOSE --> CODE
CODE_COMPOSE --> CORE_COMPOSE
CODE --> CORE
CORE_COMPOSE --> CORE
CORE --> KROPEStateFlow<EditorState> or StateFlow<CodeEditorState>.
UI gestures emit actions, which reducers map to state updates and side-effect commands (EditorCommand.ApplyText,
Undo, Redo).TextOperation.Data to RopeTextBuffer. Edit deltas
(TextEdit.Data) map back through surviving selections and carets without shifting or desynchronizing editor state.Paragraph, TextLine, and Paint prevent GC pressure and
allocations during continuous 60 FPS scrolling and fast typing.| Cache | Default capacity | Key |
|---|---|---|
ParagraphCache |
1000 | (text, font, textColor) |
CodeParagraphCache |
1000 | (line, text, tokens, font, syntaxTheme) |
TextLineCache |
1000 |
(text, font) — used for gutter line numbers |
PaintCache |
1000 | (color, isAntiAlias, mode, strokeWidth) |
All caches implement LruCache and are closed automatically when the composable leaves composition (DisposableEffect
in rememberLayerFactory / rememberCodeLayerFactory).
| Module | Maven Artifact | Description |
|---|---|---|
:kortex-core |
io.github.numq.kortex:core:<version> |
Headless editor primitives: EditorEngine, EditorState, Caret, Selection, FoldMap, HistoryManager, and functional reducers. Zero Compose dependencies. |
:kortex-core-compose |
io.github.numq.kortex:core-compose:<version> |
Skia canvas rendering integration (SkiaCanvas), EditorFont, FontManager, EditorScrollController, EditorInputArea, keymaps, and default context menus. |
:kortex-code |
io.github.numq.kortex:code:<version> |
Code abstractions: CodeEditorEngine, LanguageAnalysisService, tokenization models, diagnostics, occurrence tracking, and completion. |
:kortex-code-compose |
io.github.numq.kortex:code-compose:<version> |
High-level KortexCodeEditor composable, syntax highlighting layers, folding markers, column ruler, and code completion overlays. |
Kortex leverages Kotlin Multiplatform and Skiko/Skia bindings:
| Platform | Target Identifier | Architecture / Runtime |
|---|---|---|
| JVM (Desktop) | jvm |
macOS, Windows, Linux (Java 17+) |
| Android | android |
API 24+ via SkikoSurfaceView
|
| iOS |
iosX64, iosArm64, iosSimulatorArm64
|
Native Skia canvas rendering |
| WebAssembly | wasmJs |
Modern browsers supporting WebAssembly & WebGL/WebGPU |
Add mavenCentral() to your repositories:
repositories {
mavenCentral()
}Add dependencies to your build.gradle.kts in your multiplatform commonMain or platform source sets:
dependencies {
// Core text manipulation engine (Headless)
implementation("io.github.numq.kortex:core:1.0.0")
// Compose Multiplatform basic Skia text editor
implementation("io.github.numq.kortex:core-compose:1.0.0")
// Code editor engine & language analysis abstractions
implementation("io.github.numq.kortex:code:1.0.0")
// Complete Compose Multiplatform IDE code editor (Includes syntax, folding, completions)
implementation("io.github.numq.kortex:code-compose:1.0.0")
// Krope buffer dependency (Required for buffer initialization)
implementation("io.github.numq.krope:text:1.1.1")
}Requires
:kortex-core-compose(and:kortex-coretransitively).
If you need a lightweight, high-performance text buffer editor without code-specific tooling:
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import io.github.numq.kortex.core.EditorEngine
import io.github.numq.kortex.core.compose.KortexEditor
import io.github.numq.kortex.core.compose.font.EditorFont
import io.github.numq.kortex.core.compose.theme.EditorTheme
import io.github.numq.krope.core.Encoding
import io.github.numq.krope.text.RopeTextBuffer
import io.github.numq.krope.text.TextLineEnding
import org.jetbrains.skia.FontMgr
import org.jetbrains.skia.FontStyle
import org.jetbrains.skia.Typeface
@Composable
fun SimpleEditor() {
val scope = rememberCoroutineScope()
val buffer = remember {
RopeTextBuffer(
initialText = "Welcome to Kortex Plain Text Editor!",
initialLineEnding = TextLineEnding.LF,
initialEncoding = Encoding.UTF8,
enablePooling = true
)
}
val engine = remember(buffer) { EditorEngine(scope = scope, buffer = buffer) }
val font = remember {
val typeface = FontMgr.default.matchFamilyStyle("JetBrains Mono", FontStyle.NORMAL)
?: FontMgr.default.matchFamilyStyle(null, FontStyle.NORMAL)
?: Typeface.makeEmpty()
EditorFont(typeface = typeface, size = 14f, lineSpacing = 1.2f)
}
val theme = remember {
EditorTheme.fromColorScheme(androidx.compose.material3.MaterialTheme.colorScheme)
}
KortexEditor(engine = engine, font = font, theme = theme, modifier = Modifier.fillMaxSize())
}Requires
:kortex-code-compose(pulls:kortex-codeand:kortex-core-composetransitively).
Setting up KortexCodeEditor with line numbers, code folding, scrollbars, and syntax rendering:
import androidx.compose.foundation.HorizontalScrollbar
import androidx.compose.foundation.VerticalScrollbar
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import io.github.numq.kortex.code.CodeEditorEngine
import io.github.numq.kortex.code.analysis.LanguageAnalysisService
import io.github.numq.kortex.code.compose.KortexCodeEditor
import io.github.numq.kortex.code.compose.theme.CodeEditorTheme
import io.github.numq.kortex.core.EditorEngine
import io.github.numq.kortex.core.compose.font.EditorFont
import io.github.numq.kortex.core.compose.scroll.rememberEditorScrollController
import io.github.numq.kortex.core.compose.scrollbar.rememberHorizontalScrollbarAdapter
import io.github.numq.kortex.core.compose.scrollbar.rememberVerticalScrollbarAdapter
import io.github.numq.krope.core.Encoding
import io.github.numq.krope.text.RopeTextBuffer
import io.github.numq.krope.text.TextLineEnding
import org.jetbrains.skia.FontMgr
import org.jetbrains.skia.FontStyle
import org.jetbrains.skia.Typeface
@Composable
fun FullCodeEditorScreen() {
val scope = rememberCoroutineScope()
// 1. Persistent Krope buffer
val buffer = remember {
RopeTextBuffer(
initialText = """
package example
fun main() {
println("Hello from Kortex Skia Code Editor!")
}
""".trimIndent(),
initialLineEnding = TextLineEnding.LF,
initialEncoding = Encoding.UTF8,
enablePooling = true
)
}
// 2. Instantiate engines
val editorEngine = remember(buffer) { EditorEngine(scope = scope, buffer = buffer) }
val codeEngine = remember(editorEngine) {
CodeEditorEngine(
scope = scope,
engine = editorEngine,
analysisService = object : LanguageAnalysisService {} // Plug custom analysis here
)
}
// 3. Configure typography & theme
val font = remember {
val typeface = FontMgr.default.matchFamilyStyle("JetBrains Mono", FontStyle.NORMAL)
?: FontMgr.default.matchFamilyStyle(null, FontStyle.NORMAL)
?: Typeface.makeEmpty()
EditorFont(typeface = typeface, size = 14f, lineSpacing = 1.25f)
}
val isDark = isSystemInDarkTheme()
val colorScheme = if (isDark) darkColorScheme() else lightColorScheme()
val theme = remember(colorScheme, isDark) { CodeEditorTheme.fromColorScheme(colorScheme, isDark) }
val scrollController = rememberEditorScrollController()
MaterialTheme(colorScheme = colorScheme) {
Box(modifier = Modifier.fillMaxSize()) {
KortexCodeEditor(
engine = codeEngine,
font = font,
theme = theme,
scrollController = scrollController,
columnRuler = 80,
modifier = Modifier.fillMaxSize()
)
// Desktop vertical scrollbar
if (scrollController.canScrollVertically) {
VerticalScrollbar(
adapter = rememberVerticalScrollbarAdapter(scrollController),
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight()
)
}
// Desktop horizontal scrollbar aligned with text content area
if (scrollController.canScrollHorizontally) {
val density = LocalDensity.current
val gutterPadding = with(density) { scrollController.gutterWidth.toDp() }
HorizontalScrollbar(
adapter = rememberHorizontalScrollbarAdapter(scrollController),
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.padding(start = gutterPadding)
)
}
}
}
}To equip Kortex with syntax highlighting, diagnostics, autocompletion, and hover information, provide an implementation
of LanguageAnalysisService:
val customLanguageService = object : LanguageAnalysisService {
override val tokenProvider = TokenProvider { snapshot, range ->
// Return Map<Int, List<Token>> (Key = Line Index)
// Executed asynchronously in background (debounced at 50ms)
parseSyntaxTokens(snapshot, range)
}
override val diagnosticsProvider = DiagnosticsProvider { snapshot ->
// Return List<CodeIssue>
// Executed asynchronously in background (debounced at 300ms)
runLinter(snapshot)
}
override val completionProvider = CompletionProvider { snapshot, offset ->
// Return List<CodeSuggestion>
// Triggered via Ctrl+Space / Cmd+Space or typing triggers
computeAutocompletions(snapshot, offset)
}
override val documentationProvider = DocumentationProvider { snapshot, offset ->
// Return CodeDocumentation(content, range)
computeHoverDocumentation(snapshot, offset)
}
}Debounce intervals (50ms for tokens, 300ms for diagnostics) are fixed in
CodeEditorEngine. Providers run onDispatchers.Defaultand receive the latestTextSnapshotrevision only - stale results are dropped viadistinctUntilChangedBy(TextSnapshot::revision).
Tokens are mapped by zero-based line indices using TextRange and TokenType:
// A TokenProvider receives `range = null` (whole document) or a specific TextRange
// for incremental re-tokenization after an edit.
val token = Token(
range = TextRange(
start = TextPosition(line = 0, column = 0),
end = TextPosition(line = 0, column = 7)
),
type = TokenType.KEYWORD
)Built-in token types include:
TokenType.KEYWORD, TokenType.KEYWORD_CONTROL
TokenType.TYPE, TokenType.TYPE_BUILTIN
TokenType.FUNCTION, TokenType.FUNCTION_METHOD
TokenType.VARIABLE, TokenType.VARIABLE_PARAMETER
TokenType.STRING, TokenType.NUMBER
TokenType.COMMENT, TokenType.COMMENT_LINE, TokenType.COMMENT_BLOCK
TokenType.OPERATOR, TokenType.PUNCTUATION, TokenType.PUNCTUATION_BRACKET, TokenType.PUNCTUATION_DELIMITER
TokenType.MARKUP_LINK, TokenType.DEFAULT
Issues are rendered as smooth, high-precision sine-wave underline paths on the Skia canvas:
val compilerError = CodeIssue.Error(
range = TextRange(
start = TextPosition(line = 1, column = 4),
end = TextPosition(line = 1, column = 16)
),
message = "Unresolved reference: unresolvedMethod",
source = "Compiler",
code = "E0042"
)Available issue types: CodeIssue.Error, CodeIssue.Warning, CodeIssue.Information, CodeIssue.Hint,
CodeIssue.Unknown.
val suggestion = CodeSuggestion(
label = "calculateMetrics(buffer)",
kind = CodeSuggestion.Kind.FUNCTION,
text = "calculateMetrics()",
detail = "Result",
documentation = "Analyzes the persistent rope buffer and computes structural metrics."
)If range is provided, applying the suggestion replaces that range (useful for partial word completion); otherwise
it inserts text at the caret anchor. This is handled by CodeEditorAction.Completion.Apply →
TextOperation.Data.Single.Replace / .Insert.
Popup controls when visible:
DirectionDown / DirectionUp: Navigate items.Enter / Tab: Apply selected completion.Escape: Dismiss overlay.val docs = CodeDocumentation(
content = "RopeTextBuffer: Thread-safe, balanced Red-Black tree buffer.",
// `range` is optional - omit it to attach documentation to the whole document
range = TextRange(
start = TextPosition(line = 0, column = 5),
end = TextPosition(line = 0, column = 19)
)
)Kortex themes use a hierarchical dot-separated resolver. When styling keyword.control, if no explicit style exists, it
automatically inherits from keyword:
val mySyntaxTheme = SyntaxTheme.build(
defaultText = Color(0xFFABB2BF)
) {
put(TokenType.KEYWORD.name, Color(0xFFC678DD))
put(TokenType.KEYWORD_CONTROL.name, Color(0xFFE06C75))
put(TokenType.FUNCTION.name, Color(0xFF61AFEF))
put(TokenType.STRING.name, Color(0xFF98C379))
put(TokenType.NUMBER.name, Color(0xFFD19A66))
put(TokenType.COMMENT.name, Color(0xFF5C6370))
}
val editorTheme = CodeEditorTheme(
editor = EditorTheme.fromColorScheme(colorScheme),
syntax = mySyntaxTheme,
diagnostics = DiagnosticsTheme(
error = Color(0xFFE06C75),
warning = Color(0xFFE5C07B),
info = Color(0xFF61AFEF),
hint = Color(0xFF5C6370)
),
occurrences = OccurrencesTheme(
matchBackground = Color(0xFF3E4451).copy(alpha = 0.5f),
currentMatchBackground = Color(0xFF528BFF).copy(alpha = 0.4f)
),
folding = FoldingTheme(
iconColor = Color(0xFF61AFEF)
),
ruler = Color(0xFF3E4451).copy(alpha = 0.3f)
)Extend or customize keyboard behaviors using buildEditorKeymap:
val myCustomKeymap = buildEditorKeymap(baseKeymap = DefaultCodeEditorKeymap.create(codeEngine)) {
// Quick Save: Ctrl+S / Cmd+S
bind(Key.S, primary = true, description = "Save File") {
val content = state.snapshot.text
saveToFile(content)
}
// Move current line up: Alt+Up
bind(Key.DirectionUp, alt = true, requiresEditable = true, description = "Move Line Up") {
dispatch(EditAction.MoveLineUp)
}
// Format document: Ctrl+Alt+L
bind(Key.L, primary = true, alt = true, requiresEditable = true, description = "Reformat Code") {
formatSource()
}
}| Shortcut (PC / Mac) | Action Description |
|---|---|
Ctrl+A / Cmd+A
|
Select entire document |
Ctrl+D / Cmd+D
|
Select word / Add next occurrence match (Multi-Cursor) |
Alt + Click / Drag |
Add independent cursor / rectangular selection drag |
Ctrl+Z / Cmd+Z
|
Undo |
Ctrl+Shift+Z / Cmd+Shift+Z
|
Redo |
Ctrl+C / Ctrl+X / Ctrl+V
|
Copy / Cut / Paste (works with multi-cursors) |
Ctrl+Shift+D |
Duplicate current line or selection |
Alt + Up / Down |
Move current line or block up / down |
Tab / Shift+Tab
|
Indent / Unindent selected block or insert soft tabs |
Ctrl+/ / Cmd+/
|
Toggle line comment (// ) |
Ctrl+Space |
Request IntelliSense code completion |
Ctrl+Backspace / Ctrl+Delete
|
Delete word left / right |
Home / End
|
Smart move to indent start / line end |
Ctrl+Home / Ctrl+End
|
Move to document start / document end |
PageUp / PageDown
|
Viewport page scrolling with caret tracking |
Escape |
Dismiss completion popup / collapse extra carets |
Kortex easily renders files with hundreds of thousands of lines by rendering only the visible viewport slice:
val foldMap = state.foldMap
val visualLineIndex = foldMap.documentToVisualLine(actualDocumentLine)
val documentLineIndex = foldMap.visualToDocumentLine(visualLineIndex)FoldMap manages collapsed regions using interval trees with $O (\log K)$ mapping.KortexCodeEditor wires gutter clicks to ViewportAction.ToggleFolding(line) when a
FoldingRegion starts on that line. KortexEditor (plain text) exposes a generic onGutterClick(line, offset)
callback instead — you decide what a gutter click means.Typing individual characters does not create hundreds of micro undo entries. HistoryManager automatically merges edits
when:
Insert + Insert or Delete + Delete).Executing Undo/Redo restores both buffer contents and exact caret/selection states.
Add multiple cursors programmatically or interactively:
// Add cursor at specific location
editorEngine.dispatch(CaretAction.AddCursor(TextPosition(line = 10, column = 4)))
// Add next match to selection
editorEngine.dispatch(SelectionAction.SelectNextMatch)SelectNextMatch behaves like VS Code's Ctrl+D:
primary.range.end, wrapping to the start of the document.Ctrl+D advances to the next distinct
occurrence.Selection.normalize().FORWARD / BACKWARD).Kortex provides adapters for Compose Desktop scrollbars:
EditorScrollController.asVerticalScrollbarAdapter() / rememberVerticalScrollbarAdapter(...)
EditorScrollController.asHorizontalScrollbarAdapter() / rememberHorizontalScrollbarAdapter(...)
A built-in Material 3 right-click context menu handles Cut, Copy, Paste, Undo, Redo, and Select All out of the box.
| Class / Interface | Responsibility |
|---|---|
EditorEngine |
Coordinates text buffer operations, history tracking, and reducer dispatch loops. |
CodeEditorEngine |
Extends EditorEngine with asynchronous debounced language analysis pipelines. |
EditorConfig |
Immutable editor preferences: tabSize, useSoftTabs, lineSeparator (derived tabString). |
EditorState |
Immutable snapshot of text snapshot, selections, collapsed ranges, and config. |
CodeEditorState |
Extends EditorState with syntax tokens, lint diagnostics, and completion state. |
EditorFont |
Thread-safe Skia typeface wrapper managing font collections, ascents, descents, and paragraph builders. |
EditorScrollController |
Stable controller handling viewport offsets, bounds, and auto-scrolling to carets. |
FoldMap |
High-performance coordinate mapper between logical lines and visible lines. |
EditorConfig is passed into EditorState.config and can be updated at runtime via
EditorAction.UpdateConfig(EditorConfig(tabSize = 2, useSoftTabs = true)).
CodeEditorState.CompletionState is a sealed hierarchy: Hidden or
Visible(suggestions: List<CodeSuggestion>, selectedIndex: Int, anchorPosition: TextPosition). The selectedItem
property resolves the currently highlighted suggestion.
EditAction:
Insert, Paste, Enter, Tab, Untab, Backspace, Delete, WordDeleteLeft, WordDeleteRight, MoveLineUp,
MoveLineDown, Duplicate, JoinLines
CaretAction: Move, AddCursor, MoveLeft, MoveRight, MoveUp, MoveDown, MoveWordLeft,MoveWordRight,
MoveLineStart, MoveLineEnd, MoveDocStart, MoveDocEnd.SelectionAction: Clear, SelectAll, SelectWordAt, SelectLine, SelectRange, AddSelection,
SetSelections, SelectNextMatch.ViewportAction: ToggleFolding, UpdateCollapsedLines, UpdateFoldingRegions.HistoryAction: Undo, Redo.CodeEditorAction: Core, Edit.ToggleComment, External.UpdateSyntax, External.UpdateAnalysis,
Completion.Show, Completion.Apply, Completion.Dismiss, Completion.SelectNext, Completion.SelectPrevious.
EditActionandCaretActionmutate the buffer or selection directly.ViewportActiononly rebuildsFoldMap(buffer is untouched).HistoryActionis routed asEditorCommand.Undo/Redoand replays the inverseTextOperation.Datarecorded byHistoryManager.
Apache-2.0 License - see LICENSE file for details.