Parsing and displaying the markdown content nicely

master
Wynd 2025-08-08 19:12:10 +03:00
parent 8229d7a6f0
commit 19e052c7b3
3 changed files with 235 additions and 37 deletions

View File

@ -58,4 +58,5 @@ dependencies {
debugImplementation(libs.androidx.ui.test.manifest) debugImplementation(libs.androidx.ui.test.manifest)
implementation(libs.jtoml) implementation(libs.jtoml)
implementation(libs.commonmark)
} }

View File

@ -1,16 +1,22 @@
package xyz.pixelatedw.recipe package xyz.pixelatedw.recipe
import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.content.Intent import android.content.Intent
import android.content.res.Configuration import android.graphics.Color
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.text.Html
import android.util.Log
import android.view.ViewGroup
import android.widget.TextView
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@ -21,6 +27,9 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -28,20 +37,46 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.ParagraphStyle
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.text.style.TextIndent
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.text.trimmedLength
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import io.github.wasabithumb.jtoml.JToml import io.github.wasabithumb.jtoml.JToml
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import org.commonmark.node.BulletList
import org.commonmark.node.Document
import org.commonmark.node.Emphasis
import org.commonmark.node.HardLineBreak
import org.commonmark.node.Heading
import org.commonmark.node.ListItem
import org.commonmark.node.Node
import org.commonmark.node.OrderedList
import org.commonmark.node.Paragraph
import org.commonmark.node.SoftLineBreak
import org.commonmark.node.StrongEmphasis
import org.commonmark.node.Text
import org.commonmark.node.ThematicBreak
import org.commonmark.parser.Parser
import org.commonmark.renderer.html.HtmlRenderer
import xyz.pixelatedw.recipe.ui.theme.RecipeTheme import xyz.pixelatedw.recipe.ui.theme.RecipeTheme
import java.io.BufferedReader import java.io.BufferedReader
import java.io.InputStreamReader import java.io.InputStreamReader
import androidx.compose.material3.Typography
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@ -105,12 +140,16 @@ class MainActivity : ComponentActivity() {
val inputStream = this.contentResolver.openInputStream(file.uri) val inputStream = this.contentResolver.openInputStream(file.uri)
val reader = BufferedReader(InputStreamReader(inputStream)) val reader = BufferedReader(InputStreamReader(inputStream))
val lines = reader.readLines() val text = reader.readText()
val lines = text.lines()
val sb = StringBuilder() val sb = StringBuilder()
var hasToml = false var hasToml = false
var frontMatterSize = 0
// TODO This could use some improvements as it always assumes frontmatter is the very first thing in the file
for (i in 0..lines.size) { for (i in 0..lines.size) {
val line = lines[i] val line = lines[i]
frontMatterSize += line.trimmedLength() + 1
if (line == "+++") { if (line == "+++") {
if (hasToml) { if (hasToml) {
@ -131,9 +170,13 @@ class MainActivity : ComponentActivity() {
tags.add(tomlElem!!.asPrimitive().asString()) tags.add(tomlElem!!.asPrimitive().asString())
} }
val content = text.substring(frontMatterSize..<text.length)
val recipe = Recipe( val recipe = Recipe(
title = doc["title"]!!.asPrimitive().asString(), title = doc["title"]!!.asPrimitive().asString(),
tags = tags tags = tags,
lastModified = lastModified,
content = content
) )
return recipe return recipe
@ -141,44 +184,67 @@ class MainActivity : ComponentActivity() {
} }
class RecipesView : ViewModel() { class RecipesView : ViewModel() {
private val _activeRecipe = MutableStateFlow<Recipe?>( null )
val activeRecipe = _activeRecipe.asStateFlow()
private val _recipes = MutableStateFlow<List<Recipe>>( arrayListOf() ) private val _recipes = MutableStateFlow<List<Recipe>>( arrayListOf() )
val recipes = _recipes.asStateFlow() val recipes = _recipes.asStateFlow()
public fun addRecipe(recipe: Recipe) { fun addRecipe(recipe: Recipe) {
_recipes.update { _recipes.update {
it + recipe it + recipe
} }
} }
fun setActive(recipe: Recipe) {
_activeRecipe.update { recipe }
}
} }
data class Recipe(val title: String, val tags: List<String>) data class Recipe(
val title: String,
val tags: List<String>,
val lastModified: Long,
val content: String
)
@SuppressLint("NewApi")
@Composable @Composable
fun RecipeList(padding: PaddingValues, view: RecipesView) { fun RecipeList(padding: PaddingValues, view: RecipesView) {
val recipes = view.recipes.collectAsState() val recipes = view.recipes.collectAsState()
val active = view.activeRecipe.collectAsState()
LazyColumn(modifier = Modifier.padding(padding)) { if (active.value == null) {
items(recipes.value) { recipe -> LazyColumn(modifier = Modifier.padding(padding)) {
RecipePreview(recipe) items(recipes.value) { recipe ->
RecipePreview(recipe, view)
}
}
}
else {
Column(modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(padding)) {
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
Text(
text = active.value!!.title,
style = MaterialTheme.typography.displayLarge,
textAlign = TextAlign.Center
)
}
val annotatedString = parseMarkdown(active.value!!.content, MaterialTheme.typography)
Text(text = annotatedString)
} }
} }
} }
//@Preview
//@Composable
//fun RecipeListPreview() {
// val recipes = listOf(
// Recipe(title = "Test", tags = listOf("tag1", "tag2")),
// Recipe(title = "Actual Recipe", tags = listOf("test"))
// )
//// RecipeTheme {
//// RecipeList(PaddingValues(), recipes)
//// }
//}
@Composable @Composable
fun RecipePreview(recipe: Recipe) { fun RecipePreview(recipe: Recipe, view: RecipesView) {
Column(modifier = Modifier.padding(8.dp)) { Column(modifier = Modifier
.padding(8.dp)
.clickable { view.setActive(recipe) }) {
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
Image( Image(
painter = painterResource(R.drawable.ic_launcher_background), painter = painterResource(R.drawable.ic_launcher_background),
@ -207,20 +273,149 @@ fun RecipePreview(recipe: Recipe) {
} }
} }
@Preview( //@Preview(
showBackground = true, // showBackground = true,
name = "Light Mode" // name = "Light Mode"
) //)
@Preview( //@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES, // uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true, // showBackground = true,
name = "Dark Mode" // name = "Dark Mode"
) //)
@Composable //@Composable
fun RecipePreviewPreview() { //fun RecipePreviewPreview() {
RecipeTheme { // RecipeTheme {
Surface { // Surface {
RecipePreview(Recipe("Test", listOf("test", "test2", "test3"))) // RecipePreview(Recipe("Test", listOf("test", "test2", "test3")), active)
// }
// }
//}
private fun parseMarkdown(
markdown: String,
typography: Typography,
): AnnotatedString {
val parser = Parser.builder().build()
val document = parser.parse(markdown)
val annotatedString = buildAnnotatedString {
visitMarkdownNode(document, typography)
}
return annotatedString.trim() as AnnotatedString
}
private fun AnnotatedString.Builder.visitMarkdownNode(
node: Node,
typography: Typography,
) {
val headingColor = androidx.compose.ui.graphics.Color.White
when (node) {
is Heading -> {
val style = when (node.level) {
in 1..3 -> typography.titleLarge
4 -> typography.titleMedium
5 -> typography.bodySmall
else -> typography.bodySmall
}
withStyle(style.toParagraphStyle().merge(ParagraphStyle(textAlign = TextAlign.Center))) {
withStyle(style.toSpanStyle().copy(color = headingColor)) {
visitChildren(node, typography)
appendLine()
}
}
}
is Paragraph -> {
if (node.parents.any { it is BulletList || it is OrderedList }) {
visitChildren(node, typography)
} else {
withStyle(typography.bodyLarge.toParagraphStyle()) {
visitChildren(node, typography)
appendLine()
}
}
}
is Emphasis -> {
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
visitChildren(node, typography)
}
}
is StrongEmphasis -> {
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
visitChildren(node, typography)
}
}
is Text -> {
append(node.literal)
visitChildren(node, typography)
}
is SoftLineBreak -> {
append(" ")
}
is HardLineBreak -> {
appendLine()
}
is ThematicBreak -> {
withStyle(ParagraphStyle(textAlign = TextAlign.Center)) {
withStyle(SpanStyle(letterSpacing = 0.sp)) {
appendLine("".repeat(10))
}
}
}
is OrderedList -> {
withStyle(ParagraphStyle(textIndent = TextIndent(firstLine = 10.sp, restLine = 20.sp))) {
visitChildren(node, typography)
}
}
is BulletList -> {
withStyle(ParagraphStyle(textIndent = TextIndent(firstLine = 10.sp, restLine = 20.sp))) {
visitChildren(node, typography)
}
}
is ListItem -> {
if (node.parents.any { it is BulletList }) {
append("")
} else if (node.parents.any { it is OrderedList }) {
val startNumber = (node.parents.first { it is OrderedList } as OrderedList).markerStartNumber
val index = startNumber + node.previousSiblings.filterIsInstance<ListItem>().size
append("$index. ")
}
visitChildren(node, typography)
appendLine()
}
is Document -> {
visitChildren(node, typography)
}
else -> {
Log.e("MarkdownText", "Traversing unhandled node: $node")
visitChildren(node, typography)
} }
} }
} }
private fun AnnotatedString.Builder.visitChildren(
node: Node,
typography: Typography,
) {
var child = node.firstChild
while (child != null) {
visitMarkdownNode(child, typography)
child = child.next
}
}
private val Node.parents: List<Node> get() {
val list = mutableListOf<Node>()
var current = this
while (true) {
current = current.parent ?: return list
list += current
}
}
private val Node.previousSiblings: List<Node> get() {
val list = mutableListOf<Node>()
var current = this
while (true) {
current = current.previous ?: return list
list += current
}
}

View File

@ -10,6 +10,7 @@ lifecycleRuntimeKtx = "2.9.2"
activityCompose = "1.10.1" activityCompose = "1.10.1"
composeBom = "2024.04.01" composeBom = "2024.04.01"
documentfile = "1.1.0" documentfile = "1.1.0"
commonmark = "0.25.1"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -28,6 +29,7 @@ androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-man
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" } androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" }
commonmark = { group = "org.commonmark", name = "commonmark", version.ref = "commonmark"}
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }