From 2a8d4e34453731e262d37b666ff7480ced49d60e Mon Sep 17 00:00:00 2001 From: MM20 <15646950+MM2-0@users.noreply.github.com> Date: Wed, 26 Apr 2023 18:28:16 +0200 Subject: [PATCH] Note widget: better markdown rendering in read mode --- .../ui/component/markdown/MarkdownEditor.kt | 201 +++---------- .../ui/component/markdown/MarkdownText.kt | 282 ++++++++++++++++++ .../component/markdown/StringAnnotations.kt | 141 +++++++++ 3 files changed, 467 insertions(+), 157 deletions(-) create mode 100644 app/ui/src/main/java/de/mm20/launcher2/ui/component/markdown/MarkdownText.kt create mode 100644 app/ui/src/main/java/de/mm20/launcher2/ui/component/markdown/StringAnnotations.kt 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 index 182ceddc..4b3cb9d2 100644 --- 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 @@ -1,36 +1,32 @@ package de.mm20.launcher2.ui.component.markdown import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable 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.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester 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.flavours.gfm.GFMFlavourDescriptor import org.intellij.markdown.parser.MarkdownParser -import kotlin.math.min @Composable fun MarkdownEditor( @@ -41,44 +37,60 @@ fun MarkdownEditor( val typography = MaterialTheme.typography val delimiterColor = MaterialTheme.colorScheme.secondary val interactionSource = remember { MutableInteractionSource() } - val focused by interactionSource.collectIsFocusedAsState() + var focused by remember { mutableStateOf(false) } + + val focusRequester = remember { FocusRequester() } - val focusManager = LocalFocusManager.current BackHandler( enabled = focused ) { - focusManager.clearFocus() + focused = false } + if (focused) { + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } - 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, - ) + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier.focusRequester(focusRequester), + textStyle = MaterialTheme.typography.bodyMedium.copy( + color = LocalContentColor.current, + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + visualTransformation = remember( + typography, + delimiterColor + ) { MarkdownTransformation(typography, delimiterColor) }, + interactionSource = interactionSource, + ) + + } else { + MarkdownText( + value, + modifier = modifier.clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { + focused = true + }, + onTextChange = onValueChange, + ) + } } + class MarkdownTransformation( private val typography: Typography, - renderDelimiters: Boolean, delimiterColor: Color, ) : VisualTransformation { - private val parser = MarkdownParser(SFMFlavourDescriptor()) + private val parser = MarkdownParser(GFMFlavourDescriptor()) private val delimiterStyle = SpanStyle( color = delimiterColor, - fontSize = if (renderDelimiters) TextUnit.Unspecified else 0.sp ) override fun filter(text: AnnotatedString): TransformedText { @@ -92,128 +104,3 @@ class MarkdownTransformation( ) } } - -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/component/markdown/MarkdownText.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/component/markdown/MarkdownText.kt new file mode 100644 index 00000000..710d5386 --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/component/markdown/MarkdownText.kt @@ -0,0 +1,282 @@ +package de.mm20.launcher2.ui.component.markdown + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Divider +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +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.gfm.GFMFlavourDescriptor +import org.intellij.markdown.flavours.gfm.GFMTokenTypes +import org.intellij.markdown.parser.MarkdownParser + +@Composable +fun MarkdownText( + text: String, + modifier: Modifier = Modifier, + onTextChange: (String) -> Unit, +) { + val parsed = remember(text) { + MarkdownParser(GFMFlavourDescriptor()).buildMarkdownTreeFromString(text) + } + MarkdownText(parsed, text, modifier, onTextChange) +} + +@Composable +fun MarkdownText( + rootNode: ASTNode, text: String, modifier: Modifier = Modifier, + onTextChange: (String) -> Unit, +) { + ProvideTextStyle(MaterialTheme.typography.bodyMedium) { + Column( + modifier = modifier, + ) { + for (child in rootNode.children) { + MarkdownNode(child, text, onTextChange) + } + } + } +} + +@Composable +fun MarkdownNode( + node: ASTNode, text: String, + onTextChange: (String) -> Unit, +) { + when (node.type) { + MarkdownTokenTypes.TEXT -> TextNode(node, text) + MarkdownTokenTypes.CODE_FENCE_CONTENT -> TextNode(node, text) + MarkdownTokenTypes.CODE_LINE -> TextNode(node, text) + MarkdownElementTypes.PARAGRAPH -> ParagraphNode(node, text) + MarkdownElementTypes.ATX_1 -> AtxNode(node, text, 1, onTextChange) + MarkdownElementTypes.ATX_2 -> AtxNode(node, text, 2, onTextChange) + MarkdownElementTypes.ATX_3 -> AtxNode(node, text, 3, onTextChange) + MarkdownElementTypes.ATX_4 -> AtxNode(node, text, 4, onTextChange) + MarkdownElementTypes.ATX_5 -> AtxNode(node, text, 5, onTextChange) + MarkdownElementTypes.ATX_6 -> AtxNode(node, text, 6, onTextChange) + MarkdownTokenTypes.HORIZONTAL_RULE -> Divider(modifier = Modifier.padding(vertical = 4.dp)) + MarkdownElementTypes.UNORDERED_LIST -> UnorderedListNode(node, text, onTextChange) + MarkdownElementTypes.ORDERED_LIST -> OrderedListNode(node, text, onTextChange) + MarkdownElementTypes.BLOCK_QUOTE -> BlockQuoteNode(node, text, onTextChange) + MarkdownElementTypes.CODE_BLOCK -> CodeBlockNode(node, text, onTextChange) + MarkdownElementTypes.CODE_FENCE -> CodeBlockNode(node, text, onTextChange) + + else -> { + ChildNodes(node, text, onTextChange) + } + } +} + +@Composable +fun ChildNodes(node: ASTNode, text: String, onTextChange: (String) -> Unit) { + for (child in node.children) { + MarkdownNode(child, text, onTextChange) + } +} + +@Composable +fun AtxNode(node: ASTNode, text: String, level: Int, onTextChange: (String) -> Unit) { + ProvideTextStyle( + when (level) { + 1 -> MaterialTheme.typography.headlineLarge + 2 -> MaterialTheme.typography.headlineMedium + 3 -> MaterialTheme.typography.headlineSmall + 4 -> MaterialTheme.typography.titleLarge + 5 -> MaterialTheme.typography.titleMedium + else -> MaterialTheme.typography.titleSmall + } + ) { + ChildNodes(node, text, onTextChange) + } +} + +@Composable +fun TextNode(node: ASTNode, text: String) { + val start = node.startOffset + val end = node.endOffset + val substring = text.substring(start, end) + Text( + text = substring, + ) +} + +@Composable +fun ParagraphNode(node: ASTNode, text: String) { + val start = node.startOffset + val end = node.endOffset + val substring = text.substring(start, end) + val typography = MaterialTheme.typography + Text( + text = buildAnnotatedString { + append(substring) + applyStyles(node, typography, SpanStyle(fontSize = 0.sp), node.startOffset) + }, + ) +} + +@Composable +fun UnorderedListNode(node: ASTNode, text: String, onTextChange: (String) -> Unit) { + var counter = 1 + for (child in node.children) { + if (child.type == MarkdownElementTypes.LIST_ITEM) { + ListItemNode(child, counter, text, onTextChange) + counter++ + } + } +} + +@Composable +fun OrderedListNode(node: ASTNode, text: String, onTextChange: (String) -> Unit) { + var counter = 1 + for (child in node.children) { + if (child.type == MarkdownElementTypes.LIST_ITEM) { + ListItemNode(child, counter, text, onTextChange) + counter++ + } + } +} + +@Composable +fun ListItemNode( + node: ASTNode, + index: Int, + text: String, + onTextChange: (String) -> Unit +) { + + Row { + val checkbox = node.children.find { it.type == GFMTokenTypes.CHECK_BOX } + if (checkbox != null) { + CheckboxNode(checkbox, text, onTextChange) + } else { + val ordinal = node.children.find { it.type == MarkdownTokenTypes.LIST_NUMBER } + if (ordinal != null) { + Text( + text = "${index}.", + modifier = Modifier + .width(24.dp) + .padding(end = 4.dp), + textAlign = TextAlign.End, + ) + } else { + Text( + text = "•", + modifier = Modifier + .width(24.dp) + .padding(end = 4.dp), + textAlign = TextAlign.End, + ) + } + } + Column( + modifier = Modifier + .weight(1f) + .padding(top = if (checkbox != null) 4.dp else 0.dp) + ) { + ChildNodes(node, text, onTextChange) + } + + } +} + +@Composable +fun BlockQuoteNode(node: ASTNode, text: String, onTextChange: (String) -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .height(IntrinsicSize.Min) + .clip(MaterialTheme.shapes.extraSmall) + .background(MaterialTheme.colorScheme.surfaceVariant) + ) { + Box( + modifier = Modifier + .width(8.dp) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.primary) + ) + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + CompositionLocalProvider( + LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant + ) { + ChildNodes(node, text, onTextChange) + + } + } + } +} + +@Composable +fun CodeBlockNode(node: ASTNode, text: String, onTextChange: (String) -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clip(MaterialTheme.shapes.extraSmall) + .background(MaterialTheme.colorScheme.surfaceVariant) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + CompositionLocalProvider( + LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant + ) { + ProvideTextStyle( + LocalTextStyle.current.copy(fontFamily = FontFamily.Monospace) + ) { + ChildNodes(node, text, onTextChange) + } + + } + } + } +} + +@Composable +fun CheckboxNode(node: ASTNode, text: String, onTextChange: (String) -> Unit = {}) { + val checkbox = text.substring(node.startOffset, node.endOffset - 1) + val checked = checkbox == "[x]" + + + Checkbox( + checked = checked, onCheckedChange = { + val newCheckbox = if (it) "[x]" else "[ ]" + val newText = text.replaceRange(node.startOffset, node.endOffset - 1, newCheckbox) + onTextChange(newText) + }, modifier = Modifier + .padding(top = 4.dp, bottom = 4.dp, end = 8.dp) + .requiredSize(18.dp) + ) +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/component/markdown/StringAnnotations.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/component/markdown/StringAnnotations.kt new file mode 100644 index 00000000..47a13b12 --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/component/markdown/StringAnnotations.kt @@ -0,0 +1,141 @@ +package de.mm20.launcher2.ui.component.markdown + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import org.intellij.markdown.MarkdownElementTypes +import org.intellij.markdown.MarkdownTokenTypes +import org.intellij.markdown.ast.ASTNode +import kotlin.math.min + +fun AnnotatedString.Builder.applyStyles( + node: ASTNode, + typography: Typography, + delimiterStyle: SpanStyle, + rootOffset: Int = 0, +) { + require(node.startOffset >= rootOffset) { + "Node start offset ${node.startOffset} is smaller than root offset $rootOffset" + } + when (node.type) { + MarkdownElementTypes.STRONG -> { + addStyle( + SpanStyle(fontWeight = FontWeight.Bold), + node.startOffset - rootOffset, + node.endOffset - rootOffset, + ) + } + + MarkdownElementTypes.EMPH -> { + addStyle( + SpanStyle(fontStyle = FontStyle.Italic), + node.startOffset - rootOffset, + node.endOffset - rootOffset, + ) + } + + MarkdownElementTypes.CODE_SPAN -> { + addStyle( + SpanStyle(fontFamily = FontFamily.Monospace), + node.startOffset - rootOffset, + node.endOffset - rootOffset, + ) + } + MarkdownElementTypes.ATX_1 -> { + addStyle( + typography.headlineLarge.toSpanStyle(), + node.startOffset - rootOffset, + node.endOffset - rootOffset, + ) + addStyle( + typography.headlineLarge.toParagraphStyle(), + node.startOffset - rootOffset, + min(node.endOffset + 1 - rootOffset, length) + ) + } + + MarkdownElementTypes.ATX_2 -> { + addStyle( + typography.headlineMedium.toSpanStyle(), + node.startOffset - rootOffset, + node.endOffset - rootOffset, + ) + addStyle( + typography.headlineMedium.toParagraphStyle(), + node.startOffset - rootOffset, + min(node.endOffset + 1 - rootOffset, length) + ) + } + + MarkdownElementTypes.ATX_3 -> { + addStyle( + typography.headlineSmall.toSpanStyle(), + node.startOffset - rootOffset, + node.endOffset - rootOffset, + ) + addStyle( + typography.headlineSmall.toParagraphStyle(), + node.startOffset - rootOffset, + min(node.endOffset + 1 - rootOffset, length) + ) + } + + MarkdownElementTypes.ATX_4 -> { + addStyle( + typography.titleLarge.toSpanStyle(), + node.startOffset - rootOffset, + node.endOffset - rootOffset, + ) + addStyle( + typography.titleLarge.toParagraphStyle(), + node.startOffset - rootOffset, + min(node.endOffset + 1 - rootOffset, length) + ) + } + + MarkdownElementTypes.ATX_5 -> { + addStyle( + typography.titleMedium.toSpanStyle(), + node.startOffset - rootOffset, + node.endOffset - rootOffset, + ) + addStyle( + typography.titleMedium.toParagraphStyle(), + node.startOffset - rootOffset, + min(node.endOffset + 1 - rootOffset, length) + ) + } + + MarkdownElementTypes.ATX_6 -> { + addStyle( + typography.titleSmall.toSpanStyle(), + node.startOffset - rootOffset, + node.endOffset - rootOffset, + ) + addStyle( + typography.titleSmall.toParagraphStyle(), + node.startOffset - rootOffset, + min(node.endOffset + 1 - rootOffset, length) + ) + } + } + for (child in node.children) { + applyStyles(child, typography, delimiterStyle, rootOffset) + } + + if (node.children.isEmpty() && + node.type != MarkdownTokenTypes.TEXT && + node.type != MarkdownTokenTypes.WHITE_SPACE && + node.type != MarkdownTokenTypes.CODE_FENCE_CONTENT && + node.type != MarkdownTokenTypes.CODE_LINE + ) { + addStyle( + delimiterStyle, + node.startOffset - rootOffset, + node.endOffset - rootOffset, + ) + } +} \ No newline at end of file