A fast code editor for Jetpack Compose, with syntax highlighting, line wrapping and pinch to zoom.
- Stays responsive with large documents, because only the visible lines are measured and drawn
- Pinch to zoom and fling, with scrollbars to drag through long documents
- Line wrapping
- Folding of whole sections and parts of lines, built in for all languages of KodeHighlighter and your own rules for others
- A bar next to the text with line numbers and your own columns, like warnings or bookmarks
- Syntax highlighting for many languages with KodeHighlighter, or your own rules
- Hardware keyboard support, selection handles and a text menu
- Built for collaborative editing, with changes of other users and an overlay for their cursors
- Animations for all of this, which can be turned off
Every build of master publishes the demo app as a pre-release build-<n>, a release build with R8 that is signed
with the public key .ci/debug.keystore. The newest 5 are kept, and they are built from the current code, which can be
ahead of the latest library release.
The easiest way to install and update it is Obtainium:
Open the link on the phone. Obtainium shows Add App with the address filled in. Turn on Include prereleases,
because the demo builds are pre-releases, then tap Add and Install. Everything else stays at its default. Without
the link, enter the address https://github.com/markusressel/KodeEditor in Add App yourself.
Without that switch Obtainium reports "Could not find a suitable release", because the library releases have no APK. All builds have the same key and get a higher version code from CI, so they update each other, and Obtainium checks for new builds and offers the update.
The editor requires minSdk 24 and compileSdk 37. It is published through JitPack, so add that to your repositories:
repositories {
maven { url = uri("https://jitpack.io") }
}Then add the editor. It brings KodeHighlighter with all its languages, R8 removes those your app does not use:
dependencies {
implementation("com.github.markusressel:KodeEditor:9.1.0")
}var text by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue("# Hello KodeEditor"))
}
KodeEditor(
modifier = Modifier.fillMaxSize(),
languageRuleBook = MarkdownRuleBook(),
colorScheme = DarkBackgroundColorSchemeWithSpanStyle(),
text = text,
onValueChange = { text = it },
lineWrapping = true,
)Have a look at the demo app (app module) for a complete sample.
| Parameter | Description | Default |
|---|---|---|
colors |
colors of the text and the bar, see KodeEditorDefaults.editorColors() |
Material theme |
textStyle |
style of the text, a monospaced font makes line wrapping cheap | LocalTextStyle.current |
enabled |
whether the user can interact with the editor | true |
readOnly |
whether the text can be selected but not changed | enabled.not() |
lineWrapping |
whether long lines continue in the next row instead of scrolling sideways | false |
minZoom, maxZoom |
limits of the zoom | 0.25f, 10f |
keepCursorVisible |
whether the view follows the cursor while the user edits | true |
scrollbars |
whether scrollbars show up while the view moves, their thumbs can be dragged | true |
scrollbarStyle |
KodeEditorScrollbarStyle: the thickness, margin, touch size and shortest thumb of the bars, how long they stay after the view stopped and how long they fade |
KodeEditorScrollbarStyle() |
autoCorrect |
whether the keyboard may correct words on its own, it can still offer suggestions without this | false |
gutterColumns |
what the bar next to the text shows | line numbers |
animations |
what the editor animates | everything |
foldProvider |
what can be folded | the provider of the language of languageRuleBook |
lineDecorations |
what the editor shows on whole lines | nothing |
keyBindings |
what the keys of a hardware keyboard do | KodeEditorDefaults.keyBindings |
overlay |
content on top of the text | nothing |
onTextLayout |
called with the KodeEditorLayout when the text changed |
nothing |
languageRuleBook decides what is highlighted, and colorScheme how it looks. Pass the rule book that fits the
document, KodeHighlighterLanguages.detectRuleBook(fileName, text) finds it from the name or the content of a file. The
available languages and how to write your own are described in
KodeHighlighter.
MarkdownRuleBook() highlights YAML front matter and fenced code blocks in their language, like ```kotlin.
MarkdownRuleBook { null } keeps code blocks in one color. The Markdown color schemes only style Markdown, so the editor
colors the other languages with GitHubDarkTheme or GitHubLightTheme, whichever fits the brightness of
textFieldBackgroundColor. A color scheme that styles a rule itself keeps its style. Code blocks that did not change
are not highlighted again while typing.
The editor keeps the text itself, in a KodeEditorState, and each change happens there directly on the main thread.
Hand the state to the editor instead of a TextFieldValue to avoid a second copy of the text. This is the fastest way
to use the editor, and changes can not overtake each other:
val state = rememberKodeEditorState(initialText)
DisposableEffect(state) {
val listener = object : KodeEditorListener {
// the user changed the text, this is called within the change
override fun onLocalEdit(splice: TextSplice) = sync.send(splice)
}
state.addListener(listener)
onDispose { state.removeListener(listener) }
}
// changes of other users, the cursor and the keyboard stay in the right place
state.applyRemote(splices)
KodeEditor(
state = state,
languageRuleBook = ruleBook,
colorScheme = colorScheme,
)All lines have the same height, so a style with a larger font size does not make its line taller.
gutterColumns decides what the bar shows, from left to right. By default that is the line numbers. A column draws
one cell per line and can react to taps, which is enough for warning markers or bookmarks. Reading snapshot state in
drawCell draws the bar again when that state changes.
With lineWrapping, a cell is on the first row of its line. A column that marks the whole line, e.g. who wrote it, can
draw down to cell.fullHeight, the height of all rows of the line.
Columns work with line numbers, so markers that belong to a line have to move when an edit moves that line. The demo app
shows both kinds: WarningColumn computes its markers from the text, and BookmarkColumn keeps its own and moves them
with LineEdit.
class WarningColumn(private val warnings: SnapshotStateList<Int>) : KodeEditorGutterColumn {
override fun width(lineCount: Int) = 48
override fun DrawScope.drawCell(cell: GutterCell) {
if (cell.line in warnings) drawCircle(Color.Yellow, 12f, Offset(cell.width / 2, cell.height / 2))
}
override fun onTap(line: Int): Boolean {
// show the warning of this line
return line in warnings
}
}
KodeEditor(
// ...
gutterColumns = listOf(warningColumn, rememberLineNumberColumn(textStyle, colors)),
)KodeEditorViewState moves the view without touching the cursor, the selection or the focus, so it also works in a
read-only editor and does not open the keyboard. scrollToPosition stays put if the position is visible already.
scrollToRange centers the middle of a range, and stays put if that is near the center. scrollToLine puts a place
in a line exactly at a height of the view, the top by default, also for tiny steps. isPositionVisible and
visibleRange tell what is on the screen. lineAt tells which place in which line is at a height of the view,
verticalScrollOffset and maxVerticalScrollOffset how far the view is scrolled.
An app can follow the cursor of another user, and pause while the user moves the view, like navigation in a map:
val viewState = rememberKodeEditorViewState()
var paused by remember { mutableStateOf(false) }
LaunchedEffect(remoteCursor, paused) {
if (!paused) viewState.scrollToPosition(remoteCursor)
}
LaunchedEffect(paused, viewState.isUserMovingView) {
if (paused && !viewState.isUserMovingView) {
delay(5000)
paused = false
}
}
KodeEditor(
// ...
viewState = viewState,
onUserMovedView = { paused = true },
// or changes of the text in front of the local cursor scroll to that cursor
keepCursorVisible = paused,
)onUserMovedView is called once per gesture, and isUserMovingView lasts until the gesture and its fling have ended.
A dragged scrollbar counts as a gesture too.
Both ignore what moves the view on its own. To open a text at a position, call
scrollToPosition(position, animate = false) after the text is in the editor. The editor then shows nothing until it
is at that position, instead of the top of the text first.
To keep the editor next to another scrolling view, like a preview of the text, pass the line at a height of that view
on every change, and the same height as viewportFraction. scrollToLine jumps by default, so this follows smoothly.
scrollToLine with what lineAt returns for the same height does not move the view:
LaunchedEffect(viewState) {
snapshotFlow { previewReadingLine }
.collect { (line, fraction, height) -> viewState.scrollToLine(line, fraction, viewportFraction = height) }
}
// the other way, while the user scrolls the editor
val progress = viewState.verticalScrollOffset!! / viewState.maxVerticalScrollOffset!!.coerceAtLeast(1f)
val place = viewState.lineAt(progress) // a LinePosition(line, fraction)To sync the other way, only react to the editor while isUserMovingView is true, or after onUserMovedView. Scrolls
of KodeEditorViewState never set either, so following the preview does not scroll the preview back.
The bar next to the text animates to a new width, the numbers of new and removed lines fade, and with line wrapping everything moves to its new place when lines wrap differently. Each can be turned off:
KodeEditor(
// ...
animations = KodeEditorAnimations(reflow = false),
)Folding moves what is below a fold to its new place and fades the lines of the fold, folding = false turns that off.
KodeEditorAnimations.None turns off all of them.
foldProvider decides what can be folded, and adds a column with arrows to the end of the bar. Tap an arrow or the ⋯
behind a folded line. The editor handles everything else, so only finding the regions depends on the language.
By default, the editor folds the language of its languageRuleBook, so a KotlinRuleBook folds Kotlin without any
setup. A rule book whose language has no provider, like most of your own, folds nothing. Pass a provider to choose
one, or null to turn folding off:
KodeEditor(
// ...
foldProvider = null,
)Every language of KodeHighlighter has a built-in provider: CFoldProvider, CppFoldProvider, CSharpFoldProvider,
CssFoldProvider, DartFoldProvider, DiffFoldProvider, GoFoldProvider, JavaFoldProvider,
JavaScriptFoldProvider, JsonFoldProvider, KotlinFoldProvider, MarkdownFoldProvider, OCamlFoldProvider,
PropertiesFoldProvider, PythonFoldProvider, RustFoldProvider, SwiftFoldProvider, TomlFoldProvider,
XmlFoldProvider, also for HTML, and YamlFoldProvider.
KodeEditorFoldProviders.forLanguage(KotlinLanguage) and forLanguageId("kotlin") find one by its language. They fold blocks, brackets over
several lines, comments, runs of imports, and what else belongs to the language, like the headings of Markdown, the
elements of XML or the hunks of a diff. Each one reads the text once.
For other languages, BraceFoldProvider folds languages with curly brackets and IndentFoldProvider languages with
indented blocks, both with your settings for comments, strings and imports. Or write your own:
val scala = BraceFoldProvider(nestedBlockComments = true, importPrefixes = listOf("import "))
val custom = KodeEditorFoldProvider { text -> listOf(FoldRegion(startLine = 0, endLine = 3)) }A folded line gets a background and a placeholder, their colors are foldedLineBackgroundColor and
foldPlaceholderColor of KodeEditorColors. Line numbers stay the same, the arrow keys skip folded text, and a cursor
that ends up in it or a change across its edge unfolds it. The folds are not saved with the text.
A provider can also show parts of a line as something shorter, in a box. The text itself stays what it is, so copying a
selection returns all of it. MarkdownFoldProvider does this for targets of links and images, long addresses, comments
and character references.
val addresses = object : KodeEditorFoldProvider {
override fun foldRegions(text: String) = emptyList<FoldRegion>()
// runs on another thread
override fun inlineFolds(text: String) = listOf(InlineFold(start = 10, end = 60, placeholder = "example.com/…"))
}Tapping the box or Ctrl + Plus shows the text, and it goes back when the cursor leaves or with Ctrl + Minus. The arrow keys
move over a box. A fold that the provider finds with another range, like while typing a link or next to one, stays open
until the cursor leaves the line, so the text does not jump around. Other changes leave a box closed. Backspace behind a
box or Delete in front of one only opens it, the next press removes text you can see. The colors are
inlineFoldBackgroundColor and inlineFoldTextColor of KodeEditorColors.
lineDecorations shows something on whole lines: a background behind the selection and the text, text behind the end of
the line, or both. This fits a highlight of the current line, lines with errors, changed lines or notes next to the
code. Only visible lines are asked, and reading snapshot state in a decoration updates it when that state changes.
class ErrorLines(private val errors: SnapshotStateMap<Int, String>) : KodeEditorLineDecoration {
override fun DrawScope.drawBackground(line: DecoratedLine) {
if (line.line in errors) drawRect(Color.Red.copy(alpha = 0.15f), size = Size(line.width, line.height))
}
override fun trailingText(line: Int) = errors[line]?.let { AnnotatedString(" $it", SpanStyle(Color.Red)) }
override fun onTrailingTextTap(line: Int): Boolean {
// show the whole error of this line
return line in errors
}
}
KodeEditor(
// ...
lineDecorations = listOf(errorLines),
)Decorations work with line numbers like the columns of the bar, so they have to move when an edit moves their line. To mark a part of a line or to draw on top of the text, use the overlay.
Use the overlay parameter to draw on top of the editor text, e.g. the cursors and selections of other users.
The content moves with zoom and scroll and receives the KodeEditorLayout of the editor text:
KodeEditor(
// ...
overlay = { layout ->
val cursorRect = layout.getCursorRect(remoteCursorIndex)
Canvas(modifier = Modifier.matchParentSize()) {
drawLine(Color.Red, cursorRect.topCenter, cursorRect.bottomCenter, strokeWidth = 2.dp.toPx())
}
},
)The editor works with hardware keyboards:
| Keys | Action |
|---|---|
| Arrow keys | move the cursor |
| Ctrl or Alt + Left/Right | move by word |
| Ctrl or Alt + Up/Down | move by paragraph |
| Page Up, Page Down | move by the visible height |
| Home, End | move to the start or end of the line, with Ctrl of the text |
| Shift + Enter | start a new line below the current one, without splitting it |
| Tab | insert a tab (in a read-only editor it moves the focus) |
| Ctrl + A, C, X, V | select all, copy, cut, paste |
| Ctrl + Z, Ctrl + Y or Ctrl + Shift + Z | undo, redo |
| Shift + Tab | move the focus out of the editor |
| Shift + a key to move the cursor | extend the selection |
| Ctrl + Plus, Minus | show or fold what is at the cursor, otherwise zoom in or out |
| Ctrl + 0 | reset the zoom |
The D-pad of a TV remote or a gamepad still moves the focus between the elements of the screen.
KodeEditorDefaults.keyBindings holds these as a list, to show them to the user or to change them with keyBindings.
The first binding that fits a key press counts, and a key without a binding is left to the rest of the app:
KodeEditor(
state = state,
languageRuleBook = ruleBook,
colorScheme = colorScheme,
keyBindings = listOf(
KodeEditorKeyBinding(Key.D, KodeEditorKeyAction.DeleteWord, ctrl = true),
) + KodeEditorDefaults.keyBindings.filter { it.action != KodeEditorKeyAction.ExpandOrZoomIn },
)The View based editor (CodeEditorLayout, CodeEditorView) was removed in 8.0.0. Version 7.0.0 is the last one that has
it, and its XML attributes and APIs are described in the
README of 7.0.0.
GitHub is for social coding: if you want to write code, I encourage contributions through pull requests from forks of this repository. Create GitHub tickets for bugs and new features and comment on the ones that you are interested in.
MIT License
Copyright (c) 2018 Markus Ressel
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

