Note widget: better markdown rendering in read mode
This commit is contained in:
parent
5a3be88e5f
commit
2a8d4e3445
@ -1,36 +1,32 @@
|
|||||||
package de.mm20.launcher2.ui.component.markdown
|
package de.mm20.launcher2.ui.component.markdown
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.material3.LocalContentColor
|
import androidx.compose.material3.LocalContentColor
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Typography
|
import androidx.compose.material3.Typography
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
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.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
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.OffsetMapping
|
||||||
import androidx.compose.ui.text.input.TransformedText
|
import androidx.compose.ui.text.input.TransformedText
|
||||||
import androidx.compose.ui.text.input.VisualTransformation
|
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.ast.ASTNode
|
||||||
import org.intellij.markdown.flavours.space.SFMFlavourDescriptor
|
import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor
|
||||||
import org.intellij.markdown.parser.MarkdownParser
|
import org.intellij.markdown.parser.MarkdownParser
|
||||||
import kotlin.math.min
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MarkdownEditor(
|
fun MarkdownEditor(
|
||||||
@ -41,44 +37,60 @@ fun MarkdownEditor(
|
|||||||
val typography = MaterialTheme.typography
|
val typography = MaterialTheme.typography
|
||||||
val delimiterColor = MaterialTheme.colorScheme.secondary
|
val delimiterColor = MaterialTheme.colorScheme.secondary
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
val focused by interactionSource.collectIsFocusedAsState()
|
var focused by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
val focusManager = LocalFocusManager.current
|
|
||||||
|
|
||||||
BackHandler(
|
BackHandler(
|
||||||
enabled = focused
|
enabled = focused
|
||||||
) {
|
) {
|
||||||
focusManager.clearFocus()
|
focused = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (focused) {
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
|
||||||
BasicTextField(
|
BasicTextField(
|
||||||
value = value,
|
value = value,
|
||||||
onValueChange = onValueChange,
|
onValueChange = onValueChange,
|
||||||
modifier = modifier,
|
modifier = modifier.focusRequester(focusRequester),
|
||||||
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||||
color = LocalContentColor.current,
|
color = LocalContentColor.current,
|
||||||
),
|
),
|
||||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||||
visualTransformation = remember(
|
visualTransformation = remember(
|
||||||
typography,
|
typography,
|
||||||
focused,
|
delimiterColor
|
||||||
delimiterColor
|
) { MarkdownTransformation(typography, delimiterColor) },
|
||||||
) { MarkdownTransformation(typography, focused, delimiterColor) },
|
interactionSource = interactionSource,
|
||||||
interactionSource = interactionSource,
|
)
|
||||||
)
|
|
||||||
|
} else {
|
||||||
|
MarkdownText(
|
||||||
|
value,
|
||||||
|
modifier = modifier.clickable(
|
||||||
|
indication = null,
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
) {
|
||||||
|
focused = true
|
||||||
|
},
|
||||||
|
onTextChange = onValueChange,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class MarkdownTransformation(
|
class MarkdownTransformation(
|
||||||
private val typography: Typography,
|
private val typography: Typography,
|
||||||
renderDelimiters: Boolean,
|
|
||||||
delimiterColor: Color,
|
delimiterColor: Color,
|
||||||
) : VisualTransformation {
|
) : VisualTransformation {
|
||||||
|
|
||||||
private val parser = MarkdownParser(SFMFlavourDescriptor())
|
private val parser = MarkdownParser(GFMFlavourDescriptor())
|
||||||
private val delimiterStyle = SpanStyle(
|
private val delimiterStyle = SpanStyle(
|
||||||
color = delimiterColor,
|
color = delimiterColor,
|
||||||
fontSize = if (renderDelimiters) TextUnit.Unspecified else 0.sp
|
|
||||||
)
|
)
|
||||||
|
|
||||||
override fun filter(text: AnnotatedString): TransformedText {
|
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user