From cf6af1a7d108c695621f1a8bc3d260dee69443a0 Mon Sep 17 00:00:00 2001 From: Sam King <49367123+agent-polyblank@users.noreply.github.com> Date: Fri, 26 Sep 2025 09:23:53 +0100 Subject: [PATCH] Add line numbers (#38) * Add textstyle to codeview to remove the need for a CompositionLocalProvider * wip line numbers * end align numbers * add line number toggle to example app * add line number toggle to example app * remove vertical divider I was trying out in swiftui version * update changelog --- CHANGELOG.md | 7 + .../androidexample/LineNumberSwitcher.kt | 44 +++++ .../dev/snipme/androidexample/MainActivity.kt | 13 +- desktopExample/src/main/kotlin/Main.kt | 1 + .../dev/snipme/kodeview/view/CodeEditText.kt | 63 +++++--- .../dev/snipme/kodeview/view/CodeTextView.kt | 40 ++++- .../kodeview/view/material3/CodeEditText.kt | 152 ++++++++++++------ 7 files changed, 239 insertions(+), 81 deletions(-) create mode 100644 androidExample/src/main/java/dev/snipme/androidexample/LineNumberSwitcher.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 01a3755..d05a72e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [0.10.0] + +### Added +- Add toggleable line numbers to `CodeTextView` and `CodeEditText` +- Updated sample app to include a toggle for line numbers +- Added `textStyle` param to `CodeTextView` and `CodeEditText` for custom text styling + ## [0.9.0] ### Changed diff --git a/androidExample/src/main/java/dev/snipme/androidexample/LineNumberSwitcher.kt b/androidExample/src/main/java/dev/snipme/androidexample/LineNumberSwitcher.kt new file mode 100644 index 0000000..207a0cc --- /dev/null +++ b/androidExample/src/main/java/dev/snipme/androidexample/LineNumberSwitcher.kt @@ -0,0 +1,44 @@ +package dev.snipme.androidexample + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.FormatAlignLeft +import androidx.compose.material.icons.filled.DarkMode +import androidx.compose.material.icons.outlined.FormatAlignLeft +import androidx.compose.material.icons.outlined.FormatListNumbered +import androidx.compose.material.icons.outlined.LightMode +import androidx.compose.material.icons.outlined.Reorder +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun LineNumberSwitcher( + lineNumbersEnabled: Boolean, + modifier: Modifier = Modifier, + onChange: (Boolean) -> Unit, +) { + Surface { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(Modifier.width(8.dp)) + Icon(Icons.Outlined.FormatListNumbered, contentDescription = "Line numbers enabled") + Spacer(Modifier.width(16.dp)) + Switch(checked = lineNumbersEnabled, onCheckedChange = onChange) + Spacer(Modifier.width(16.dp)) + Icon(Icons.Outlined.Reorder, contentDescription = "Dark mode") + Spacer(Modifier.width(8.dp)) + } + } +} \ No newline at end of file diff --git a/androidExample/src/main/java/dev/snipme/androidexample/MainActivity.kt b/androidExample/src/main/java/dev/snipme/androidexample/MainActivity.kt index 07fce69..754947d 100644 --- a/androidExample/src/main/java/dev/snipme/androidexample/MainActivity.kt +++ b/androidExample/src/main/java/dev/snipme/androidexample/MainActivity.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -58,13 +59,16 @@ private val sampleCode = @Composable fun App() { val isDarkModeState = remember { mutableStateOf(false) } + val areLineNumbersEnabled = remember { mutableStateOf(true) } val isDarkMode = isDarkModeState.value + val lineNumbersEnabled = areLineNumbersEnabled.value val highlightsState = remember { mutableStateOf( Highlights.Builder(code = sampleCode).build() ) } + val highlights = highlightsState.value fun updateSyntaxTheme(theme: SyntaxTheme) { @@ -100,6 +104,10 @@ fun App() { Spacer(Modifier.height(16.dp)) + LineNumberSwitcher(lineNumbersEnabled, modifier = Modifier.fillMaxWidth()) { lineNumberEnabled -> + areLineNumbersEnabled.value = lineNumberEnabled + } + Text( modifier = Modifier.fillMaxWidth(), text = "KodeView", @@ -109,11 +117,11 @@ fun App() { Spacer(modifier = Modifier.size(16.dp)) - CodeTextView(highlights = highlights) + CodeTextView(highlights = highlights, showLineNumbers = lineNumbersEnabled) Spacer(modifier = Modifier.size(16.dp)) - Divider() + HorizontalDivider() Spacer(modifier = Modifier.size(16.dp)) @@ -133,6 +141,7 @@ fun App() { disabledIndicatorColor = Color.Transparent, errorIndicatorColor = Color.Transparent, ), + showLineNumbers = lineNumbersEnabled, ) Spacer(modifier = Modifier.size(16.dp)) diff --git a/desktopExample/src/main/kotlin/Main.kt b/desktopExample/src/main/kotlin/Main.kt index 1b04ab1..e7e757a 100644 --- a/desktopExample/src/main/kotlin/Main.kt +++ b/desktopExample/src/main/kotlin/Main.kt @@ -89,6 +89,7 @@ fun main() = application { updateSyntaxTheme(highlights.getTheme().useDark(setToDarkMode)!!) } + Spacer(Modifier.height(16.dp)) Text( diff --git a/kodeview/src/commonMain/kotlin/dev/snipme/kodeview/view/CodeEditText.kt b/kodeview/src/commonMain/kotlin/dev/snipme/kodeview/view/CodeEditText.kt index 80230a5..62ad281 100644 --- a/kodeview/src/commonMain/kotlin/dev/snipme/kodeview/view/CodeEditText.kt +++ b/kodeview/src/commonMain/kotlin/dev/snipme/kodeview/view/CodeEditText.kt @@ -1,18 +1,22 @@ package dev.snipme.kodeview.view import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.LocalTextStyle +import androidx.compose.material.MaterialTheme import androidx.compose.material.TextField import androidx.compose.material.TextFieldColors import androidx.compose.material.TextFieldDefaults +import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle @@ -24,6 +28,7 @@ import updateIndentations import dev.snipme.highlights.Highlights import dev.snipme.highlights.model.CodeHighlight import generateAnnotatedString +import androidx.compose.ui.unit.dp @Composable fun CodeEditText( @@ -47,7 +52,9 @@ fun CodeEditText( minLines: Int = 1, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, shape: Shape = TextFieldDefaults.TextFieldShape, - colors: TextFieldColors = TextFieldDefaults.textFieldColors() + colors: TextFieldColors = TextFieldDefaults.textFieldColors(), + showLineNumbers: Boolean = false, + lineNumberTextStyle: TextStyle = textStyle.copy() ) { val currentText = remember { mutableStateOf( @@ -78,26 +85,34 @@ fun CodeEditText( ) } - TextField( - modifier = modifier.fillMaxWidth(), - onValueChange = ::updateNewValue, - value = currentText.value, - enabled = enabled, - readOnly = readOnly, - textStyle = textStyle, - label = label, - placeholder = placeholder, - leadingIcon = leadingIcon, - trailingIcon = trailingIcon, - isError = isError, - visualTransformation = visualTransformation, - keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions, - singleLine = singleLine, - maxLines = maxLines, - minLines = minLines, - interactionSource = interactionSource, - shape = shape, - colors = colors, - ) + Row(modifier = modifier) { + if (showLineNumbers) { + val lines = currentText.value.text.lines().size + Column(horizontalAlignment = Alignment.End,) { + for (i in 1..lines) { + androidx.compose.material.Text( + text = i.toString(), + style = lineNumberTextStyle + ) + } + + } + Spacer(modifier = Modifier.width(8.dp)) + } + TextField( + modifier = Modifier.fillMaxWidth(), + onValueChange = ::updateNewValue, + value = currentText.value, + enabled = enabled, + readOnly = readOnly, + textStyle = textStyle, + label = label, + placeholder = placeholder, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + isError = isError, + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + ) + } } \ No newline at end of file diff --git a/kodeview/src/commonMain/kotlin/dev/snipme/kodeview/view/CodeTextView.kt b/kodeview/src/commonMain/kotlin/dev/snipme/kodeview/view/CodeTextView.kt index 0035379..34ac50d 100644 --- a/kodeview/src/commonMain/kotlin/dev/snipme/kodeview/view/CodeTextView.kt +++ b/kodeview/src/commonMain/kotlin/dev/snipme/kodeview/view/CodeTextView.kt @@ -2,8 +2,13 @@ package dev.snipme.kodeview.view import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -12,16 +17,22 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp import dev.snipme.highlights.Highlights import generateAnnotatedString @Composable fun CodeTextView( modifier: Modifier = Modifier.background(Color.Transparent), - highlights: Highlights + highlights: Highlights, + textStyle: TextStyle = LocalTextStyle.current, + showLineNumbers: Boolean = false, + lineNumberTextStyle: TextStyle = textStyle.copy() ) { var textState by remember { mutableStateOf(AnnotatedString(highlights.getCode())) @@ -37,11 +48,26 @@ fun CodeTextView( modifier = modifier, color = Color.Transparent ) { - Text( - modifier = modifier - .verticalScroll(rememberScrollState()) - .horizontalScroll(rememberScrollState()), - text = textState - ) + Row(modifier = Modifier + .verticalScroll(rememberScrollState()) + .horizontalScroll(rememberScrollState()) + ) { + if (showLineNumbers) { + val lines = textState.text.lines().size + Column(horizontalAlignment = Alignment.End) { + for (i in 1..lines) { + Text( + text = i.toString(), + style = lineNumberTextStyle, + ) + } + } + Spacer(modifier = Modifier.width(8.dp)) + } + Text( + text = textState, + style = textStyle + ) + } } } \ No newline at end of file diff --git a/kodeview/src/commonMain/kotlin/dev/snipme/kodeview/view/material3/CodeEditText.kt b/kodeview/src/commonMain/kotlin/dev/snipme/kodeview/view/material3/CodeEditText.kt index 07b207f..470525f 100644 --- a/kodeview/src/commonMain/kotlin/dev/snipme/kodeview/view/material3/CodeEditText.kt +++ b/kodeview/src/commonMain/kotlin/dev/snipme/kodeview/view/material3/CodeEditText.kt @@ -1,30 +1,41 @@ package dev.snipme.kodeview.view.material3 import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Divider +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp import copySpanStyles -import updateIndentations import dev.snipme.highlights.DefaultHighlightsResultListener import dev.snipme.highlights.Highlights import dev.snipme.highlights.model.CodeHighlight import generateAnnotatedString +import updateIndentations import androidx.compose.material3.LocalTextStyle as LocalTextStyle3 import androidx.compose.material3.TextField as TextField3 import androidx.compose.material3.TextFieldColors as TextFieldColors3 import androidx.compose.material3.TextFieldDefaults as TextFieldDefaults3 + @Composable fun CodeEditText( highlights: Highlights, @@ -47,7 +58,9 @@ fun CodeEditText( minLines: Int = 1, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, shape: Shape = TextFieldDefaults3.shape, - colors: TextFieldColors3 = TextFieldDefaults3.colors() + colors: TextFieldColors3 = TextFieldDefaults3.colors(), + showLineNumbers: Boolean = false, + lineNumberTextStyle: TextStyle = textStyle.copy() ) { val currentText = remember { mutableStateOf( @@ -57,6 +70,7 @@ fun CodeEditText( ) } + LaunchedEffect(highlights) { highlights.getHighlightsAsync(object : DefaultHighlightsResultListener() { override fun onSuccess(result: List) { @@ -78,28 +92,46 @@ fun CodeEditText( ) } - TextField3( - modifier = modifier.fillMaxWidth(), - onValueChange = ::updateNewValue, - value = currentText.value, - enabled = enabled, - readOnly = readOnly, - textStyle = textStyle, - label = label, - placeholder = placeholder, - leadingIcon = leadingIcon, - trailingIcon = trailingIcon, - isError = isError, - visualTransformation = visualTransformation, - keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions, - singleLine = singleLine, - maxLines = maxLines, - minLines = minLines, - interactionSource = interactionSource, - shape = shape, - colors = colors, - ) + Row(modifier = modifier.fillMaxWidth()) { + if (showLineNumbers) { + val lines = currentText.value.text.lines().size.coerceAtLeast(minLines) + Column( + horizontalAlignment = Alignment.End, + modifier = Modifier + .padding(top = 16.dp, end = 8.dp) + ) { + (1..lines).forEach { i -> + Text( + text = i.toString(), + style = lineNumberTextStyle, + ) + } + } + } + + TextField3( + modifier = Modifier.weight(1f), + onValueChange = ::updateNewValue, + value = currentText.value, + enabled = enabled, + readOnly = readOnly, + textStyle = textStyle, + label = label, + placeholder = placeholder, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + isError = isError, + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, + interactionSource = interactionSource, + shape = shape, + colors = colors, + ) + } } @Composable @@ -124,7 +156,9 @@ fun CodeEditTextSwiftUi( minLines: Int = 1, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, shape: Shape = TextFieldDefaults3.shape, - colors: TextFieldColors3 = TextFieldDefaults3.colors() + colors: TextFieldColors3 = TextFieldDefaults3.colors(), + showLineNumbers: Boolean = false, + lineNumberTextStyle: TextStyle = textStyle.copy() ) { val currentText = remember { mutableStateOf( @@ -133,6 +167,11 @@ fun CodeEditTextSwiftUi( ) ) } + val commentColor = remember { + mutableStateOf( + Color(highlights.getTheme().comment) + ) + } LaunchedEffect(highlights) { highlights.getHighlightsAsync(object : DefaultHighlightsResultListener() { @@ -140,6 +179,7 @@ fun CodeEditTextSwiftUi( currentText.value = currentText.value.copy( annotatedString = result.generateAnnotatedString(currentText.value.text), ) + commentColor.value = Color(highlights.getTheme().comment) } }) } @@ -156,27 +196,43 @@ fun CodeEditTextSwiftUi( ) } - TextField3( - modifier = modifier.fillMaxWidth(), - value = currentText.value, - onValueChange = ::updateNewValue, - enabled = enabled, - readOnly = readOnly, - textStyle = textStyle, - label = label, - placeholder = placeholder, - leadingIcon = leadingIcon, - trailingIcon = trailingIcon, - isError = isError, - visualTransformation = visualTransformation, - keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions, - singleLine = singleLine, - maxLines = maxLines, - minLines = minLines, - interactionSource = interactionSource, - shape = shape, - colors = colors, - ) -} + Row(modifier = modifier.fillMaxWidth()) { + if (showLineNumbers) { + val lines = currentText.value.text.lines().size.coerceAtLeast(minLines) + Column( + modifier = Modifier + .padding(top = 16.dp, end = 8.dp) // Align with TextField's internal padding + ) { + (1..lines).forEach { i -> + Text( + text = i.toString(), + style = lineNumberTextStyle, + ) + } + } + } + TextField3( + modifier = Modifier.weight(1f), + value = currentText.value, + onValueChange = ::updateNewValue, + enabled = enabled, + readOnly = readOnly, + textStyle = textStyle, + label = label, + placeholder = placeholder, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + isError = isError, + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, + interactionSource = interactionSource, + shape = shape, + colors = colors, + ) + } +}