From 19e052c7b36978241cb6d93768ba85ef356db8fc Mon Sep 17 00:00:00 2001 From: Wynd Date: Fri, 8 Aug 2025 19:12:10 +0300 Subject: [PATCH] Parsing and displaying the markdown content nicely --- app/build.gradle.kts | 1 + .../xyz/pixelatedw/recipe/MainActivity.kt | 269 +++++++++++++++--- gradle/libs.versions.toml | 2 + 3 files changed, 235 insertions(+), 37 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8ef7608..2898e6b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -58,4 +58,5 @@ dependencies { debugImplementation(libs.androidx.ui.test.manifest) implementation(libs.jtoml) + implementation(libs.commonmark) } diff --git a/app/src/main/java/xyz/pixelatedw/recipe/MainActivity.kt b/app/src/main/java/xyz/pixelatedw/recipe/MainActivity.kt index e4e165c..9870a52 100644 --- a/app/src/main/java/xyz/pixelatedw/recipe/MainActivity.kt +++ b/app/src/main/java/xyz/pixelatedw/recipe/MainActivity.kt @@ -1,16 +1,22 @@ package xyz.pixelatedw.recipe +import android.annotation.SuppressLint import android.app.Activity import android.content.Intent -import android.content.res.Configuration +import android.graphics.Color import android.net.Uri 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.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column 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.lazy.LazyColumn 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.Surface import androidx.compose.material3.Text @@ -28,20 +37,46 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier 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.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.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.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.lifecycle.ViewModel import io.github.wasabithumb.jtoml.JToml import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow 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 java.io.BufferedReader import java.io.InputStreamReader +import androidx.compose.material3.Typography class MainActivity : ComponentActivity() { @@ -105,12 +140,16 @@ class MainActivity : ComponentActivity() { val inputStream = this.contentResolver.openInputStream(file.uri) val reader = BufferedReader(InputStreamReader(inputStream)) - val lines = reader.readLines() + val text = reader.readText() + val lines = text.lines() val sb = StringBuilder() 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) { val line = lines[i] + frontMatterSize += line.trimmedLength() + 1 if (line == "+++") { if (hasToml) { @@ -131,9 +170,13 @@ class MainActivity : ComponentActivity() { tags.add(tomlElem!!.asPrimitive().asString()) } + val content = text.substring(frontMatterSize..( null ) + val activeRecipe = _activeRecipe.asStateFlow() + private val _recipes = MutableStateFlow>( arrayListOf() ) val recipes = _recipes.asStateFlow() - public fun addRecipe(recipe: Recipe) { + fun addRecipe(recipe: Recipe) { _recipes.update { it + recipe } } + + fun setActive(recipe: Recipe) { + _activeRecipe.update { recipe } + } } -data class Recipe(val title: String, val tags: List) +data class Recipe( + val title: String, + val tags: List, + val lastModified: Long, + val content: String +) +@SuppressLint("NewApi") @Composable fun RecipeList(padding: PaddingValues, view: RecipesView) { val recipes = view.recipes.collectAsState() + val active = view.activeRecipe.collectAsState() - LazyColumn(modifier = Modifier.padding(padding)) { - items(recipes.value) { recipe -> - RecipePreview(recipe) + if (active.value == null) { + LazyColumn(modifier = Modifier.padding(padding)) { + 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 -fun RecipePreview(recipe: Recipe) { - Column(modifier = Modifier.padding(8.dp)) { +fun RecipePreview(recipe: Recipe, view: RecipesView) { + Column(modifier = Modifier + .padding(8.dp) + .clickable { view.setActive(recipe) }) { Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { Image( painter = painterResource(R.drawable.ic_launcher_background), @@ -207,20 +273,149 @@ fun RecipePreview(recipe: Recipe) { } } -@Preview( - showBackground = true, - name = "Light Mode" -) -@Preview( - uiMode = Configuration.UI_MODE_NIGHT_YES, - showBackground = true, - name = "Dark Mode" -) -@Composable -fun RecipePreviewPreview() { - RecipeTheme { - Surface { - RecipePreview(Recipe("Test", listOf("test", "test2", "test3"))) +//@Preview( +// showBackground = true, +// name = "Light Mode" +//) +//@Preview( +// uiMode = Configuration.UI_MODE_NIGHT_YES, +// showBackground = true, +// name = "Dark Mode" +//) +//@Composable +//fun RecipePreviewPreview() { +// RecipeTheme { +// Surface { +// 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().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 get() { + val list = mutableListOf() + var current = this + while (true) { + current = current.parent ?: return list + list += current + } +} + +private val Node.previousSiblings: List get() { + val list = mutableListOf() + var current = this + while (true) { + current = current.previous ?: return list + list += current + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e86f4ea..a7f120b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,7 @@ lifecycleRuntimeKtx = "2.9.2" activityCompose = "1.10.1" composeBom = "2024.04.01" documentfile = "1.1.0" +commonmark = "0.25.1" [libraries] 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-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" } +commonmark = { group = "org.commonmark", name = "commonmark", version.ref = "commonmark"} [plugins] android-application = { id = "com.android.application", version.ref = "agp" }