From 617ac0f25f37f1cb263f64f14baf58666dbaf062 Mon Sep 17 00:00:00 2001 From: MM20 <15646950+MM2-0@users.noreply.github.com> Date: Tue, 25 Apr 2023 19:30:03 +0200 Subject: [PATCH] Notes widget: add markdown support --- app/ui/build.gradle.kts | 1 + .../ui/component/markdown/MarkdownEditor.kt | 219 ++++++++++++++++++ .../ui/launcher/widgets/notes/NotesWidget.kt | 7 +- .../launcher/widgets/notes/NotesWidgetVM.kt | 5 + .../launcher2/licenses/OpenSourceLicenses.kt | 7 + settings.gradle.kts | 3 + 6 files changed, 237 insertions(+), 5 deletions(-) create mode 100644 app/ui/src/main/java/de/mm20/launcher2/ui/component/markdown/MarkdownEditor.kt diff --git a/app/ui/build.gradle.kts b/app/ui/build.gradle.kts index c3d26e1f..5be3525b 100644 --- a/app/ui/build.gradle.kts +++ b/app/ui/build.gradle.kts @@ -81,6 +81,7 @@ dependencies { implementation(libs.androidx.navigation.compose) implementation(libs.jsoup) + implementation(libs.markdown) // Legacy dependencies implementation(libs.androidx.transition) diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/component/markdown/MarkdownEditor.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/component/markdown/MarkdownEditor.kt new file mode 100644 index 00000000..182ceddc --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/component/markdown/MarkdownEditor.kt @@ -0,0 +1,219 @@ +package de.mm20.launcher2.ui.component.markdown + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Typography +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp +import org.intellij.markdown.MarkdownElementTypes +import org.intellij.markdown.MarkdownTokenTypes +import org.intellij.markdown.ast.ASTNode +import org.intellij.markdown.flavours.space.SFMFlavourDescriptor +import org.intellij.markdown.parser.MarkdownParser +import kotlin.math.min + +@Composable +fun MarkdownEditor( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val typography = MaterialTheme.typography + val delimiterColor = MaterialTheme.colorScheme.secondary + val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() + + val focusManager = LocalFocusManager.current + + BackHandler( + enabled = focused + ) { + focusManager.clearFocus() + } + + + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier, + textStyle = MaterialTheme.typography.bodyMedium.copy( + color = LocalContentColor.current, + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + visualTransformation = remember( + typography, + focused, + delimiterColor + ) { MarkdownTransformation(typography, focused, delimiterColor) }, + interactionSource = interactionSource, + ) +} + +class MarkdownTransformation( + private val typography: Typography, + renderDelimiters: Boolean, + delimiterColor: Color, +) : VisualTransformation { + + private val parser = MarkdownParser(SFMFlavourDescriptor()) + private val delimiterStyle = SpanStyle( + color = delimiterColor, + fontSize = if (renderDelimiters) TextUnit.Unspecified else 0.sp + ) + + override fun filter(text: AnnotatedString): TransformedText { + val tree = parser.buildMarkdownTreeFromString(text.text) + return TransformedText( + buildAnnotatedString { + append(text) + applyStyles(tree, typography, delimiterStyle) + }, + OffsetMapping.Identity, + ) + } +} + +private fun AnnotatedString.Builder.applyStyles( + node: ASTNode, + typography: Typography, + delimiterStyle: SpanStyle +) { + when (node.type) { + MarkdownElementTypes.STRONG -> { + addStyle( + SpanStyle(fontWeight = FontWeight.Bold), + node.startOffset, + node.endOffset + ) + } + + MarkdownElementTypes.EMPH -> { + addStyle( + SpanStyle(fontStyle = FontStyle.Italic), + node.startOffset, + node.endOffset + ) + } + + MarkdownElementTypes.CODE_SPAN -> { + addStyle( + SpanStyle(fontFamily = FontFamily.Monospace), + node.startOffset, + node.endOffset + ) + } + + MarkdownElementTypes.ATX_1 -> { + addStyle( + typography.headlineLarge.toSpanStyle(), + node.startOffset, + node.endOffset + ) + addStyle( + typography.headlineLarge.toParagraphStyle(), + node.startOffset, + min(node.endOffset + 1, length) + ) + } + + MarkdownElementTypes.ATX_2 -> { + addStyle( + typography.headlineMedium.toSpanStyle(), + node.startOffset, + node.endOffset + ) + addStyle( + typography.headlineMedium.toParagraphStyle(), + node.startOffset, + min(node.endOffset + 1, length) + ) + } + + MarkdownElementTypes.ATX_3 -> { + addStyle( + typography.headlineSmall.toSpanStyle(), + node.startOffset, + node.endOffset + ) + addStyle( + typography.headlineSmall.toParagraphStyle(), + node.startOffset, + min(node.endOffset + 1, length) + ) + } + + MarkdownElementTypes.ATX_4 -> { + addStyle( + typography.titleLarge.toSpanStyle(), + node.startOffset, + node.endOffset + ) + addStyle( + typography.titleLarge.toParagraphStyle(), + node.startOffset, + min(node.endOffset + 1, length) + ) + } + + MarkdownElementTypes.ATX_5 -> { + addStyle( + typography.titleMedium.toSpanStyle(), + node.startOffset, + node.endOffset + ) + addStyle( + typography.titleMedium.toParagraphStyle(), + node.startOffset, + min(node.endOffset + 1, length) + ) + } + + MarkdownElementTypes.ATX_6 -> { + addStyle( + typography.titleSmall.toSpanStyle(), + node.startOffset, + node.endOffset + ) + addStyle( + typography.titleSmall.toParagraphStyle(), + node.startOffset, + min(node.endOffset + 1, length) + ) + } + } + for (child in node.children) { + applyStyles(child, typography, delimiterStyle) + } + + if (node.children.isEmpty() && node.type != MarkdownTokenTypes.TEXT + && node.children.isEmpty() && node.type != MarkdownTokenTypes.LIST_BULLET + && node.children.isEmpty() && node.type != MarkdownTokenTypes.LIST_NUMBER + && node.children.isEmpty() && node.type != MarkdownTokenTypes.WHITE_SPACE + ) { + addStyle( + delimiterStyle, + node.startOffset, + node.endOffset, + ) + } +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/notes/NotesWidget.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/notes/NotesWidget.kt index fe882d4d..0077609d 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/notes/NotesWidget.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/notes/NotesWidget.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import de.mm20.launcher2.ui.component.markdown.MarkdownEditor import de.mm20.launcher2.widgets.NotesWidget @Composable @@ -25,13 +26,9 @@ fun NotesWidget(widget: NotesWidget) { val text by viewModel.noteText - BasicTextField( + MarkdownEditor( value = text, onValueChange = { viewModel.setText(it) }, modifier = Modifier.fillMaxWidth().padding(16.dp), - textStyle = MaterialTheme.typography.bodyMedium.copy( - color = LocalContentColor.current, - ), - cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), ) } \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/notes/NotesWidgetVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/notes/NotesWidgetVM.kt index a9038807..b2c56cb0 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/notes/NotesWidgetVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/notes/NotesWidgetVM.kt @@ -1,5 +1,6 @@ package de.mm20.launcher2.ui.launcher.widgets.notes +import android.util.Log import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -11,6 +12,10 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch +import org.intellij.markdown.ast.ASTNode +import org.intellij.markdown.ast.getTextInNode +import org.intellij.markdown.flavours.commonmark.CommonMarkFlavourDescriptor +import org.intellij.markdown.parser.MarkdownParser import org.koin.core.component.KoinComponent import org.koin.core.component.get diff --git a/core/base/src/main/java/de/mm20/launcher2/licenses/OpenSourceLicenses.kt b/core/base/src/main/java/de/mm20/launcher2/licenses/OpenSourceLicenses.kt index ab77c2f5..18948a07 100644 --- a/core/base/src/main/java/de/mm20/launcher2/licenses/OpenSourceLicenses.kt +++ b/core/base/src/main/java/de/mm20/launcher2/licenses/OpenSourceLicenses.kt @@ -24,6 +24,13 @@ val OpenSourceLicenses = arrayOf( licenseText = R.raw.license_apache_2, url = "https://github.com/Kotlin/kotlinx.collections.immutable" ), + OpenSourceLibrary( + name = "IntelliJ Markdown", + description = "Multiplatform Markdown processor written in Kotlin.", + licenseName = R.string.apache_license_name, + licenseText = R.raw.license_apache_2, + url = "https://github.com/JetBrains/markdown" + ), OpenSourceLibrary( name = "KotlinX Serialization", description = "Kotlin serialization consists of a compiler plugin, that generates visitor code for serializable classes, runtime library with core serialization API and support libraries with various serialization formats.", diff --git a/settings.gradle.kts b/settings.gradle.kts index 0694ddeb..69b95f63 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -34,6 +34,9 @@ dependencyResolutionManagement { library("kotlinx.serialization.json", "org.jetbrains.kotlinx", "kotlinx-serialization-json") .versionRef("kotlinx.serialization") + library("markdown", "org.jetbrains", "markdown") + .version("0.4.1") + version("androidx.compose.compiler", "1.4.5") library("androidx.compose.runtime", "androidx.compose.runtime", "runtime") .version("1.5.0-alpha03")