Note widget: better markdown rendering in read mode

This commit is contained in:
MM20 2023-04-26 18:28:16 +02:00
parent 5a3be88e5f
commit 2a8d4e3445
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
3 changed files with 467 additions and 157 deletions

View File

@ -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,
)
}
}

View File

@ -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)
)
}

View File

@ -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,
)
}
}